From c9a6642ac13b6a13eecc08e11e957123f197c04f Mon Sep 17 00:00:00 2001 From: AAA <35992542+TNTcraftHIM@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:59:44 +0800 Subject: [PATCH] feat(media): share Browser encoding and complete local diagnostics * fix: allow Linux interface discovery in embedded media service * feat: expand local diagnostic reports * research: validate independent Browser encoding producers * feat: prototype Browser pool ownership and record remaining media gates * feat: refine Browser shared encoders and native output reuse * Refine carrier costs and simplify release entry points * Close media phase and prepare piik design handoff --- .github/workflows/repository-hygiene.yml | 55 +-- AGENTS.md | 9 +- LICENSE.zh-CN.md | 22 + README.md | 171 +++----- README.zh-CN.md | 133 +++---- cmd/screener-client/README.md | 4 +- cmd/screener-server/main.go | 30 +- deploy/systemd/screener.service.example | 3 +- docs/README.md | 164 ++++---- .../0014-browser-node-local-encoding-pool.md | 117 ++++++ docs/product/media-quality.md | 18 +- docs/project-memory.md | 6 +- docs/reference/configuration.md | 124 +++--- docs/research/advanced-peer-distribution.md | 12 +- docs/research/browser-local-encoding-pool.md | 163 ++++++++ docs/research/data/browser-local-pool.json | 31 ++ docs/research/diagnostic-feedback.md | 53 +++ docs/status.md | 217 ++++------ docs/todo.md | 123 +++--- docs/verification-status.md | 252 ++++-------- go.mod | 8 +- internal/client/clientapp/app.go | 14 +- internal/client/mediaedge/edge.go | 23 +- internal/client/mediaedge/engine.go | 12 +- internal/client/mediaedge/groups.go | 67 +++- internal/client/mediaedge/groups_test.go | 25 ++ internal/client/mediaedge/quality.go | 11 + internal/client/mediaedge/relay_derivation.go | 16 +- internal/client/mediaedge/source.go | 3 - internal/client/nativecapture/probe.go | 26 +- internal/client/nativecapture/stream.go | 53 ++- internal/client/nativecapture/stream_test.go | 17 + internal/client/nativecontrol/publication.go | 6 +- internal/client/nativecontrol/session.go | 103 +++-- internal/client/nativecontrol/session_test.go | 32 ++ internal/client/nativehost/session.go | 35 +- internal/client/nativehost/session_test.go | 7 +- internal/client/portmapping/mapping.go | 10 +- internal/diagnostics/logging.go | 221 +++++++++++ internal/diagnostics/recorder.go | 181 ++++++--- internal/diagnostics/recorder_test.go | 90 ++++- internal/media/forwarding/publication.go | 21 +- internal/media/forwarding/source.go | 6 +- internal/media/forwarding/transport.go | 26 +- internal/server/app/app.go | 7 +- native/capture/windows/adaptive_encoder.cpp | 61 ++- native/capture/windows/adaptive_encoder.h | 3 +- native/capture/windows/main.cpp | 2 +- scripts/browser-local-pool-page.js | 375 ++++++++++++++++++ scripts/browser-local-pool-probe.ts | 105 +++++ scripts/browser-local-pool-summary.mjs | 63 +++ scripts/browser-pool-shaper.mjs | 66 +++ scripts/client-native-host-gate.ts | 2 +- src/client/components/living/Header.tsx | 29 ++ src/client/components/living/hints/index.tsx | 2 + src/client/components/living/hints/set3.tsx | 19 + src/client/lib/debug-webrtc.ts | 61 +++ src/client/lib/debug.ts | 303 ++++++++------ src/client/lib/signaling.ts | 2 + src/client/locales/en.ts | 3 + src/client/locales/zh.ts | 3 + src/client/media/browser-encoding-output.ts | 222 +++++++++++ src/client/media/browser-encoding-pool.ts | 359 +++++++++++++++++ src/client/media/browser-encoding-producer.ts | 223 +++++++++++ src/client/media/browser-encoding-stats.ts | 74 ++++ src/client/media/host-provisional-child.ts | 3 + src/client/media/quality.ts | 25 +- src/client/native/client.ts | 29 +- src/client/native/media-bridge.ts | 5 +- src/client/pages/HostPage.tsx | 26 ++ src/client/sfu/peer.ts | 3 + src/client/sfu/publisher.ts | 7 +- src/client/sfu/subscriber.ts | 5 +- src/client/webrtc/host-peer.ts | 104 ++++- src/client/webrtc/stats.ts | 15 +- src/client/webrtc/viewer-peer.ts | 4 + src/client/webrtc/viewer-relay.ts | 4 + tests/browser-debug.test.ts | 195 ++++++--- tests/host-quality-ownership.test.ts | 2 + 79 files changed, 3955 insertions(+), 1176 deletions(-) create mode 100644 LICENSE.zh-CN.md create mode 100644 docs/adr/0014-browser-node-local-encoding-pool.md create mode 100644 docs/research/browser-local-encoding-pool.md create mode 100644 docs/research/data/browser-local-pool.json create mode 100644 docs/research/diagnostic-feedback.md create mode 100644 internal/diagnostics/logging.go create mode 100644 scripts/browser-local-pool-page.js create mode 100644 scripts/browser-local-pool-probe.ts create mode 100644 scripts/browser-local-pool-summary.mjs create mode 100644 scripts/browser-pool-shaper.mjs create mode 100644 src/client/lib/debug-webrtc.ts create mode 100644 src/client/media/browser-encoding-output.ts create mode 100644 src/client/media/browser-encoding-pool.ts create mode 100644 src/client/media/browser-encoding-producer.ts create mode 100644 src/client/media/browser-encoding-stats.ts diff --git a/.github/workflows/repository-hygiene.yml b/.github/workflows/repository-hygiene.yml index 0f4bfe6..e9db5ef 100644 --- a/.github/workflows/repository-hygiene.yml +++ b/.github/workflows/repository-hygiene.yml @@ -18,8 +18,6 @@ permissions: jobs: validate: runs-on: ubuntu-latest - outputs: - client_scope: ${{ steps.client-scope.outputs.run }} steps: - name: Check out repository uses: actions/checkout@v6 @@ -34,56 +32,6 @@ jobs: run: sh scripts/check-project-state.sh env: SCREENER_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} - - name: Detect Screener Client scope - id: client-scope - shell: bash - env: - BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} - EXPLICIT_CLIENT_CHECKS: ${{ inputs.client_checks || false }} - run: | - run_client=false - if [ "$EXPLICIT_CLIENT_CHECKS" = "true" ]; then - run_client=true - elif [ "$GITHUB_EVENT_NAME" != "workflow_dispatch" ]; then - base="$BASE_SHA" - case "$base" in - ""|0000000000000000000000000000000000000000) - base=$(git rev-parse HEAD^ 2>/dev/null || true) - ;; - esac - if [ -n "$base" ] && ! git diff --quiet "$base" HEAD -- \ - .github/workflows/repository-hygiene.yml \ - .node-version \ - LICENSE \ - licenses \ - index.html \ - cmd \ - internal \ - native \ - go.mod \ - go.sum \ - package.json \ - package-lock.json \ - public \ - src \ - tsconfig.json \ - tsconfig.app.json \ - vite.config.ts \ - scripts/assemble-client.mjs \ - scripts/archive-tool.mjs \ - scripts/client-icons.mjs \ - scripts/package-app-release.mjs \ - scripts/package-licenses.mjs \ - scripts/package-client-candidate.mjs \ - scripts/client-package-targets.mjs \ - scripts/check-client.mjs \ - scripts/client-local-gate.ts \ - scripts/client-loopback-gate.ts \ - scripts/client-media-gate.ts; then - run_client=true - fi - fi - echo "run=$run_client" >> "$GITHUB_OUTPUT" - name: Install dependencies run: npm ci # The Go build embeds internal/server/webassets/dist, so Vite runs first. @@ -145,8 +93,7 @@ jobs: - validate - release-application if: >- - (github.event_name == 'push' && github.ref == 'refs/heads/main') || - needs.validate.outputs.client_scope == 'true' + github.event_name == 'workflow_dispatch' && inputs.client_checks == true strategy: fail-fast: false matrix: diff --git a/AGENTS.md b/AGENTS.md index b8220c2..b23e853 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,13 +88,20 @@ - Prefer the simplest cohesive design that is easy to use and maintain. Reuse mature components, keep ownership explicit, and remove superseded paths in the same change instead of retaining speculative flexibility or technical debt. +- Apply good coding practice to every change: split modules around clear + responsibilities and state/lifecycle ownership, with cohesive internals and + narrow interfaces. Reuse proven shared behavior and abstract stable common + responsibilities; avoid both duplicate implementations and needless wrappers. + Review coupling and readability, not only file size or line count. - Judge a change by evidenced user value against its full implementation, maintenance, compatibility, and failure cost. A large measured gain may justify broad or breaking work; a small or speculative gain does not justify material complexity or risk. - After completing a material module, run one ablation pass and remove every new component, state, dependency, or branch that is not needed for the verified - result or an accepted contract. + result or an accepted contract. Its primary goal is lower total system + complexity, not perfect performance; marginal gains do not justify permanent + mechanisms or a materially larger maintenance surface. - Use primary sources for non-trivial design and bugs. Add focused tests in proportion to risk; batch full browser/network/endurance checks at acceptance. - Keep scripts deterministic, fast, cross-platform, and CI-runnable. Support diff --git a/LICENSE.zh-CN.md b/LICENSE.zh-CN.md new file mode 100644 index 0000000..a3c56bb --- /dev/null +++ b/LICENSE.zh-CN.md @@ -0,0 +1,22 @@ +# MIT 许可证中文参考译文 + +The authoritative license is the English [LICENSE](./LICENSE). +This translation is for convenience and adds no terms or restrictions. + +本文件为方便阅读提供中文参考译文,不替代英文 [LICENSE](./LICENSE), +也不增加条件或限制。Screener 自有代码的 MIT 许可不替代 +[第三方组件的许可与声明](./licenses/README.md)。 + +版权所有 (c) 2026 TNTcraft 和 Screener 贡献者 + +特此免费授予任何获得本软件及相关文档文件(以下简称“软件”)副本的人, +不受限制地处置本软件的权利,包括但不限于使用、复制、修改、合并、出版、 +分发、再许可和/或销售本软件副本,并允许获得软件的人这样做, +但须遵守以下条件: + +上述版权声明和本许可声明应包含在本软件的所有副本或实质部分中。 + +本软件按“原样”提供,不作任何明示或默示保证,包括但不限于适销性、 +特定用途适用性和不侵权的保证。在任何情况下,无论依据合同、侵权或其他 +法律理论,作者或版权持有人均不对因软件、软件的使用或其他处置而产生、 +源自或与之有关的任何索赔、损害或其他责任负责。 diff --git a/README.md b/README.md index ff9b88d..1672b0e 100644 --- a/README.md +++ b/README.md @@ -1,124 +1,83 @@ +Screener TV logo + # Screener English | [简体中文](./README.zh-CN.md) -Screener is a private, low-latency screen-sharing tool for one game player and -up to 20 authenticated friends. Web Viewers need no installation. Media is -automatic and P2P-first, with bounded Browser relay and one embedded SFU/UDP -fallback. The Hosted Go process owns signaling, STUN, and admitted SFU media. +Share your screen. Bring your friends. -Windows Client and Browser are the current acceptance targets. macOS and Linux -have source/build support; their physical acceptance is separate. See -[current status](./docs/status.md) for release readiness and the distinction -between current source and the last recorded production deployment. +Screener is private screen sharing for one Host and up to 20 invited friends. +Friends watch in their browsers, with no installation. -## Screener Client - -For a matching Client bundle: - -1. Extract the whole package, keeping its `runtime` directory beside the executable. -2. Run `screener-client.exe` on Windows, `./screener-client` on Linux, or - `Screener Client.app` on macOS. -3. Choose a mode, select a source, and send the room invitation to your friends. - -The Client opens the interface in your system browser; it has no embedded -browser UI. The package carries the Go application and native capture/public-link -helpers. Running the package does not require Node.js, npm, or Go. Linux native -capture uses system Portal/PipeWire/GStreamer dependencies; see the -[Client guide](./cmd/screener-client/README.md). - -- **Local:** share with devices on the same LAN. -- **Public link:** send an ordinary invitation link. Cloudflare Quick Tunnel - carries room access and signaling; media travels between peers without an SFU. - The link lasts for that Client run. Relaunching creates a new temporary link; - Quick Tunnel provides no uptime guarantee. -- **Site:** open a saved Screener server and use the running Client for native - capture or compatible media forwarding. The Site can provide its SFU fallback. - -Choose Browser, application/window, or screen in the source picker. Native -screen audio uses the default system output; window audio uses the selected -application when supported. Audio has a separate sharing switch. Native capture -on Windows offers VP8, Auto, and H264 in the same controls as Browser capture. -Other native platform adapters currently use hardware H.264. - -Native parents reuse encoded outputs for compatible direct children and derive -a missing lower output only when needed. Each endpoint has a bounded outgoing-copy -capacity, default two; incompatible weak paths can use separate local outputs. -Native SFU publication uses that same source. Ordinary Browser capture and relay -retain WebRTC senders; encoded reuse is a Native capability. See -[media quality](./docs/product/media-quality.md) for the behavior and bounds. - -Media requires a working UDP path; P2P does not guarantee connectivity between -every network pair. Browser/OS suspension and network policy can interrupt a -share. Client access is open by default; its launcher can set an optional -site-access password. This does not change access rules on a saved Site. - -If a Chromium-based Browser cannot establish media connections, see the -[Chromium WebRTC FAQ](./cmd/screener-client/README.md#chromium-webrtc-connections). - -For feedback, start the Client with `--debug`, reproduce the problem, and press -`D` in its terminal to export a local diagnostic ZIP. Browser diagnostics are -separate; see [diagnostics and export](./docs/reference/configuration.md#diagnostics). - -## Self-Hosting - -The server package runs `screener-server` with the Browser assets embedded in -the binary. Initial setup, configuration and coordinated embedded-media cutover -are documented in [self-hosting](./docs/operations/self-hosting.md). -[Deployment and recovery](./docs/deployment.md) covers matching artifacts and -later application updates. These instructions do not imply that a public -GitHub Release or Docker image has already been published. -An [optional container recipe](./docs/operations/self-hosting.md#optional-container) -uses the same verified Server release. - -## Local Development - -Node.js 24, npm 11, and Go 1.26 are required. - -```sh -npm ci -npm run dev +```mermaid +flowchart LR + Browser[Browser] --> Host[Host a room] + Client[Screener Client] --> Host + Host -->|Invite link| Friends[Friends watch in browsers] ``` -In a second terminal, run the application server that the dev server proxies. -The command below uses POSIX environment syntax; PowerShell uses the corresponding -`$env:` variables. +## Join a friend -```sh -PORT=8788 PUBLIC_BASE_URL=http://localhost:8787 go run ./cmd/screener-server -``` +1. Open the invitation your friend sent. +2. Press Play if your browser asks. Use the video's sound and fullscreen controls. -Open `http://localhost:8787`. The development server listens on `0.0.0.0`, so a -phone on the same LAN may use `http://:8787`. Screen capture -must remain on `localhost` or HTTPS. Set `PUBLIC_BASE_URL` and -`ALLOWED_ORIGINS` when generated invitations need a different LAN origin. +Got a room code instead? Open the same Screener site and choose **Join a room**. +Site access and the room's password settings still apply. -Production requires an independent visible-ASCII `SITE_ACCESS_PASSWORD`; local -development and tests may leave it empty. Cross-network setup is documented in -[self-hosting](./docs/operations/self-hosting.md) and -[configuration reference](./docs/reference/configuration.md). Never commit real -credentials. +## Share from your browser -Run complete automated validation with: +1. Open the Screener site your group uses. Enter its site password if asked. +2. Choose **Start sharing**, pick a screen, window or tab, and check the sound switch. +3. Send the room's invitation link to your friends. Keep the sharing tab open. -```sh -npm run check -npm run check:client -``` +Capture needs HTTPS or `localhost`; available sources and sound depend on the +browser and platform. English, Chinese and an illustrated visual mode share the +same controls, with light and dark themes. -## Documentation +## Start your own room with Client -- [Product and repository map](./docs/README.md) -- [Rooms and access](./docs/product/rooms-access.md) -- [Routing and transport](./docs/product/routing-transport.md) -- [Capture and media quality](./docs/product/media-quality.md) -- [Presentation and lifecycle](./docs/product/presentation-lifecycle.md) -- [Current work](./docs/todo.md) -- [Contributing workflow](./CONTRIBUTING.md) +With a Client package for your platform: + +1. Extract the whole package, keeping `runtime` beside the executable. +2. Open `screener-client.exe` on Windows, `Screener Client.app` on macOS, or + `./screener-client` on Linux. +3. Choose a mode, pick what to share, and send the invitation. Keep Client running. + +| Mode | Use it for | +| --- | --- | +| **Local room** | Friends on the same local network. An optional site password controls access. | +| **Public invite** | A temporary Internet invitation. The link lasts for this Client run; media remains P2P. | +| **Connect to Site** | An existing Screener site, with Client capture and media capabilities available in the same browser UI. | + +Client opens your system browser. Its package includes the Go application and +capture/link helpers; running it needs no Node.js, npm or Go installation. +Linux native capture uses the system Portal, PipeWire and GStreamer stack. +See the [Client guide](./cmd/screener-client/README.md) for platform details. + +Temporary public links use Cloudflare Quick Tunnel, which has no uptime guarantee. +Media needs a working UDP path; restrictive networks or browser/OS suspension +can interrupt sharing. Windows Client and Browser are the current acceptance +focus; macOS/Linux physical capture checks remain separate. See +[current status](./docs/status.md) for candidate and release readiness. + +## Host a site or contribute + +The Server package is one Go executable with the Web UI embedded. It owns room +signaling, STUN and optional embedded SFU fallback. Follow the +[self-hosting guide](./docs/operations/self-hosting.md) to set up your own site. + +For source builds, tests and architecture, start with the +[documentation map](./docs/README.md). [Contributing](./CONTRIBUTING.md) covers the workflow. + +## Something went wrong? + +Include your version, OS/browser, expected result and steps to reproduce. +For a local report, start Client with `--debug` and press `D`, or add `?debug=1` +before any `#` in the Browser page URL and use its header download button. Review reports before +sharing; see [diagnostics and export](./docs/reference/configuration.md#diagnostics). +Connection trouble? Try the [Chromium WebRTC FAQ](./cmd/screener-client/README.md#chromium-webrtc-connections). ## License -Screener's own code is [MIT licensed](./LICENSE). Redistributed components retain -their own licenses and notices; see [third-party licensing](./licenses/README.md). -The software is provided "AS IS", without warranty; performance and connectivity -depend on the devices, Browsers and networks in use. +Screener-owned code is [MIT licensed](./LICENSE). Third-party components retain +their own licenses and notices: [licensing guide](./licenses/README.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index 2ff536f..d3f7b0b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,92 +1,79 @@ +Screener 小电视标志 + # Screener [English](./README.md) | 简体中文 -Screener 是面向一位游戏玩家和最多 20 位已获授权好友的私密、低延迟屏幕分享工具。 -观看者只需浏览器,无须安装。媒体自动选择路径,优先 P2P,支持有容量限制的浏览器中继和 -内置 SFU/UDP 后备路径。Hosted Go 进程统一承载信令、STUN 和已授权的 SFU 媒体。 +分享你的屏幕,叫上朋友一起看。 -当前阶段以 Windows Client 和浏览器为验收目标。macOS、Linux 保留源码和构建支持, -实机验收另行进行。[当前状态](./docs/status.md) 区分源码能力、发布准备情况和最近记录的生产部署。 +Screener 是一个私密屏幕分享工具:一位房主,最多 20 位受邀好友。 +朋友用浏览器就能观看,无须安装。 -## 使用 Screener Client +```mermaid +flowchart LR + Browser[浏览器] --> Host[开房分享] + Client[Screener Client] --> Host + Host -->|邀请链接| Friends[好友用浏览器观看] +``` -拿到相互匹配的 Client 程序包后: +## 去朋友的房间 + +1. 打开朋友发来的邀请链接。 +2. 如果浏览器提示,点击播放。声音和全屏由视频控件控制。 + +只有房间号?打开同一个 Screener 站点,选择 **加入房间**。 +站点访问权限和房间密码设置仍然适用。 + +## 用浏览器分享 + +1. 打开你们使用的 Screener 站点,按提示输入站点密码。 +2. 点击 **开始分享**,选择屏幕、窗口或标签页,并检查声音开关。 +3. 把房间邀请链接发给朋友,保持分享标签页打开。 + +浏览器采集需要 HTTPS 或 `localhost`;可选来源和声音支持取决于浏览器与平台。 +界面提供中文、英文和图示模式,以及明暗主题。 + +## 用 Client 自己开房 + +拿到对应平台的 Client 程序包后: 1. 完整解压,保留可执行文件旁的 `runtime` 目录。 -2. Windows 运行 `screener-client.exe`,Linux 运行 `./screener-client`, - macOS 打开 `Screener Client.app`。 -3. 选择模式和分享来源,将房间邀请链接发给好友。 +2. Windows 打开 `screener-client.exe`,macOS 打开 `Screener Client.app`, + Linux 运行 `./screener-client`。 +3. 选择模式和分享来源,发出邀请。分享期间保持 Client 运行。 -Client 使用系统浏览器展示界面,没有内嵌浏览器界面。程序包包含 Go 应用以及原生采集、 -公网链接所需的辅助程序,运行时无须安装 Node.js、npm 或 Go。Linux 原生采集依赖系统的 -Portal、PipeWire 和 GStreamer,详见 [Client 指南](./cmd/screener-client/README.md)。 +| 模式 | 适合什么情况 | +| --- | --- | +| **本地房间** | 与同一局域网内的好友分享,可设置站点访问密码。 | +| **公网邀请** | 生成本次 Client 运行期间有效的临时邀请链接;媒体仍走 P2P。 | +| **连接站点** | 使用已有 Screener 站点,并在同一浏览器界面使用 Client 的采集与媒体能力。 | -- **Local:** 与同一局域网中的设备分享。 -- **Public link:** 通过普通邀请链接分享。Cloudflare Quick Tunnel 只承载房间访问和信令, - 媒体仍通过 P2P 传输,不提供 SFU。链接只在本次 Client 运行期间有效,重启会创建新的临时链接, - Quick Tunnel 不保证持续可用。 -- **Site:** 打开已保存的 Screener 站点,并使用正在运行的 Client 进行原生采集或媒体转发。 - 站点可以提供 SFU 后备路径。 +Client 会打开系统浏览器。程序包包含 Go 应用和采集、临时链接辅助程序, +运行时无须安装 Node.js、npm 或 Go。Linux 原生采集使用系统的 Portal、PipeWire +和 GStreamer,平台细节见 [Client 指南](./cmd/screener-client/README.md)。 -在来源选择器中选择浏览器、应用窗口或屏幕。原生屏幕音频来自默认系统输出;支持时, -窗口音频来自选中的应用。音频有独立开关。Windows 原生采集提供 VP8、Auto、H264 选项, -其他原生平台目前使用硬件 H.264。 +临时公网链接使用 Cloudflare Quick Tunnel,不保证持续可用。 +媒体需要可用的 UDP 路径;网络限制、浏览器或系统挂起都可能中断分享。 +当前以 Windows Client 和浏览器为验收重点,macOS/Linux 原生采集的实机验证另行进行。 +候选版本与发布准备情况见 [当前状态](./docs/status.md)。 -原生节点复用适合直属观看者的编码输出,仅在需要时生成缺少的较低输出。每个节点的持续 -向外发送份数有上限,默认两份;不兼容的弱连接可以使用独立的本地编码输出。原生 SFU 发布 -复用同一来源。纯浏览器采集和中继仍使用正常的 WebRTC 发送器;编码复用属于原生节点能力。 -行为和边界见 [媒体质量](./docs/product/media-quality.md)。 +## 自建站点或参与开发 -媒体需要可用的 UDP 路径,P2P 不保证任意两个网络都能互通。浏览器或操作系统挂起、网络策略 -都可能中断分享。Client 本地站点默认开放,可在启动器中设置可选访问密码;这不会改变已保存 -Site 的访问规则。 +Server 是一个内嵌 Web 界面的 Go 可执行文件,负责房间信令、STUN 和可选的内置 +SFU 后备路径。搭建自己的站点,请看 [自托管指南](./docs/operations/self-hosting.md)。 -Chromium 系浏览器无法建立媒体连接时,请查看 -[Chromium WebRTC 常见问题](./cmd/screener-client/README.md#chromium-webrtc-connections)。 -反馈问题时,以 `--debug` 启动 Client,复现后在终端按 `D` 导出本地诊断 ZIP。 -浏览器诊断单独导出,详见 [诊断与导出](./docs/reference/configuration.md#diagnostics)。 +源码运行、测试和架构从 [文档地图](./docs/README.md) 开始; +[贡献指南](./CONTRIBUTING.md) 说明协作流程。 -## 自行部署 +## 遇到问题? -服务器程序包运行 `screener-server`,浏览器资源已嵌入 Go 可执行文件。首次部署、配置及内置 -媒体服务的协同切换见 [自托管指南](./docs/operations/self-hosting.md);匹配的构建产物、 -后续更新与恢复见 [部署与恢复](./docs/deployment.md)。文档中的流程不代表已经发布了公开的 -GitHub Release 或 Docker 镜像。 -[可选容器方案](./docs/operations/self-hosting.md#optional-container) 使用同一套已验证的服务器构建产物。 +反馈时附上版本、系统与浏览器、预期结果和复现步骤。 +Client 可用 `--debug` 启动后按 `D` 导出本地报告;浏览器网址加上 `?debug=1` +后(放在 `#` 之前),可点击顶部下载按钮。分享前请检查报告内容,详见 +[诊断与导出](./docs/reference/configuration.md#diagnostics)。 +连接不上时,也可以查看 [Chromium WebRTC 常见问题](./cmd/screener-client/README.md#chromium-webrtc-connections)。 -## 本地开发 +## 许可 -需要 Node.js 24、npm 11 和 Go 1.26。 - -```sh -npm ci -npm run dev -``` - -在另一个终端启动 Go 服务。以下为 POSIX shell 写法,PowerShell 使用对应的 `$env:` 变量设置: - -```sh -PORT=8788 PUBLIC_BASE_URL=http://localhost:8787 go run ./cmd/screener-server -``` - -打开 `http://localhost:8787`。开发服务器监听 `0.0.0.0`,同一局域网设备可访问 -`http://<电脑局域网地址>:8787`;屏幕采集需在 `localhost` 或 HTTPS 上运行。 -需要不同邀请地址时,按 [配置参考](./docs/reference/configuration.md) 设置来源和访问规则。 -生产环境要求独立的 `SITE_ACCESS_PASSWORD`,实际凭据不要提交到仓库。 - -```sh -npm run check -npm run check:client -``` - -## 文档与许可 - -- [文档地图](./docs/README.md) -- [产品摘要](./docs/project-memory.md) -- [当前工作](./docs/todo.md) -- [贡献流程](./CONTRIBUTING.md) - -Screener 自有代码采用 [MIT 许可](./LICENSE)。分发的第三方组件保留各自许可和声明, -详见 [第三方许可](./licenses/README.md)。软件按“现状”提供,不作担保;性能和连通性取决于 -使用的设备、浏览器和网络。 +Screener 自有代码采用 [MIT 许可](./LICENSE),提供 [中文参考译文](./LICENSE.zh-CN.md)。 +第三方组件保留各自的许可与声明,详见 [许可说明](./licenses/README.md)。 diff --git a/cmd/screener-client/README.md b/cmd/screener-client/README.md index 4654a8e..d6dc77f 100644 --- a/cmd/screener-client/README.md +++ b/cmd/screener-client/README.md @@ -125,7 +125,9 @@ and the [verified policy mechanism and field case](../../docs/research/native-cl Start the packaged executable with `--debug`, reproduce the problem, then press `D` in the interactive terminal to export a local ZIP. This does not stop the -share or upload the archive. Browser diagnostics are separate; the +share or upload the archive. Browser diagnostics are separate: open the page with +`?debug=1` and use its header download button. For cooperation failures, include +both reports from the same reproduction. The [diagnostic reference](../../docs/reference/configuration.md#diagnostics) owns log locations, export commands, retention and privacy boundaries. diff --git a/cmd/screener-server/main.go b/cmd/screener-server/main.go index e98aa65..e9d3464 100644 --- a/cmd/screener-server/main.go +++ b/cmd/screener-server/main.go @@ -80,23 +80,26 @@ func fail(err error) { // with Close rather than End: Hosted rooms are leased in the database and must // survive a restart (DECISIONS D6). func serve(debug bool) (returnedErr error) { - logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ReplaceAttr: diagnostics.ReplaceAttr})) + var recorder *diagnostics.Recorder if debug || serverDebugEnabled(os.Getenv("SCREENER_DEBUG")) { - recorder, err := diagnostics.Open(serverLogDirectory(), "server", BuildRevision) + var err error + recorder, err = diagnostics.Open(serverLogDirectory(), "server", BuildRevision) if err != nil { return fmt.Errorf("Screener server diagnostics are unavailable: %w", err) } logger = slog.New(slog.NewMultiHandler(logger.Handler(), recorder.Logger().Handler())) previous, previousWriter, previousFlags := slog.Default(), log.Writer(), log.Flags() slog.SetDefault(logger) - // Unstructured dependency logs retain their existing journal destination. - log.SetOutput(previousWriter) + dependencyLog := diagnostics.Writer("stdlib") + log.SetOutput(dependencyLog) log.SetFlags(previousFlags) fmt.Fprintln(os.Stderr, "Screener diagnostic log:", recorder.LogPath()) stopExport := watchDiagnosticExport(recorder) defer func() { stopExport() - logger.Info("screener-server", "event", "stopped", "failed", returnedErr != nil) + _ = dependencyLog.Close() + logger.Info("screener-server", "event", "stopped", "failed", returnedErr != nil, diagnostics.Error(returnedErr)) returnedErr = errors.Join(returnedErr, exportServerDiagnostics(recorder), recorder.Close()) slog.SetDefault(previous) log.SetOutput(previousWriter) @@ -108,6 +111,15 @@ func serve(debug bool) (returnedErr error) { if err != nil { return err } + if recorder != nil { + recorder.Context("configuration", map[string]any{ + "environment": configuration.Env, "port": configuration.Port, + "sqlite": configuration.RoomDatabasePath != "", "siteAccessProtected": configuration.SiteAccessPassword != "", + "maxViewers": configuration.MaxViewersPerRoom, "endpointCapacity": configuration.EndpointMediaCopyCapacity, + "sfu": configuration.SFU, "stunURLs": configuration.STUNURLs, + "natPrediction": configuration.NATPredictionEnabled, "roomLeaseMs": configuration.RoomLeaseMs, + }) + } server, err := app.New(app.Options{ Config: configuration, Assets: webassets.FS(), @@ -116,12 +128,8 @@ func serve(debug bool) (returnedErr error) { if err != nil { return err } - // Startup does not take the stop signal: index.ts registered its SIGINT and - // SIGTERM handlers only after `await server.listen()` resolved, so a stop - // during the LiveKit reconciliation ended the process by the signal's - // default disposition. Cancelling startup instead would abort it and exit - // non-zero, which systemd records as a failed unit. Each LiveKit call keeps - // its own 5 s bound (D1). + // Install ordered shutdown after startup; a stop during startup retains the + // operating system's default signal handling. port, err := server.Listen(context.Background()) if err != nil { return err diff --git a/deploy/systemd/screener.service.example b/deploy/systemd/screener.service.example index 0e62bca..736b41f 100644 --- a/deploy/systemd/screener.service.example +++ b/deploy/systemd/screener.service.example @@ -31,7 +31,8 @@ ProtectKernelLogs=true ProtectControlGroups=true ProtectClock=true ProtectHostname=true -RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +# Pion uses Go's net.Interfaces, which queries Linux through NETLINK_ROUTE. +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK RestrictNamespaces=true RestrictRealtime=true RestrictSUIDSGID=true diff --git a/docs/README.md b/docs/README.md index f333a8b..ce453a4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,90 +1,94 @@ -# Documentation Map +# Documentation -Start with [project memory](./project-memory.md), [status](./status.md), and -[TODO](./todo.md). Read only the product, decision, evidence, or operations file -needed for the current task. +[English Quick Start](../README.md) · [中文快速开始](../README.zh-CN.md) -## Current Product +## Use Screener + +| What you need | Start here | +| --- | --- | +| Join a friend or share a screen | [Quick Start](../README.md) | +| Run Client, choose a mode, or fix capture trouble | [Client guide](../cmd/screener-client/README.md) | +| Collect a useful bug report | [Diagnostics and export](./reference/configuration.md#diagnostics) | +| Host your own site | [Self-hosting](./operations/self-hosting.md) | +| Configure ports, access or persistence | [Configuration reference](./reference/configuration.md) | +| Update an existing deployment | [Deployment and recovery](./deployment.md) | +| Check platform and release readiness | [Current status](./status.md) | + +The public entry points have English and Chinese versions. Technical references +keep one owner rather than duplicate the full documentation tree. + +## Run from source + +Use Node from [.node-version](../.node-version), npm from +[package.json](../package.json), and Go 1.26 as specified by [go.mod](../go.mod). +Node builds and serves the development UI; the application server runs in Go. + +Start the UI: + +```sh +npm ci +npm run dev +``` + +In a second terminal, start the server. For a POSIX shell: + +```sh +PORT=8788 PUBLIC_BASE_URL=http://localhost:8787 go run ./cmd/screener-server +``` + +Or in PowerShell: + +```powershell +$env:PORT = '8788' +$env:PUBLIC_BASE_URL = 'http://localhost:8787' +go build -o build/dev/screener-server.exe ./cmd/screener-server +./build/dev/screener-server.exe +``` + +The fixed Windows executable path avoids repeated firewall prompts from `go run`. + +Open `http://localhost:8787`. The development UI also listens on the LAN; +screen capture requires `localhost` or HTTPS. For another invitation origin, +follow the [configuration reference](./reference/configuration.md). + +The standard checks are: + +```sh +npm run check +npm run check:client +``` + +The [contributing guide](../CONTRIBUTING.md#verification-entrypoints) explains +native build prerequisites, focused checks and separate physical gates. + +## Product and design + +Current behavior belongs to four product modules: - [Rooms and access](./product/rooms-access.md) - [Routing and transport](./product/routing-transport.md) -- [Capture, audio, and media quality](./product/media-quality.md) +- [Capture, audio and media quality](./product/media-quality.md) - [Presentation and lifecycle](./product/presentation-lifecycle.md) -These four modules own current product behavior. Source code and tests own -ordinary implementation and UI detail. +Key decisions cover [automatic routing](./adr/0005-automatic-hybrid-media-routing.md), +the [shared Go core](./adr/0012-shared-go-backend-core.md), +[embedded media and Native output groups](./adr/0013-embedded-node-local-media.md), +and the [Browser encoding-pool candidate](./adr/0014-browser-node-local-encoding-pool.md). +Browse [all ADRs](./adr/) for their context and consequences. -## Current Indexes And Operations +## Evidence and current work -- [Project memory](./project-memory.md): compact cross-domain map and invariants. -- [Status](./status.md): exact current source, production, milestone, and holds. -- [TODO](./todo.md): the only current work ledger. -- [Verification](./verification-status.md): open physical evidence and expensive - gate applicability. -- [Deployment](./deployment.md): immutable application release and recovery. -- [Self-hosting](./operations/self-hosting.md): initial services, topology, and - operational verification. -- [Configuration](./reference/configuration.md): environment, secrets, bounds, - and ports. -- [Maintenance](./maintenance.md): truth ownership, context, and repository - lifecycle. +- [Project memory](./project-memory.md): compact product map and invariants. +- [Status](./status.md): current source, production and acceptance boundaries. +- [TODO](./todo.md): the only work ledger. +- [Verification status](./verification-status.md): remaining physical evidence. +- [Native encoding research](./research/webrtc-encoder-pool.md) and + [Browser pooling research](./research/browser-local-encoding-pool.md): measured + results, failed controls and limits; neither promises universal performance. +- [Research directory](./research/): transport, capture, platform and backend evidence. +- [Maintenance](./maintenance.md): document ownership and repository lifecycle. +- [Licensing](../licenses/README.md): Screener's MIT scope and third-party notices. -## Decisions - -- [ADR-0001](./adr/0001-p2p-first-media-topology.md): initial P2P-first - baseline. -- [ADR-0002](./adr/0002-memory-resident-protected-rooms.md): room authority and - optional SQLite stability. -- [ADR-0004](./adr/0004-peer-assisted-media-experiment.md): Browser relay - experiment. -- [ADR-0005](./adr/0005-automatic-hybrid-media-routing.md): current automatic - route and SFU resource model. -- [ADR-0006](./adr/0006-fixed-high-native-sender-canary.md): historical native - sender canary evidence (superseded by ADR-0010). -- [ADR-0007](./adr/0007-path-isolated-representation-quality.md): framework- - owned media adaptation. -- [ADR-0008](./adr/0008-window-scoped-audio-capture.md): Browser/native window - audio boundary. -- [ADR-0009](./adr/0009-optional-nat-prediction.md): optional connection-local - NAT prediction. -- [ADR-0011](./adr/0011-browser-assisted-native-fanout.md): Browser capture with - Client encoded fanout. -- [ADR-0010](./adr/0010-cross-platform-client-runtime.md): cross-platform Client - runtime and self-contained package boundary. -- [ADR-0012](./adr/0012-shared-go-backend-core.md): one shared Go backend core - for Hosted and Client, amending part of ADR-0010. - -## Evidence - -Routing and transport: - -- [WebRTC feasibility](./research/webrtc-p2p-screen-sharing.md) -- [Browser peer relay](./research/peer-assisted-media.md) -- [Low-server-cost routes](./research/low-server-media-routes.md) -- [Advanced distribution alternatives](./research/advanced-peer-distribution.md) - -Media and platform: - -- [Realtime quality and codecs](./research/realtime-quality-adaptation.md) -- [Browser background capture](./research/browser-background-capture.md) -- [Browser screen audio](./research/browser-screen-audio-quality.md) -- [Browser NAT traversal](./research/nat-traversal.md) -- [Browser platform output](./research/platform-output.md) -- [Historical native sender and shared encode evidence](./research/native-sender.md) -- [Native Client media](./research/native-client-media.md) -- [Cross-platform Client runtime](./research/cross-platform-client-runtime.md) - -Rooms and repository practice: - -- [Cross-restart room recovery](./research/cross-restart-room-recovery.md) -- [Agent context governance](./research/agent-context-governance.md) - -Backend structure: - -- [Server consolidation](./research/server-consolidation.md): Go core ownership - and its acceptance checklist -- [Backend audit and refactor preparation](./research/backend-audit-1b01048.md) - -Research records verified facts, measurements, assumptions, licenses, and -remaining evidence boundaries. It does not own current product behavior or work -priority. +Research distinguishes observations from assumptions. Product modules own +accepted behavior, source and tests own implementation detail, and Git/PRs own +completed history. diff --git a/docs/adr/0014-browser-node-local-encoding-pool.md b/docs/adr/0014-browser-node-local-encoding-pool.md new file mode 100644 index 0000000..4b7b904 --- /dev/null +++ b/docs/adr/0014-browser-node-local-encoding-pool.md @@ -0,0 +1,117 @@ +# ADR-0014: Browser Node-Local Encoding Pool + +- Status: Accepted; bounded implementation evidence complete, broader limits in verification status +- Date: 2026-09-09 + +## Context + +The [Browser pool research](../research/browser-local-encoding-pool.md) proves +that an independent local WebRTC encoder can supply real video to multiple +outgoing senders. The initial standard payload-only transform retained carrier +metadata and failed intermittent VP8 startup checks. The +selected refinement uses Chromium's existing encoded-stream API to retain the +producer's encoded payload/type and exposed codec metadata, with the outgoing +stream's RTP clock. It avoids making one downstream +connection the shared encoding authority. Small carrier encodes and local +transport/decoder work remain real costs. Measured savings and recovery vary +by codec; the prototype is not production acceptance. + +This decision extends [ADR-0007](./0007-path-isolated-representation-quality.md) +and [ADR-0013](./0013-embedded-node-local-media.md) for eligible Browser outputs. +Their ordinary Browser path remains available. The existing Native composition +in [ADR-0011](./0011-browser-assisted-native-fanout.md) remains independently owned. + +## Decision + +1. A Browser source owner manages a bounded set of local encoding groups for + its direct children. Compatible consumers reuse one group; a missing + compatible output may require another group. No ancestor lookup, extra + upstream subscription, room-wide minimum or topology operation is introduced. +2. Each group uses stock WebRTC encoding and adaptation through an independent + local producer. Each outgoing connection retains stock ICE, DTLS-SRTP, + bandwidth estimation, RTP packetization, pacing and loss recovery. Screener + connects existing demand observations to group budgets and membership; it + adds no congestion estimator, quality score or arbitrary resolution ladder. +3. Reuse `HostPeer` and its existing signaling, sender-mutation and retirement + owners. A connection-owned encoded output stays attached across source + changes. It copies complete producer frames, preserving codec/reference + metadata, and assigns the outgoing carrier's RTP timestamp. A scaled source + clone bootstraps negotiation; once the producer supplies real frames, the + existing track-replacement owner installs a tiny CPU-resident Canvas carrier. + Producer frame events drive it directly, without an independent clock timer + or Worker. This avoids processing a full received decoder surface for every + carrier. Chromium's `createEncodedStreams()` path is + required for this composition. Do not force this through the standard + `RTCRtpScriptTransform` owner restrictions, use feature flags to weaken them, + or fabricate native frame statistics. Unsupported APIs use ordinary senders. +4. Group compatibility includes source-track identity/generation, negotiated + codec and codec parameters, Host ceilings, content intent, degradation + preference and effective demand. Adapted dimensions alone are insufficient. + Update an owned group's rates in place; a new observation does not itself + require recreating an encoder. Re-evaluate pending membership when a shared + producer outgrows a waiting child's allocation; it cannot depend on bandwidth + growth before delivering its first frame. Unproven compatibility uses ordinary encoding. +5. The original captured or received track remains intact for presentation and + source ownership. Producers own their input clones; outgoing bindings own + their carriers. Group queues, retained frames and references are bounded by + admitted consumers and existing prepared operations. Last-use retirement + closes unused producers and local connections. The outgoing encoded stream + belongs to its PeerConnection, so replacing a source cannot detach it or + create a second writer. Audio passes through the same API unchanged. +6. Live settings and source replacement follow the existing requested/applied + boundary. Prepare the replacement while retaining healthy output, reject + stale completion, and release the old group only after the owning operation + commits. Failure preserves the prior applied state. Pause synchronously gates + borrowed payload, including pending output; stop cancels pending preparation + and releases resources. A quiet source is not evidence of failure. +7. Switching requires an accepted recovery frame and compatible key/delta, + dependency, codec and packetization metadata. Preserve ordering and discard + obsolete data only at a valid recovery boundary. Carrier timestamps and + sender reports must remain consistent with real content and audio timing. + A copied frame does not preserve every internal native capture timestamp; + paired source-age and played-audio measurements are required. Current + H264/VP8 connections do not negotiate generic dependency descriptors and + do not need another frame-ID mapping layer. + Browser feature flags that relax ownership or metadata checks are not a + product dependency. +8. Keep producer, carrier, actual egress and receiver observations distinct. + Receiver details use inbound decoded dimensions/FPS and received-byte rates. + Fresh receiver reports remain authoritative for remote delivery details. + Carrier dimensions, encode time and limitation reason describe its tiny + encoder; they cannot establish real-picture quality or per-child CPU cost. + Preserve raw RTCStats and report unknown when actual-media attribution is + unproved. Quality convergence requires evidence from the attached output + and the same receiving connection, not substituted shared-producer counters. +9. Browser-to-Native ingress, dedicated ordinary recovery candidates and the SFU + publisher remain distinct compositions. Do not add redundant pooling behind + Native encoded fanout. SFU integration must preserve its existing two-RID + representation policy, demand activation and aggregate publication budget; + declaring carrier dimensions or silently collapsing it to one RID is invalid. + Unsupported APIs and unproven compositions retain the ordinary Browser path. + +## Acceptance And Consequences + +Activation requires codec-specific evidence for late join, constrained-child +entry/recovery, unaffected siblings, dependency correctness, synchronized A/V, +pause, settings/source rollback, producer failure and bounded retirement. +Compare useful delivery and whole-node CPU/GPU cost with ordinary senders; +fewer full-size encodes alone do not justify adoption. Sampling intervals, +test budgets and fixture thresholds remain research evidence, not product rules. +Implementation and physical acceptance are tracked in [TODO](../todo.md). + +## API And Primary References + +- [W3C Encoded Transform](https://www.w3.org/TR/webrtc-encoded-transform/#stream-processing) + permits own-frame payload modification while enforcing owner and order. +- [Chromium M152 sender implementation](https://chromium.googlesource.com/chromium/src/+/refs/tags/152.0.7977.82/third_party/blink/renderer/modules/peerconnection/rtc_rtp_sender.cc) + supports keyframe requests through `setParameters` encoding options. +- [M152 transformer IDL](https://chromium.googlesource.com/chromium/src/+/refs/tags/152.0.7977.82/third_party/blink/renderer/modules/peerconnection/rtc_rtp_script_transformer.idl) + does not expose `generateKeyFrame`; do not assume that API exists. +- [M152 encoded frame implementation](https://raw.githubusercontent.com/chromium/chromium/152.0.7977.82/third_party/blink/renderer/modules/peerconnection/rtc_encoded_video_frame.cc) + exposes copy construction with validated RTP timestamp metadata. +- [LiveKit encoded-stream capability detection](https://github.com/livekit/client-sdk-js/blob/main/src/e2ee/utils.ts) + recognizes the existing Chromium API. This is reuse of an available platform + boundary, not a claim that LiveKit provides this encoding pool. +- [Pinned WebRTC transform delegate](https://webrtc.googlesource.com/src/+/6f37672d358475cd17544121a12494da454d85fb/modules/rtp_rtcp/source/rtp_sender_video_frame_transformer_delegate.cc) + retains original frame type and pre-transform size, motivating the metadata + and accounting requirements above. diff --git a/docs/product/media-quality.md b/docs/product/media-quality.md index 312db0a..b327513 100644 --- a/docs/product/media-quality.md +++ b/docs/product/media-quality.md @@ -54,7 +54,8 @@ production behavior and remaining acceptance. Browser still owns preview, pause, capture settings, and source selection. Loss of that optional ingress retains capture and rebuilds the assigned Browser edges. [ADR-0011](../adr/0011-browser-assisted-native-fanout.md) owns - this composition; pure Browser and VP8 sharing retain their normal senders. + this composition; pure Browser and VP8 sharing use the Browser-owned sender + paths described below. ## Video Profiles @@ -116,8 +117,19 @@ sender also stops its clone, so native adaptation state cannot survive by being inherited through the original track. Host pause and live capture constraints are propagated to current Host-owned clones. -Each Browser PeerConnection owns stock WebRTC congestion control and sender -adaptation. Clones share one underlying media source and therefore do not provide +Eligible Browser P2P parents use the source-owned pool in +[ADR-0014](../adr/0014-browser-node-local-encoding-pool.md). Compatible direct +children share one independent local WebRTC producer; incompatible demands +remain separate. Each outgoing connection keeps native transport, allocation +and recovery, with a tiny carrier supplying its RTP clock. Producers use +the existing native video target under Host ceilings. Actual forwarded-frame +and producer observations remain distinct from the tiny carrier's statistics. +The first eligible consumer follows the same path; unsupported APIs or failed +pooling use ordinary senders. Prepared ordinary quality candidates, Native +ingress and Browser SFU remain independent compositions. + +Each ordinary Browser PeerConnection owns stock WebRTC congestion control and +sender adaptation. Clones share one underlying media source and therefore do not provide complete simultaneous isolation: framework source-wants aggregation may still partially reduce frames available to sibling clones. Sibling outputs may differ; Screener does not impose a room-wide minimum. The Host's one Browser SFU diff --git a/docs/project-memory.md b/docs/project-memory.md index a4c04d2..adadf5b 100644 --- a/docs/project-memory.md +++ b/docs/project-memory.md @@ -27,8 +27,10 @@ Native parents reuse suitable encoded outputs and derive a missing lower output only for direct-child demand. Source-owned groups retain the highest needed output and lower fallbacks, while each child receives its selected output. Native Host SFU publication reuses the encoded source directly; its loopback -Browser edge owns preview only. Pure Browser capture and relay retain normal -WebRTC senders. Windows, macOS and Linux producers share one encoded-frame +Browser edge owns preview only. Eligible Browser capture and relay now share +independent local WebRTC producers for compatible direct children under ADR-0014; +unsupported APIs and failed pooling retain ordinary senders. Windows, macOS and +Linux producers share one encoded-frame boundary and Host quality controls. Source replacement retains route and connection identity; lower-output adaptation preserves the original and higher sibling outputs. [Status](./status.md) owns the remaining physical and release diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index ffa822e..9842e68 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -43,68 +43,80 @@ dual configuration readers. ## Diagnostics -Client file diagnostics default off; enable them with `--debug` or -`SCREENER_DEBUG=client`. Structured lifecycle, build-revision, native-capability, -fixed native-failure and sanitized Local route events go to `client.log`. -Native share changes, validated capture states, connection states and selected -candidate types/provenance are included without source names or addresses. -Failed profile preparation records a fixed rejection stage. Native outbound -connections record ICE and DTLS state separately; local-bridge closure records -candidate type/transport counts, never candidate addresses or SDP. -The embedded Local server uses the same file. Debug events do not stream through -the TUI or change machine-readable stdout. On the Client, `SCREENER_DEBUG=route` -alone retains console route tracing and does not enable file capture or `D`. +Diagnostics are local and opt-in. Enable them **before** reproducing the problem: -The default Client directory is `logs` beside the executable. If that directory -is not writable, the Client uses `Screener/logs` inside the OS user-cache directory -and displays the actual log path. `--log-dir ` overrides -`SCREENER_LOG_DIR`; either explicit choice must be writable or startup fails. -Selecting a directory alone does not enable diagnostics. +| Surface | Enable | Export | +| --- | --- | --- | +| Client | Start with `--debug` or `SCREENER_DEBUG=client` | Press `D` in the terminal for a ZIP | +| Browser Host/Viewer | Add `?debug=1` to the page URL, before any invitation fragment | Use the download button beside language/theme controls | +| Hosted Server | Start with `--debug`, `SCREENER_DEBUG=server` or `SCREENER_DEBUG=route` | On Unix, `kill -USR1 `; also exported at orderly shutdown | -Press `D` in the interactive Client terminal to export a ZIP without stopping -the Client. The terminal shows the saved path as a local-file hyperlink when -supported; the visible path can also be copied normally. English, Chinese and -visual modes share that action. Non-interactive and `TERM=dumb` sessions export -at orderly shutdown, since they do not read TUI keys. +Client/Server ZIP and Browser JSON reports are separate: when investigating +Browser/Client cooperation, include both from the same reproduction. Neither +action stops an active share or uploads anything. Browser export also remains +available as `await window.__SCREENER_DEBUG__.export()` in DevTools. -Hosted Server diagnostics use `--debug`, `SCREENER_DEBUG=server` or -`SCREENER_DEBUG=route`. They persist structured events in `server.log` while -normal service journal output continues. The directory is `SCREENER_LOG_DIR`, -otherwise the first systemd `LOGS_DIRECTORY`, otherwise `logs` under the working -directory. The tracked systemd unit supplies `/var/log/screener` with mode `0700` -through `LogsDirectory`, which remains writable with `ProtectSystem=strict`. -An unwritable selection fails startup. In a running debug-enabled -Unix server, `kill -USR1 ` saves a bundle and prints its path in the journal. -Every platform also exports at orderly debug shutdown. Windows does not have -the Unix signal action; forced termination cannot produce a shutdown snapshot. -No diagnostic HTTP listener or automatic upload is added. +Client logs go to `logs` beside the executable, falling back to `Screener/logs` +in the OS user-cache directory when that default is unwritable. The TUI shows +the actual path and the exported ZIP. `--log-dir` overrides +`SCREENER_LOG_DIR`; an explicit directory must be writable. Choosing a +directory alone does not enable collection. Non-interactive Clients export at +orderly shutdown. On Client, `SCREENER_DEBUG=route` alone retains console route +tracing; use `--debug` for file collection and the `D` action. -`--debug` enables the existing sanitized route events for the local Go process; -`SCREENER_DEBUG=route` also works when loaded from the Server `.env`. Those events -record room and participant ordinals, route reasons, candidates, revisions, -quality states and commit/failure outcomes. They exclude raw Peer IDs, SDP, ICE -candidates, tokens and media credentials. Raw third-party protocol logging is -not enabled or copied into these files. +Hosted logs use `SCREENER_LOG_DIR`, otherwise systemd `LOGS_DIRECTORY`, +otherwise `logs` under the working directory. The service unit supplies +`/var/log/screener` with mode `0700`. Normal service notices remain in the +journal; detailed dependency records go into the report. Windows Server has no +Unix signal trigger; forced process termination cannot create a final snapshot. -Each component retains an 8 MiB current JSON log and one 8 MiB previous log. -Exports contain those existing logs, build/platform metadata, Go `runtime.MemStats`, -a sampled Go heap/allocation profile (`heap.pprof`) and aggregate goroutine -stacks (`goroutines.txt`). These are runtime counters and allocation/stack -profiles, not raw process-memory dumps. They do not collect environment values, -configuration files, credentials, media payloads, Browser logs or memory from -the separate C++ capture process. Exported ZIPs remain in the selected directory -until the user or operator removes them; log rotation does not delete exports. +The report combines operation history and existing runtime evidence: -For Browser diagnostics, add `?debug=1` to the page before reproducing the -problem. The page retains its last 256 bounded events and exposes -`window.__SCREENER_DEBUG__.export()` for manual JSON export from DevTools. -The report includes the Browser asset name and signaling contract, capture -exception categories, capture-setting outcomes, signaling state and native -request/state outcomes. The Native bridge's failure snapshot includes ICE/DTLS -states, live receiver counts and candidate type counts without delaying teardown. -It excludes raw exception messages, credentials, invitation fragments, URLs, -SDP, ICE candidates and media payloads. Collection -stays in the current page; there is no automatic upload or persistent log. +- Native request IDs connect start/completion, duration, requested/applied + profiles, source kind, codec/adapter selection and cancellation or failure. + Async quality preparation retains that ID until its actual completion. +- Capture process start/exit/EOF and streamed stderr include useful failure + causes and system/HRESULT codes. Windows samples its existing WebRTC encoder + observer during processing: input/output FPS, actual size, bitrate, encode + time/usage, QP when available, drops, limitation flags and adaptation counts. + Those are VSE counters, not a complete account of WGC mailbox overwrites. +- Pion connection/ICE/DTLS and media-component diagnostics are included. + Existing RTP/BWE/allocation observations, packet queue/drop counters, + group demand/attachment changes and gateway/STUN outcomes explain delivery + without adding a media-control loop or a new per-connection polling timer. +- Browser records capture settings and sender readbacks, signaling and Native + operations, meaningful error messages/stacks/causes and existing RTCStats. + Pool observations distinguish native carrier reports, actual output and its + assigned producer. Export adds Browser/platform metadata; diagnostic collection + does not replace media APIs or control transport. + +Each Go component keeps an 8 MiB current log and one 8 MiB backup. ZIPs contain +these retained logs, a report marker, selected startup context, build/module and +platform metadata, Go memory counters, sampled allocation profile +(`heap.pprof`) and aggregate goroutine stacks. These are not raw process-memory +or C++ memory dumps. A failed optional collector leaves useful files available; +`metadata.json` lists included files, errors and partial status. ZIP write/disk +failure still reports an export failure. Rotation and preexisting history are +identified; retained logs do not claim a complete session history. + +Browser retains up to 8,192 events and 8 MiB of compact event data in the current +page. Reports identify retained sequence/time ranges, evicted/truncated events +and collector failures. Field/record limits are explicit in the report. +Browser reload/close loses that in-page history; export before closing it. +Go ZIP exports remain until the user/operator removes them; rotation only +manages the current logs. + +Credentials, authorization/cookies, invitation secrets, ICE passwords/fragments +and private keys are filtered before persistence/export. Application media +identities use diagnostic hashes where applicable; Browser/library technical +identifiers, IP addresses, device information and file paths may remain. +Do not treat a report as anonymous. Review it before sharing. Raw screen/audio +payloads, arbitrary config/environment files and raw process-memory contents +are not collected. Browser window titles and participant display names are +omitted. Debug detail has CPU/I/O cost and should be enabled for investigation. + +The [research comparison](../research/diagnostic-feedback.md) records the mature +project references and the reasons for this collection boundary. ## Public And Private Ports diff --git a/docs/research/advanced-peer-distribution.md b/docs/research/advanced-peer-distribution.md index 2fedc98..341d2c6 100644 --- a/docs/research/advanced-peer-distribution.md +++ b/docs/research/advanced-peer-distribution.md @@ -236,14 +236,18 @@ updates frame/timing accounting before the Borrowing below that point bypasses those updates. This exposes an unverified audio/video-clock boundary, not a measured audible desynchronization claim. -**Decision: no product integration of legacy fanout.** The saved encode is real, +**Decision for this experiment: no product integration of A-as-producer fanout.** The saved encode is real, but the shared master's limitation damages a healthy sibling and the borrowed connection's ordinary encoder policy no longer controls its payload. Adding a live dummy encoder, independent budget polling, master election or timestamp projection would introduce new ownership and recovery work rather than preserve -the current native adaptation contract. Standard Transform does not provide a -portable alternative injection point. Keep normal Browser senders and the -verified optional Browser-to-Client encoded fanout. +the current native adaptation contract. This standard-transform arm tested +foreign-frame ownership, not replacement of a sender's own frame payload. +It does not reject an independent local producer. The later +[node-local producer/carrier experiments](./browser-local-encoding-pool.md) +establish a working standard-transform prototype with separate findings and +remaining quality/accounting obligations. Product media still uses normal +Browser senders and the verified optional Client fanout pending that acceptance. A relevant future input API is Chromium's experimental [`RTCEncodedSource` implementation](https://chromium-review.googlesource.com/c/chromium/src/+/8097283), diff --git a/docs/research/browser-local-encoding-pool.md b/docs/research/browser-local-encoding-pool.md new file mode 100644 index 0000000..ea16e68 --- /dev/null +++ b/docs/research/browser-local-encoding-pool.md @@ -0,0 +1,163 @@ +# Browser Node-Local Encoding Pool + +Reviewed and executed 2026-09-09 on Windows, Chrome 152.0.7977.82. +[ADR-0014](../adr/0014-browser-node-local-encoding-pool.md) owns the selected +design. [TODO](../todo.md) owns combined Debug, package and release acceptance. +This branch has not been deployed. + +## Result And Scope + +An independent local WebRTC producer can supply compatible direct children +without making one real child the encoding authority. Each child's existing +connection retains RTP, ICE, congestion control and recovery. Different weak +demands can require separate local producers; healthy siblings keep their +original producer. There is no ancestor cache or application quality ladder. + +The selected Chromium encoded-stream path copies the producer's payload, type +and exposed codec metadata, assigning the outgoing carrier's RTP timestamp. +Native scaling on a source clone bootstraps each carrier. After the first real +producer frame, its existing sender switches to a CPU-resident 16x16 Canvas +track driven by producer events. It does not first warm another full-size +encoder, and there is no independent carrier timer. Unsupported APIs and failed +pooling retain ordinary senders. +The original capture/received track is never replaced by a carrier. + +This is one full encode plus small carrier encodes for compatible children, +not literally one encoder or one Encode call. Each producer also has a local +connection and receiver decoder. Browser relay reuses its local encoded output +across direct children; it still decodes/re-encodes its received source. + +## Why The Earlier Implementations Were Removed + +- Borrowing a real child's encoder couples siblings to that child's adaptation. + It does not test an independent producer. +- Quiet placeholders omit native sender accounting and do not provide a usable + clock. They are not a zero-cost product implementation. +- Standard own-frame payload replacement can transmit real pictures, but retains + carrier metadata. At 1080p, VP8 intermittently stalled. Extra carrier key-type + rendezvous and a canvas clock did not establish reliable parity. +- The selected complete-frame API removes that Worker and key-type rendezvous. + It preserves exposed metadata, not every internal native capture timestamp; + played-audio and source-age measurements remain necessary. +- Starting two full ordinary encoders before pooling caused a transient extra + encoding load and, in one run, 500-700 ms early played-audio lag. Starting the + tiny carrier immediately removed that unnecessary warmup; the next VP8 + 1080p check measured offsets between about -64 and +42 ms. +- Hand-subtracting audio/repair from available BWE introduced another inaccurate + allocator. The pool now uses the existing sender's native video payload + target, bounded by Host settings. This is not the same unit conversion as + Native's measured RTP-to-codec budget, which remains necessary. +- Scaling the full received H264 track for each tiny carrier retained substantial + processing cost. Replacing that input with a CPU-resident Canvas, driven only + by producer frames, reduced the six-second relay fixture from 7.27 to 5.36 + CPU-seconds; ordinary relay used 5.39. Summed relay encoding time fell from + about 4,840 to 1,177 ms (ordinary: 2,268 ms). This removes the extra cost rather + than claiming a total CPU win. Picture-age p95 was 124/161 ms versus 43/49 ms + for ordinary relay; no additional timing controller was added to chase it. +- A waiting member must replan when a shared producer outgrows its allocation. + An already-available better healthy output must not require its old encoder + to recover first. Both conditions produced failed startup checks; the fixes + reuse membership and the existing output comparison, without a new timer. + +The replaced experiments and selected original measurements remain recoverable +at Git checkpoint `4e3de79`; runtime code and the runner keep only the selected +implementation. Failed cases are evidence, not acceptance passes. + +## Current Executed Evidence + +The [selected summaries](./data/browser-local-pool.json) retain exact fixture +hashes, raw artifact names, delivered frames, source ages and limitations. +The actual `HostPeer` and pool are bundled into the fixture; it does not copy +their implementation. All workloads ran serially. + +| Actual product path | Observed result | +| --- | --- | +| VP8, 1080p30, 400 kbps constrained A | A reached 480x270 and returned to 1080p; B kept 1080p. A decoded 195 frames in 14 constrained seconds and 981 in 40 recovery seconds. | +| H264, same constraint | A reached 960x540 and returned to 1080p; B kept 1080p. A decoded 366 and 1,165 frames in the corresponding windows. | +| Both codecs, live settings | 720p60, quiet-source 480p30, pause, resume and new source delivered the requested ceilings without replacing the outgoing connections. | +| Producer failure | Both codecs returned to ordinary sending on the existing connections; VP8 delivered 181 frames per child in six seconds, with no retained groups. | +| VP8, late child | Both children shared the existing producer and decoded fresh 1080p, without backward source IDs. | +| VP8, received-track relay | One local producer supplied two children at 1080p; source-age p95 was about 71/122 ms, with played audio present. | +| Actual hidden page | Native fake source continued at its available 20 fps; both receivers decoded 200 frames in ten seconds. The first run had a temporary profile-cleanup failure, recorded separately from media behavior. | + +The source generates moving bars and a frame-ID barcode. It is not a game +perceptual-quality benchmark. Played audio is recaptured from the receiving +video element; this is element-level synchronization, not physical speakers +or display scanout. Hidden-page checks use a native fake source because a +background canvas timer is itself throttled. They establish neither real-game +60 fps endurance nor background audio timing. + +The constrained runs use a test-only forward UDP bridge with about 250 ms +maximum queued service; reverse feedback is unshaped. They are not a model of +public NAT, geographic RTT or every congestion/loss pattern. Reduced quality +and temporary stalls under real constraints remain possible. + +## Cost And Accounting + +Before the CPU-carrier refinement, a matched actual-product VP8 1080p30 comparison's ordinary pair made +350 full-size encodes in six seconds, using about 1,201 ms summed encode time. +The pool made 170 full-size and 342 tiny encodes, using about 516 ms summed +encode time. Whole isolated Chrome CPU was 9.23 versus 8.95 CPU-seconds. +The fixture includes source drawing, barcode reads, all receiving pages and the +extra producer receiver; this is not Host-only CPU, GPU or thermal measurement. + +Earlier independent-producer comparisons showed a larger whole-fixture VP8 +saving, but H264 showed no total CPU win despite fewer full encodes. Do not +generalize one percentage or count dropped frames as an optimization. +The current H264 pair likewise reduced summed encode time from about 1,649 to +927 ms, while whole-fixture CPU increased from 4.17 to 4.56 CPU-seconds over six +seconds (about 0.065 of one CPU core). Less full-resolution encoding does not +guarantee lower overall CPU on hardware codecs. +The owner accepts a uniform path from the first eligible consumer, without +an audience-count threshold. Singleton and hardware costs remain explicit. + +Receiver details stay real inbound observations. Sender transport bytes/loss +belong to each connection; dimensions/cadence describe the frames actually +written; native limitation evidence comes from the assigned real producer. +Shared encode counts/total encode time are not duplicated per child. +Retired producer samples keep their latest per-instance report rather than +reverting to an older polling snapshot. + +## Reproduction + +Use the existing isolated-browser harness and stable executable paths: + +```sh +npx tsx scripts/browser-local-pool-probe.ts ordinary --1080 --av --preference=balanced +npx tsx scripts/browser-local-pool-probe.ts carrier --1080 --av --preference=balanced +npx tsx scripts/browser-local-pool-probe.ts carrier --1080 --av --network --auto --rate=400000 --preference=balanced +npx tsx scripts/browser-local-pool-probe.ts carrier --1080 --av --lifecycle +npx tsx scripts/browser-local-pool-probe.ts carrier --1080 --av --relay +npx tsx scripts/browser-local-pool-probe.ts carrier --1080 --background +``` + +Add `--h264`, `--single` or `--late` for the corresponding case. Set +`CHROME_PATH` for another installed Chromium binary. `--auto` extends the +recovery observation; product code owns all adaptation. Results go to ignored +`build/browser-local-pool`; summarize with +`node scripts/browser-local-pool-summary.mjs `. +A successful process exit does not establish visual/performance parity. + +## Primary References And Remaining Boundaries + +- [W3C Encoded Transform](https://www.w3.org/TR/webrtc-encoded-transform/#stream-processing) + enforces standard frame owner/order. The selected legacy API is a different + available API boundary, not a feature flag relaxing those checks. +- [Chromium M152 encoded frame](https://raw.githubusercontent.com/chromium/chromium/152.0.7977.82/third_party/blink/renderer/modules/peerconnection/rtc_encoded_video_frame.cc) + supports copy construction with validated RTP timestamp metadata. +- [Pinned WebRTC transform delegate](https://webrtc.googlesource.com/src/+/6f37672d358475cd17544121a12494da454d85fb/modules/rtp_rtcp/source/rtp_sender_video_frame_transformer_delegate.cc) + reconstructs copied native frames; internal capture/presentation timing is + not wholly preserved. Current negotiated VP8/H264 connections lack generic + dependency-descriptor extensions, so no frame-ID mapping was added. +- [LiveKit capability detection](https://github.com/livekit/client-sdk-js/blob/main/src/e2ee/utils.ts) + recognizes `createEncodedStreams()`; it is not itself a reusable encoding pool. +- [WebRTC stats](https://www.w3.org/TR/webrtc-stats/#dom-rtcoutboundrtpstreamstats-targetbitrate) + distinguishes the video encoder payload target from link bandwidth. +- [Chrome timer throttling](https://developer.chrome.com/blog/timer-throttling-in-chrome-88/) + explains why a JS canvas timer is not a reliable hidden-page media clock. + +The future [RTCEncodedSource proposal](https://chromestatus.com/feature/5177374353260544) +is not exposed by the installed M152 Browser. No future API, feature flag, SFU +policy change, Browser cross-level encoded passthrough or public-network +performance guarantee is assumed. Matching Debug/artifact acceptance and +representative game/hardware feedback remain separate from these bounded runs. diff --git a/docs/research/data/browser-local-pool.json b/docs/research/data/browser-local-pool.json new file mode 100644 index 0000000..f3f8601 --- /dev/null +++ b/docs/research/data/browser-local-pool.json @@ -0,0 +1,31 @@ +{ + "measuredDate": "2026-09-09", + "scope": "Actual HostPeer/BrowserEncodingPool bounded evidence; not deployment, perceptual parity or universal performance", + "notes": [ + "Whole Chrome CPU includes source, all local receivers and diagnostic barcode reads; not Host-only CPU/GPU.", + "Raw reports and code hashes preserve successes and excluded cases. Prior architectures remain at Git checkpoint 4e3de79.", + "Native fake source delivers 20 fps; hidden rendering callbacks suspend even when decoding/forwarding continues.", + "No media payload, real invitation or credentials are retained. Raw carrier counters do not describe actual outgoing pictures." + ], + "cases": [ + {"file":"build/browser-local-pool/ordinary-vp8-1080-1788949313653.json","probeSha256":"c760a349da1bbf7458db29ac53bff13d4a52431ffba2f43c5f81bcad6bbbf1a6","mode":"ordinary","codec":"VP8","product":true,"high":true,"single":false,"late":false,"network":false,"automatic":false,"av":true,"background":false,"nativeSource":false,"lifecycle":false,"shapedRate":120000,"profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music"},"completed":true,"errors":[],"cleanup":{"browserExited":true,"nativeExited":true,"serverClosed":true,"portsClosed":true,"profileRemoved":true},"phases":[{"phase":"healthy","seconds":6.00929999999702,"browserCpuSeconds":9.233858000000001,"edges":{"A":{"encoded":175,"encodeMs":591,"decoded":175,"decodeMs":338.679,"sentFrames":175,"kbps":1300.0995124230567,"output":[1920,1080],"receive":[1920,1080],"reason":"none","target":4978000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":29,"bitrateKbps":1330.0662300795984,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":null,"intervalFramesEncoded":58},"meanQp":2.0114285714285716,"senderReports":25,"pli":0},"B":{"encoded":175,"encodeMs":610,"decoded":175,"decodeMs":356.348,"sentFrames":175,"kbps":1284.785915165465,"output":[1920,1080],"receive":[1920,1080],"reason":"none","target":4922000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":29,"bitrateKbps":1330.5844334083986,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":null,"intervalFramesEncoded":58},"meanQp":2,"senderReports":25,"pli":0}},"viewers":{"A":{"rendered":168,"fresh":168,"backwards":0,"duplicates":0,"maxGapMs":67,"ageP50":31.600000008940697,"ageP95":52.5,"av":{"attached":true,"videoMinusAudioMs":[-57.5,-34.1000000089407,-8],"missed":0}},"B":{"rendered":164,"fresh":164,"backwards":0,"duplicates":0,"maxGapMs":61.70000000298023,"ageP50":34.599999994039536,"ageP95":53.1000000089407,"av":{"attached":true,"videoMinusAudioMs":[-46,-40.1000000089407,7.9000000059604645],"missed":0}}}}],"controls":[],"visibility":[],"shaper":null}, + {"file":"build/browser-local-pool/carrier-vp8-1080-1788949200368.json","probeSha256":"c760a349da1bbf7458db29ac53bff13d4a52431ffba2f43c5f81bcad6bbbf1a6","mode":"carrier","codec":"VP8","product":true,"high":true,"single":false,"late":false,"network":false,"automatic":false,"av":true,"background":false,"nativeSource":false,"lifecycle":false,"shapedRate":120000,"profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music"},"completed":true,"errors":[],"cleanup":{"browserExited":true,"nativeExited":true,"serverClosed":true,"portsClosed":true,"profileRemoved":true},"phases":[{"phase":"healthy","seconds":6.0265,"browserCpuSeconds":8.977537999999967,"edges":{"A":{"encoded":175,"encodeMs":33.00000000000036,"decoded":172,"decodeMs":353.51099999999997,"sentFrames":175,"kbps":1119.638928067701,"output":[28,16],"receive":[1920,1080],"reason":"none","target":4862000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":28.53566958698373,"bitrateKbps":1134.3258871212438,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":57,"intervalFramesEncoded":null},"meanQp":2,"senderReports":21,"pli":0},"G0":{"encoded":175,"encodeMs":500.99999999999943,"decoded":0,"decodeMs":0,"sentFrames":175,"kbps":1105.6208412843275,"output":[1920,1080],"receive":[null,null],"reason":"none","target":4080000,"retired":false,"meanQp":2,"senderReports":0,"pli":0},"B":{"encoded":175,"encodeMs":33.00000000000036,"decoded":170,"decodeMs":347.58599999999996,"sentFrames":175,"kbps":1118.0857877706794,"output":[28,16],"receive":[1920,1080],"reason":"none","target":4896000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":28.53566958698373,"bitrateKbps":1129.6321156061676,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":57,"intervalFramesEncoded":null},"meanQp":2,"senderReports":18,"pli":0}},"viewers":{"A":{"rendered":166,"fresh":166,"backwards":0,"duplicates":0,"maxGapMs":112.5,"ageP50":71.29999999701977,"ageP95":224.09999999403954,"av":{"attached":true,"videoMinusAudioMs":[-705.2999999970198,-568.3999999910593],"missed":0}},"B":{"rendered":163,"fresh":163,"backwards":0,"duplicates":0,"maxGapMs":112.69999998807907,"ageP50":79.5,"ageP95":277.29999999701977,"av":{"attached":true,"videoMinusAudioMs":[-685.6999999880791,-517.8999999910593],"missed":0}}}}],"controls":[],"visibility":[],"shaper":null,"qualification":"Superseded initial full-encoder warmup; early A/V offset failed acceptance."}, + {"file":"build/browser-local-pool/carrier-vp8-1080-1788949818500.json","probeSha256":"a3efc80f02cc6137cba0739ce7a2bc46c05eeb020f2eadc1d874d9446626f468","mode":"carrier","codec":"VP8","product":true,"high":true,"single":false,"late":false,"network":false,"automatic":false,"av":true,"background":false,"nativeSource":false,"lifecycle":false,"shapedRate":120000,"profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music"},"completed":true,"errors":[],"cleanup":{"browserExited":true,"nativeExited":true,"serverClosed":true,"portsClosed":true,"profileRemoved":true},"phases":[{"phase":"healthy","seconds":6.0065,"browserCpuSeconds":8.946513,"edges":{"A":{"encoded":171,"encodeMs":23,"decoded":168,"decodeMs":364.34299999999996,"sentFrames":171,"kbps":1146.5379172563057,"output":[28,16],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":27.013950487831423,"bitrateKbps":1099.0461985549202,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":67,"intervalFramesEncoded":null},"meanQp":2,"senderReports":22,"pli":0},"G0":{"encoded":170,"encodeMs":469,"decoded":0,"decodeMs":0,"sentFrames":170,"kbps":1152.4595022059434,"output":[1920,1080],"receive":[null,null],"reason":"none","target":5000000,"retired":false,"meanQp":2.0470588235294116,"senderReports":0,"pli":0},"B":{"encoded":171,"encodeMs":24,"decoded":169,"decodeMs":382.414,"sentFrames":171,"kbps":1153.8832930991425,"output":[28,16],"receive":[1920,1080],"reason":"none","target":4956000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":26.986506746626688,"bitrateKbps":1099.0463326512076,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":54,"intervalFramesEncoded":null},"meanQp":2,"senderReports":19,"pli":0}},"viewers":{"A":{"rendered":163,"fresh":163,"backwards":0,"duplicates":0,"maxGapMs":75.80000001192093,"ageP50":37.099999994039536,"ageP95":114.1000000089407,"av":{"attached":true,"videoMinusAudioMs":[-63.70000000298023,-33.29999999701977,31.200000002980232],"missed":0}},"B":{"rendered":165,"fresh":165,"backwards":0,"duplicates":0,"maxGapMs":77.1000000089407,"ageP50":58.400000005960464,"ageP95":114.40000000596046,"av":{"attached":true,"videoMinusAudioMs":[-5.700000002980232,-11.599999994039536,41.5],"missed":0}}}}],"controls":[],"visibility":[],"shaper":null}, + {"file":"build/browser-local-pool/ordinary-h264-1080-1788950802710.json","probeSha256":"2e3957d6154883f815fc7e0400da38329ba2a9e9128b49e902d645954ffd6b66","mode":"ordinary","codec":"H264","product":true,"high":true,"single":false,"late":false,"network":false,"automatic":false,"av":true,"background":false,"nativeSource":false,"lifecycle":false,"relay":false,"shapedRate":120000,"profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music"},"completed":true,"errors":[],"cleanup":{"browserExited":true,"nativeExited":true,"serverClosed":true,"portsClosed":true,"profileRemoved":true},"phases":[{"phase":"healthy","seconds":6.01220000000298,"browserCpuSeconds":4.167281000000002,"edges":{"A":{"encoded":177,"encodeMs":826,"decoded":177,"decodeMs":61.29999999999998,"sentFrames":177,"kbps":622.7710322341479,"output":[1920,1080],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":30,"bitrateKbps":616.2243550086928,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":null,"intervalFramesEncoded":59},"meanQp":16,"senderReports":15,"pli":0},"B":{"encoded":177,"encodeMs":823,"decoded":177,"decodeMs":78.31500000000001,"sentFrames":177,"kbps":622.7710322341479,"output":[1920,1080],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":29,"bitrateKbps":610.4560122386487,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":null,"intervalFramesEncoded":59},"meanQp":16,"senderReports":13,"pli":0}},"viewers":{"A":{"rendered":177,"fresh":177,"backwards":0,"duplicates":0,"maxGapMs":67.5,"ageP50":24.400000005960464,"ageP95":33.5,"av":{"attached":true,"videoMinusAudioMs":[-51.29999999701977,-64.8999999910593,-57.5],"missed":0}},"B":{"rendered":177,"fresh":177,"backwards":0,"duplicates":0,"maxGapMs":64.3999999910593,"ageP50":26.900000005960464,"ageP95":36.6000000089407,"av":{"attached":true,"videoMinusAudioMs":[-65,-58.5,-62.20000000298023],"missed":0}}}}],"controls":[],"visibility":[],"shaper":null}, + {"file":"build/browser-local-pool/carrier-vp8-network-auto-1080-1788949448349.json","probeSha256":"96a8891ae1126967d7d8afbb13bb1236f0a718f34b24f6781e993362283f9dd9","mode":"carrier","codec":"VP8","product":true,"high":true,"single":false,"late":false,"network":true,"automatic":true,"av":true,"background":false,"nativeSource":false,"lifecycle":false,"shapedRate":400000,"profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music"},"completed":true,"errors":[],"cleanup":{"browserExited":true,"nativeExited":true,"serverClosed":true,"portsClosed":true,"profileRemoved":true},"phases":[{"phase":"healthy","seconds":6.01370000000298,"browserCpuSeconds":9.14094,"edges":{"A":{"encoded":173,"encodeMs":25.9999999999998,"decoded":172,"decodeMs":328.61199999999997,"sentFrames":173,"kbps":1111.4182616353805,"output":[28,16],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":28.56953026388299,"bitrateKbps":1132.8533685537054,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":43,"intervalFramesEncoded":null},"meanQp":2,"senderReports":19,"pli":0},"G0":{"encoded":173,"encodeMs":503.0000000000001,"decoded":0,"decodeMs":0,"sentFrames":173,"kbps":1104.4342085565806,"output":[1920,1080],"receive":[null,null],"reason":"none","target":5000000,"retired":false,"meanQp":2,"senderReports":0,"pli":0},"B":{"encoded":173,"encodeMs":25.9999999999998,"decoded":173,"decodeMs":345.675,"sentFrames":173,"kbps":1114.846433975203,"output":[28,16],"receive":[1920,1080],"reason":"none","target":4808000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":29.23393794443841,"bitrateKbps":1141.1929479116802,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":44,"intervalFramesEncoded":null},"meanQp":2,"senderReports":18,"pli":0}},"viewers":{"A":{"rendered":166,"fresh":166,"backwards":0,"duplicates":0,"maxGapMs":91.79999999701977,"ageP50":44.70000000298023,"ageP95":101.90000000596046,"av":{"attached":true,"videoMinusAudioMs":[-303.5,-232.40000000596046],"missed":0}},"B":{"rendered":171,"fresh":171,"backwards":0,"duplicates":0,"maxGapMs":77.29999999701977,"ageP50":64.29999999701977,"ageP95":117,"av":{"attached":true,"videoMinusAudioMs":[-214.20000000298023,-204.5],"missed":0}}}},{"phase":"limited","seconds":14.012299999997019,"browserCpuSeconds":15.243217999999999,"edges":{"A":{"encoded":387,"encodeMs":62.99999999999972,"decoded":194,"decodeMs":65.48100000000001,"sentFrames":387,"kbps":214.17754401494676,"output":[28,16],"receive":[480,270],"reason":"none","target":202000,"retired":false,"details":{"frameWidth":480,"frameHeight":270,"framesPerSecond":15.024038461448768,"bitrateKbps":65.09758280234531,"qualityLimitationReason":"bandwidth","nativeEdgeQualityState":"degraded","intervalFramesSent":30,"intervalFramesEncoded":null},"meanQp":2.4031007751937983,"senderReports":20,"pli":0},"G0":{"encoded":400,"encodeMs":1122,"decoded":0,"decodeMs":0,"sentFrames":400,"kbps":1100.8345525005375,"output":[1920,1080],"receive":[null,null],"reason":"none","target":4166667,"retired":false,"meanQp":2.04,"senderReports":0,"pli":0},"G1":{"encoded":197,"encodeMs":186,"decoded":0,"decodeMs":0,"sentFrames":197,"kbps":118.44450946670804,"output":[480,270],"receive":[null,null],"reason":"bandwidth","target":70833,"retired":false,"meanQp":39.35532994923858,"senderReports":0,"pli":0},"B":{"encoded":400,"encodeMs":64.00000000000006,"decoded":398,"decodeMs":728.3049999999998,"sentFrames":400,"kbps":1109.2391684451022,"output":[28,16],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":28.54567307675266,"bitrateKbps":1123.8869279377345,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":57,"intervalFramesEncoded":null},"meanQp":2,"senderReports":49,"pli":0}},"viewers":{"A":{"rendered":189,"fresh":186,"backwards":0,"duplicates":0,"maxGapMs":536.2000000029802,"ageP50":192.19999998807907,"ageP95":309,"av":{"attached":true,"videoMinusAudioMs":[-61.70000000298023,-337,-244.29999999701977,-216.80000001192093,-130.8999999910593,-120.59999999403954],"missed":0}},"B":{"rendered":387,"fresh":387,"backwards":0,"duplicates":0,"maxGapMs":85.90000000596046,"ageP50":175.40000000596046,"ageP95":199.79999999701977,"av":{"attached":true,"videoMinusAudioMs":[-89.09999999403954,-61,16.200000002980232,61.29999999701977,120,107.19999998807907],"missed":0}}}},{"phase":"released","seconds":40.01580000001192,"browserCpuSeconds":48.12648100000003,"edges":{"A":{"encoded":1146,"encodeMs":164.0000000000006,"decoded":948,"decodeMs":1097.4859999999999,"sentFrames":1146,"kbps":601.4834140512704,"output":[28,16],"receive":[1920,1080],"reason":"none","target":4831000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":27.386280141443557,"bitrateKbps":998.3542168915645,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":41,"intervalFramesEncoded":null},"meanQp":2.1090750436300176,"senderReports":85,"pli":0},"G0":{"encoded":1146,"encodeMs":3194,"decoded":0,"decodeMs":0,"sentFrames":1146,"kbps":1089.122996416091,"output":[1920,1080],"receive":[null,null],"reason":"none","target":4107500,"retired":false,"meanQp":2.0357766143106457,"senderReports":0,"pli":0},"G1":{"encoded":529,"encodeMs":844.0000000000001,"decoded":0,"decodeMs":0,"sentFrames":529,"kbps":207.8798874443975,"output":[1920,1080],"receive":[null,null],"reason":"none","target":4113333,"retired":true,"meanQp":8.207939508506616,"senderReports":0,"pli":0},"B":{"encoded":1146,"encodeMs":163.99999999999972,"decoded":1146,"decodeMs":2181.8759999999997,"sentFrames":1146,"kbps":1098.5682655347862,"output":[28,16],"receive":[1920,1080],"reason":"none","target":4929000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":27.468411326810443,"bitrateKbps":1016.0945703874658,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":55,"intervalFramesEncoded":null},"meanQp":2,"senderReports":132,"pli":0}},"viewers":{"A":{"rendered":933,"fresh":933,"backwards":0,"duplicates":0,"maxGapMs":173.79999999701977,"ageP50":219,"ageP95":302.79999999701977,"av":{"attached":true,"videoMinusAudioMs":[-76.1000000089407,-0.10000000894069672,-28,-12.799999997019768,49.79999999701977,104.79999999701977,76.8999999910593,81.90000000596046,88.20000000298023,84.6000000089407,86.90000000596046,82.59999999403954,142.20000000298023,134.30000001192093,67.29999999701977,106.70000000298023,90.69999998807907,60.20000000298023,66.6000000089407,64.29999999701977],"missed":0}},"B":{"rendered":1119,"fresh":1119,"backwards":0,"duplicates":0,"maxGapMs":87.20000000298023,"ageP50":154.09999999403954,"ageP95":179.70000000298023,"av":{"attached":true,"videoMinusAudioMs":[66.19999998807907,61.20000000298023,51.6000000089407,62.5,49,76.79999999701977,105.70000000298023,66.70000000298023,76.19999998807907,56.70000000298023,69.70000000298023,53.5,105.09999999403954,98.79999999701977,54.599999994039536,92.20000000298023,112.6000000089407,48.099999994039536,87.90000000596046,90.6000000089407],"missed":0}}}}],"controls":[],"visibility":[],"shaper":{"forwardedBytes":4854759,"droppedBytes":66128,"packets":8253,"reverseBytes":61394,"maxQueueBytes":14587,"queuedBytes":0,"rate":0}}, + {"file":"build/browser-local-pool/carrier-h264-network-auto-1080-1788949964924.json","probeSha256":"85ef7541208ae43c0f658138a5b95e152005baa29a2cc30b03d2744df2ea6f91","mode":"carrier","codec":"H264","product":true,"high":true,"single":false,"late":false,"network":true,"automatic":true,"av":true,"background":false,"nativeSource":false,"lifecycle":false,"shapedRate":400000,"profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music"},"completed":true,"errors":[],"cleanup":{"browserExited":true,"nativeExited":true,"serverClosed":true,"portsClosed":true,"profileRemoved":true},"phases":[{"phase":"healthy","seconds":6.01079999999702,"browserCpuSeconds":4.556070999999999,"edges":{"A":{"encoded":178,"encodeMs":28.999999999999996,"decoded":176,"decodeMs":71.95899999999999,"sentFrames":178,"kbps":622.1388167967416,"output":[28,16],"receive":[1920,1080],"reason":"none","target":4847000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":28.58551010349926,"bitrateKbps":606.3946049392189,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":58,"intervalFramesEncoded":null},"meanQp":12,"senderReports":11,"pli":0},"G0":{"encoded":177,"encodeMs":870.9999999999999,"decoded":0,"decodeMs":0,"sentFrames":177,"kbps":611.7388700342422,"output":[1920,1080],"receive":[null,null],"reason":"none","target":5000000,"retired":false,"meanQp":16,"senderReports":0,"pli":0},"B":{"encoded":178,"encodeMs":27.000000000000004,"decoded":177,"decodeMs":88.657,"sentFrames":178,"kbps":625.6245424904946,"output":[28,16],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":28.982610433696596,"bitrateKbps":606.3949008131603,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":58,"intervalFramesEncoded":null},"meanQp":12,"senderReports":13,"pli":0}},"viewers":{"A":{"rendered":175,"fresh":175,"backwards":0,"duplicates":0,"maxGapMs":62.70000000298023,"ageP50":29.799999997019768,"ageP95":50.29999999701977,"av":{"attached":true,"videoMinusAudioMs":[-67.3999999910593,-47],"missed":0}},"B":{"rendered":175,"fresh":175,"backwards":0,"duplicates":0,"maxGapMs":69,"ageP50":41.70000000298023,"ageP95":64.09999999403954,"av":{"attached":true,"videoMinusAudioMs":[-63.099999994039536,-38.29999999701977],"missed":0}}}},{"phase":"limited","seconds":14.02720000000298,"browserCpuSeconds":13.729224999999998,"edges":{"A":{"encoded":410,"encodeMs":54.00000000000001,"decoded":368,"decodeMs":151.69000000000003,"sentFrames":410,"kbps":196.4497547621346,"output":[28,16],"receive":[1280,720],"reason":"none","target":259000,"retired":false,"details":{"frameWidth":1280,"frameHeight":720,"framesPerSecond":28.995650652229337,"bitrateKbps":197.28252787922605,"qualityLimitationReason":"bandwidth","nativeEdgeQualityState":"degraded","intervalFramesSent":58,"intervalFramesEncoded":null},"meanQp":12,"senderReports":18,"pli":0},"G0":{"encoded":408,"encodeMs":2788.9999999999995,"decoded":0,"decodeMs":0,"sentFrames":408,"kbps":594.4912740959157,"output":[1920,1080],"receive":[null,null],"reason":"none","target":4030833,"retired":false,"meanQp":16,"senderReports":0,"pli":0},"G1":{"encoded":376,"encodeMs":2717,"decoded":0,"decodeMs":0,"sentFrames":376,"kbps":164.4445078133562,"output":[1280,720],"receive":[null,null],"reason":"bandwidth","target":255000,"retired":false,"meanQp":37.90425531914894,"senderReports":0,"pli":0},"B":{"encoded":410,"encodeMs":54,"decoded":406,"decodeMs":306.25,"sentFrames":410,"kbps":603.3221170296425,"output":[28,16],"receive":[1920,1080],"reason":"none","target":4837000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":28.495725640984006,"bitrateKbps":621.408982581339,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":57,"intervalFramesEncoded":null},"meanQp":12,"senderReports":29,"pli":0}},"viewers":{"A":{"rendered":360,"fresh":360,"backwards":0,"duplicates":0,"maxGapMs":466.19999998807907,"ageP50":260.8999999910593,"ageP95":363.09999999403954,"av":{"attached":true,"videoMinusAudioMs":[-94.20000000298023,-79.5,-193,-141.59999999403954,-98.1000000089407],"missed":1}},"B":{"rendered":399,"fresh":399,"backwards":0,"duplicates":0,"maxGapMs":178.80000001192093,"ageP50":147.5,"ageP95":176.20000000298023,"av":{"attached":true,"videoMinusAudioMs":[62.19999998807907,84.20000000298023,78.5,50.29999999701977,35.20000000298023,69.40000000596046],"missed":0}}}},{"phase":"released","seconds":40.0205,"browserCpuSeconds":33.320818,"edges":{"A":{"encoded":1139,"encodeMs":127,"decoded":1142,"decodeMs":499.77899999999994,"sentFrames":1139,"kbps":526.4236079009509,"output":[28,16],"receive":[1920,1080],"reason":"none","target":4977000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":28.197381671701912,"bitrateKbps":626.2516519642912,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":56,"intervalFramesEncoded":null},"meanQp":12.019315188762072,"senderReports":70,"pli":0},"G0":{"encoded":1139,"encodeMs":7760,"decoded":0,"decodeMs":0,"sentFrames":1139,"kbps":606.0843817543509,"output":[1920,1080],"receive":[null,null],"reason":"none","target":4977000,"retired":false,"meanQp":16.017559262510975,"senderReports":0,"pli":0},"G1":{"encoded":0,"encodeMs":0,"decoded":0,"decodeMs":0,"sentFrames":0,"kbps":0,"output":[1280,720],"receive":[null,null],"reason":"bandwidth","target":255000,"retired":true,"meanQp":null,"senderReports":0,"pli":0},"B":{"encoded":1139,"encodeMs":130,"decoded":1138,"decodeMs":549.1189999999999,"sentFrames":1139,"kbps":616.2277832610788,"output":[28,16],"receive":[1920,1080],"reason":"none","target":4883000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":28.393185635345976,"bitrateKbps":629.7308717962244,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":71,"intervalFramesEncoded":null},"meanQp":12.019315188762072,"senderReports":80,"pli":0}},"viewers":{"A":{"rendered":1136,"fresh":1136,"backwards":0,"duplicates":0,"maxGapMs":96.6000000089407,"ageP50":215.69999998807907,"ageP95":279.1000000089407,"av":{"attached":true,"videoMinusAudioMs":[-36.900000005960464,17.299999997019768,-0.09999999403953552,61.400000005960464,88.59999999403954,101.69999998807907,94.70000000298023,64.29999999701977,78,82.6000000089407,81.5,133.20000000298023,108.40000000596046,43.20000000298023,56,70.70000000298023,57.29999999701977,48.70000000298023,55.8999999910593,42.70000000298023],"missed":0}},"B":{"rendered":1089,"fresh":1089,"backwards":0,"duplicates":0,"maxGapMs":92.29999999701977,"ageP50":122.8999999910593,"ageP95":159.79999999701977,"av":{"attached":true,"videoMinusAudioMs":[56.099999994039536,59.29999999701977,36.79999999701977,53.29999999701977,70,68.90000000596046,62.8999999910593,65.90000000596046,34.1000000089407,29.799999997019768,36.400000005960464,89.8999999910593,76.79999999701977,47.70000000298023,28.399999991059303,14.399999991059303,-0.09999999403953552,0,22.200000002980232,-0.09999999403953552],"missed":0}}}}],"controls":[],"visibility":[],"shaper":{"forwardedBytes":4018873,"droppedBytes":6705,"packets":8470,"reverseBytes":57243,"maxQueueBytes":14674,"queuedBytes":0,"rate":0}}, + {"file":"build/browser-local-pool/carrier-vp8-1080-1788949637972.json","probeSha256":"d5d826a5fb758a7f46ac4339b6247098828b0aae01a53a057719ab855d2af167","mode":"carrier","codec":"VP8","product":true,"high":true,"single":false,"late":false,"network":false,"automatic":false,"av":true,"background":false,"nativeSource":false,"lifecycle":true,"shapedRate":120000,"profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music"},"completed":true,"errors":[],"cleanup":{"browserExited":true,"nativeExited":true,"serverClosed":true,"portsClosed":true,"profileRemoved":true},"phases":[{"phase":"healthy","seconds":6.034399999991059,"browserCpuSeconds":8.384153000000001,"edges":{"A":{"encoded":177,"encodeMs":24.99999999999991,"decoded":179,"decodeMs":363.225,"sentFrames":177,"kbps":1446.412567945932,"output":[28,16],"receive":[1920,1080],"reason":"none","target":4522000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":29.369306585606523,"bitrateKbps":1462.7058573644667,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":59,"intervalFramesEncoded":null},"meanQp":2,"senderReports":26,"pli":0},"G0":{"encoded":179,"encodeMs":484,"decoded":0,"decodeMs":0,"sentFrames":179,"kbps":1457.4453135378878,"output":[1920,1080],"receive":[null,null],"reason":"none","target":4522000,"retired":false,"meanQp":2.0391061452513966,"senderReports":0,"pli":0},"B":{"encoded":177,"encodeMs":24.00000000000002,"decoded":179,"decodeMs":388.95000000000005,"sentFrames":177,"kbps":1446.412567945932,"output":[28,16],"receive":[1920,1080],"reason":"none","target":4522000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":29.369306585606523,"bitrateKbps":1462.704250391936,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":59,"intervalFramesEncoded":null},"meanQp":2,"senderReports":26,"pli":0}},"viewers":{"A":{"rendered":170,"fresh":170,"backwards":0,"duplicates":0,"maxGapMs":75.79999999701977,"ageP50":85.79999999701977,"ageP95":119.3999999910593,"av":{"attached":true,"videoMinusAudioMs":[31,-20.899999991059303,-8.900000005960464,40.79999999701977,38.1000000089407],"missed":0}},"B":{"rendered":168,"fresh":168,"backwards":0,"duplicates":0,"maxGapMs":69.5,"ageP50":81.30000001192093,"ageP95":119.40000000596046,"av":{"attached":true,"videoMinusAudioMs":[0,-34,30.799999997019768,27.799999997019768,46.3999999910593],"missed":0}}}},{"phase":"profile-720p60","seconds":6.015100000008941,"browserCpuSeconds":8.286792000000005,"edges":{"A":{"encoded":323,"encodeMs":44.99999999999993,"decoded":321,"decodeMs":314.404,"sentFrames":323,"kbps":1142.2134295339708,"output":[28,16],"receive":[1280,720],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1280,"frameHeight":720,"framesPerSecond":52.992051192400105,"bitrateKbps":1080.681592963453,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":106,"intervalFramesEncoded":null},"meanQp":2,"senderReports":21,"pli":0},"G0":{"encoded":321,"encodeMs":796.9999999999999,"decoded":0,"decodeMs":0,"sentFrames":321,"kbps":1122.3833352712284,"output":[1280,720],"receive":[null,null],"reason":"none","target":4987000,"retired":false,"meanQp":3.803738317757009,"senderReports":0,"pli":0},"B":{"encoded":323,"encodeMs":47.99999999999982,"decoded":320,"decodeMs":322.03499999999997,"sentFrames":323,"kbps":1142.2134295339708,"output":[28,16],"receive":[1280,720],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1280,"frameHeight":720,"framesPerSecond":52.992051192400105,"bitrateKbps":1080.6837035050612,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":106,"intervalFramesEncoded":null},"meanQp":2,"senderReports":23,"pli":0}},"viewers":{"A":{"rendered":298,"fresh":298,"backwards":0,"duplicates":0,"maxGapMs":62.70000000298023,"ageP50":59.400000005960464,"ageP95":76.1000000089407,"av":{"attached":true,"videoMinusAudioMs":[-28.599999994039536,-20.5,-17,-39,-34.5],"missed":0}},"B":{"rendered":301,"fresh":301,"backwards":0,"duplicates":0,"maxGapMs":46.3999999910593,"ageP50":70.5,"ageP95":92.5,"av":{"attached":true,"videoMinusAudioMs":[-13.599999994039536,-5,-14.399999991059303,-15.299999997019768,12.200000002980232],"missed":0}}}},{"phase":"quiet-profile-480p30","seconds":2.5118999999910594,"browserCpuSeconds":0.8929249999999982,"edges":{"A":{"encoded":2,"encodeMs":0,"decoded":2,"decodeMs":1.5319999999999778,"sentFrames":2,"kbps":17.57076316738608,"output":[28,16],"receive":[1280,720],"reason":"none","target":2500000,"retired":false,"details":{"frameWidth":null,"frameHeight":null,"framesPerSecond":null,"bitrateKbps":null,"qualityLimitationReason":null,"nativeEdgeQualityState":"unknown","intervalFramesSent":null,"intervalFramesEncoded":null},"meanQp":2,"senderReports":5,"pli":0},"G0":{"encoded":1,"encodeMs":3.0000000000001137,"decoded":0,"decodeMs":0,"sentFrames":1,"kbps":8.099048529030778,"output":[1280,720],"receive":[null,null],"reason":"none","target":2083333,"retired":false,"meanQp":2,"senderReports":0,"pli":0},"B":{"encoded":2,"encodeMs":0,"decoded":3,"decodeMs":2.5640000000000107,"sentFrames":2,"kbps":17.57076316738608,"output":[28,16],"receive":[1280,720],"reason":"none","target":2500000,"retired":false,"details":{"frameWidth":null,"frameHeight":null,"framesPerSecond":null,"bitrateKbps":null,"qualityLimitationReason":null,"nativeEdgeQualityState":"unknown","intervalFramesSent":null,"intervalFramesEncoded":null},"meanQp":2,"senderReports":4,"pli":0}},"viewers":{"A":{"rendered":1,"fresh":1,"backwards":0,"duplicates":0,"maxGapMs":2488.2999999970198,"ageP50":55.70000000298023,"ageP95":55.70000000298023,"av":{"attached":true,"videoMinusAudioMs":[],"missed":0}},"B":{"rendered":3,"fresh":3,"backwards":0,"duplicates":0,"maxGapMs":2468,"ageP50":85.90000000596046,"ageP95":91,"av":{"attached":true,"videoMinusAudioMs":[],"missed":0}}}},{"phase":"resumed-480p30","seconds":6.016100000008941,"browserCpuSeconds":3.714435999999992,"edges":{"A":{"encoded":180,"encodeMs":28.000000000000025,"decoded":179,"decodeMs":148.09699999999992,"sentFrames":180,"kbps":586.9676368402706,"output":[28,16],"receive":[854,480],"reason":"none","target":3000000,"retired":false,"details":{"frameWidth":854,"frameHeight":480,"framesPerSecond":29.997000299925308,"bitrateKbps":590.5029944403353,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":60,"intervalFramesEncoded":null},"meanQp":2.361111111111111,"senderReports":12,"pli":0},"G0":{"encoded":180,"encodeMs":374.0000000000001,"decoded":0,"decodeMs":0,"sentFrames":180,"kbps":569.7232426314234,"output":[854,480],"receive":[null,null],"reason":"none","target":3000000,"retired":false,"meanQp":2.577777777777778,"senderReports":0,"pli":0},"B":{"encoded":180,"encodeMs":27.000000000000135,"decoded":179,"decodeMs":149.59,"sentFrames":180,"kbps":586.9676368402706,"output":[28,16],"receive":[854,480],"reason":"none","target":3000000,"retired":false,"details":{"frameWidth":854,"frameHeight":480,"framesPerSecond":29.997000299925308,"bitrateKbps":590.5009039626647,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":60,"intervalFramesEncoded":null},"meanQp":2.361111111111111,"senderReports":12,"pli":0}},"viewers":{"A":{"rendered":179,"fresh":178,"backwards":0,"duplicates":0,"maxGapMs":63.1000000089407,"ageP50":73.40000000596046,"ageP95":97.5,"av":{"attached":true,"videoMinusAudioMs":[-1.8999999910593033,8.099999994039536,0,0,-1.7000000029802322,33.400000005960464],"missed":0}},"B":{"rendered":179,"fresh":178,"backwards":0,"duplicates":0,"maxGapMs":63.19999998807907,"ageP50":85.40000000596046,"ageP95":105.29999999701977,"av":{"attached":true,"videoMinusAudioMs":[16.900000005960464,35.79999999701977,34.099999994039536,22.80000001192093,16.799999997019768,45.20000000298023],"missed":0}}}},{"phase":"paused-profile-720p30","seconds":2.01570000000298,"browserCpuSeconds":0.8118970000000161,"edges":{"A":{"encoded":61,"encodeMs":3.0000000000001137,"decoded":1,"decodeMs":0.7440000000000779,"sentFrames":61,"kbps":0,"output":[28,16],"receive":[854,480],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":null,"frameHeight":null,"framesPerSecond":null,"bitrateKbps":null,"qualityLimitationReason":null,"nativeEdgeQualityState":"unknown","intervalFramesSent":null,"intervalFramesEncoded":null},"meanQp":2,"senderReports":3,"pli":0},"G0":{"encoded":61,"encodeMs":70.99999999999973,"decoded":0,"decodeMs":0,"sentFrames":61,"kbps":59.79064344883753,"output":[1280,720],"receive":[null,null],"reason":"none","target":4159167,"retired":false,"meanQp":9.885245901639344,"senderReports":0,"pli":0},"B":{"encoded":61,"encodeMs":4.0000000000000036,"decoded":1,"decodeMs":0.7369999999999877,"sentFrames":61,"kbps":0,"output":[28,16],"receive":[854,480],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":null,"frameHeight":null,"framesPerSecond":null,"bitrateKbps":null,"qualityLimitationReason":null,"nativeEdgeQualityState":"unknown","intervalFramesSent":null,"intervalFramesEncoded":null},"meanQp":2,"senderReports":3,"pli":0}},"viewers":{"A":{"rendered":1,"fresh":1,"backwards":0,"duplicates":0,"maxGapMs":1988.1000000089407,"ageP50":91.59999999403954,"ageP95":91.59999999403954,"av":{"attached":true,"videoMinusAudioMs":[],"missed":2}},"B":{"rendered":1,"fresh":1,"backwards":0,"duplicates":0,"maxGapMs":1986.2000000029802,"ageP50":93.5,"ageP95":93.5,"av":{"attached":true,"videoMinusAudioMs":[],"missed":2}}}},{"phase":"resumed-720p30","seconds":6.0165,"browserCpuSeconds":4.493794999999984,"edges":{"A":{"encoded":180,"encodeMs":16.999999999999904,"decoded":178,"decodeMs":163.76800000000003,"sentFrames":180,"kbps":700.258289703316,"output":[28,16],"receive":[1280,720],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1280,"frameHeight":720,"framesPerSecond":29.917726252804787,"bitrateKbps":694.444143976538,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":60,"intervalFramesEncoded":null},"meanQp":2,"senderReports":13,"pli":0},"G0":{"encoded":180,"encodeMs":442.00000000000017,"decoded":0,"decodeMs":0,"sentFrames":180,"kbps":685.5573838610488,"output":[1280,720],"receive":[null,null],"reason":"none","target":4989000,"retired":false,"meanQp":2.216666666666667,"senderReports":0,"pli":0},"B":{"encoded":180,"encodeMs":16.999999999999904,"decoded":178,"decodeMs":173.624,"sentFrames":180,"kbps":700.258289703316,"output":[28,16],"receive":[1280,720],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1280,"frameHeight":720,"framesPerSecond":29.917726252804787,"bitrateKbps":694.4451612714205,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":60,"intervalFramesEncoded":null},"meanQp":2,"senderReports":12,"pli":0}},"viewers":{"A":{"rendered":179,"fresh":179,"backwards":0,"duplicates":0,"maxGapMs":80,"ageP50":60.3999999910593,"ageP95":72.59999999403954,"av":{"attached":true,"videoMinusAudioMs":[24,-31.799999997019768,-3.2999999970197678,11.099999994039536,24.099999994039536,-25.099999994039536],"missed":0}},"B":{"rendered":179,"fresh":179,"backwards":0,"duplicates":0,"maxGapMs":76.79999999701977,"ageP50":60.29999999701977,"ageP95":73.29999999701977,"av":{"attached":true,"videoMinusAudioMs":[40.5,-16.19999998807907,-0.09999999403953552,21.099999994039536,23.399999991059303,-7.299999997019768],"missed":0}}}},{"phase":"replaced-source","seconds":6.031699999988079,"browserCpuSeconds":5.06792699999999,"edges":{"A":{"encoded":180,"encodeMs":167.00000000000003,"decoded":178,"decodeMs":163.99600000000004,"sentFrames":180,"kbps":703.6875176166568,"output":[28,16],"receive":[1280,720],"reason":"none","target":4889000,"retired":false,"details":{"frameWidth":1280,"frameHeight":720,"framesPerSecond":29.998500075085648,"bitrateKbps":747.0350633012447,"qualityLimitationReason":"none","nativeEdgeQualityState":"unknown","intervalFramesSent":60,"intervalFramesEncoded":null},"meanQp":4.527777777777778,"senderReports":14,"pli":0},"G0":{"encoded":0,"encodeMs":0,"decoded":0,"decodeMs":0,"sentFrames":0,"kbps":0,"output":[1280,720],"receive":[null,null],"reason":"none","target":4989000,"retired":true,"meanQp":null,"senderReports":0,"pli":0},"G1":{"encoded":171,"encodeMs":411,"decoded":0,"decodeMs":0,"sentFrames":171,"kbps":685.3245353728087,"output":[1280,720],"receive":[null,null],"reason":"none","target":4074167,"retired":false,"meanQp":7.473684210526316,"senderReports":0,"pli":0},"B":{"encoded":180,"encodeMs":179.00000000000006,"decoded":179,"decodeMs":170.404,"sentFrames":180,"kbps":703.6875176166568,"output":[28,16],"receive":[1280,720],"reason":"none","target":4889000,"retired":false,"details":{"frameWidth":1280,"frameHeight":720,"framesPerSecond":29.998500075085648,"bitrateKbps":747.0354280602737,"qualityLimitationReason":"none","nativeEdgeQualityState":"unknown","intervalFramesSent":60,"intervalFramesEncoded":null},"meanQp":4.527777777777778,"senderReports":14,"pli":0}},"viewers":{"A":{"rendered":177,"fresh":177,"backwards":0,"duplicates":1,"maxGapMs":76.19999998807907,"ageP50":67.70000000298023,"ageP95":84.69999998807907,"av":{"attached":true,"videoMinusAudioMs":[-3.3999999910593033,-16,-25,-0.10000000894069672,16.900000005960464],"missed":0}},"B":{"rendered":178,"fresh":178,"backwards":0,"duplicates":1,"maxGapMs":73.79999999701977,"ageP50":62.6000000089407,"ageP95":74.19999998807907,"av":{"attached":true,"videoMinusAudioMs":[-0.09999999403953552,-3.199999988079071,-5.299999997019768,10.299999997019768,-0.10000000894069672],"missed":0}}}},{"phase":"ordinary-after-producer-failure","seconds":6.017,"browserCpuSeconds":5.171544000000004,"edges":{"A":{"encoded":181,"encodeMs":461.99999999999994,"decoded":181,"decodeMs":161.90399999999983,"sentFrames":181,"kbps":821.6267242812032,"output":[1280,720],"receive":[1280,720],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1280,"frameHeight":720,"framesPerSecond":30,"bitrateKbps":855.2100822878756,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":null,"intervalFramesEncoded":60},"meanQp":5.088397790055248,"senderReports":16,"pli":0},"G0":{"encoded":0,"encodeMs":0,"decoded":0,"decodeMs":0,"sentFrames":0,"kbps":0,"output":[1280,720],"receive":[null,null],"reason":"none","target":4989000,"retired":true,"meanQp":null,"senderReports":0,"pli":0},"G1":{"encoded":0,"encodeMs":0,"decoded":0,"decodeMs":0,"sentFrames":0,"kbps":0,"output":[1280,720],"receive":[null,null],"reason":"none","target":4074167,"retired":true,"meanQp":null,"senderReports":0,"pli":0},"B":{"encoded":181,"encodeMs":479.0000000000001,"decoded":181,"decodeMs":161.82600000000002,"sentFrames":181,"kbps":821.6267242812032,"output":[1280,720],"receive":[1280,720],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1280,"frameHeight":720,"framesPerSecond":30,"bitrateKbps":855.2173893195608,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":null,"intervalFramesEncoded":60},"meanQp":5.088397790055248,"senderReports":14,"pli":0}},"viewers":{"A":{"rendered":182,"fresh":182,"backwards":0,"duplicates":0,"maxGapMs":63.1000000089407,"ageP50":80.70000000298023,"ageP95":88.79999999701977,"av":{"attached":true,"videoMinusAudioMs":[-0.09999999403953552,27.19999998807907,13.400000005960464,30.600000008940697,0,-4.9000000059604645],"missed":0}},"B":{"rendered":182,"fresh":182,"backwards":0,"duplicates":0,"maxGapMs":62,"ageP50":62.599999994039536,"ageP95":72.1000000089407,"av":{"attached":true,"videoMinusAudioMs":[-3.9000000059604645,17.799999997019768,14.300000011920929,-0.09999999403953552,-3.2999999970197678,-4.299999997019768],"missed":0}}}}],"controls":[{"phase":"profile-720p60","profile":{"resolution":"720p","maxFramerate":60,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music"},"elapsedMs":1.1000000089406967,"applied":[true,true]},{"phase":"quiet-profile-480p30","profile":{"resolution":"480p","maxFramerate":30,"maxBitrate":3000000,"degradationPreference":"balanced","screenAudioQuality":"music"},"elapsedMs":5.100000008940697,"applied":[true,true]},{"phase":"paused-profile-720p30","profile":{"resolution":"720p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music"},"elapsedMs":0.7000000029802322,"applied":[true,true]},{"action":"source-replaced","replaced":[true,true]}],"visibility":[],"shaper":null}, + {"file":"build/browser-local-pool/carrier-h264-1080-1788949553081.json","probeSha256":"2af10b2b369f0958784514bfc3888d975d1c093cbc0b0d8f90575ab7f41ea156","mode":"carrier","codec":"H264","product":true,"high":true,"single":false,"late":false,"network":false,"automatic":false,"av":true,"background":false,"nativeSource":false,"lifecycle":true,"shapedRate":120000,"profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music"},"completed":true,"errors":[],"cleanup":{"browserExited":true,"nativeExited":true,"serverClosed":true,"portsClosed":true,"profileRemoved":true},"phases":[{"phase":"healthy","seconds":6.0295,"browserCpuSeconds":4.545465,"edges":{"A":{"encoded":181,"encodeMs":24.99999999999991,"decoded":182,"decodeMs":60.99199999999993,"sentFrames":181,"kbps":697.0289410398873,"output":[28,16],"receive":[1920,1080],"reason":"none","target":4864000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":29.989503673669528,"bitrateKbps":694.0587318032772,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":60,"intervalFramesEncoded":null},"meanQp":12,"senderReports":12,"pli":0},"G0":{"encoded":181,"encodeMs":772,"decoded":0,"decodeMs":0,"sentFrames":181,"kbps":688.0676673024298,"output":[1920,1080],"receive":[null,null],"reason":"none","target":4864000,"retired":false,"meanQp":16.03314917127072,"senderReports":0,"pli":0},"B":{"encoded":181,"encodeMs":24.999999999999687,"decoded":181,"decodeMs":81.25100000000002,"sentFrames":181,"kbps":697.0289410398873,"output":[28,16],"receive":[1920,1080],"reason":"none","target":4864000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":29.989503673669528,"bitrateKbps":694.0601716795496,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":60,"intervalFramesEncoded":null},"meanQp":12,"senderReports":13,"pli":0}},"viewers":{"A":{"rendered":181,"fresh":181,"backwards":0,"duplicates":0,"maxGapMs":55.70000000298023,"ageP50":21.5,"ageP95":42.29999999701977,"av":{"attached":true,"videoMinusAudioMs":[-35,-16.100000008940697,-36.599999994039536,-58,-38.6000000089407,-34.79999999701977],"missed":0}},"B":{"rendered":181,"fresh":181,"backwards":0,"duplicates":0,"maxGapMs":55.5,"ageP50":25.799999997019768,"ageP95":47.400000005960464,"av":{"attached":true,"videoMinusAudioMs":[-39.099999994039536,-11.800000011920929,-29.599999994039536,-51.900000005960464,-33.900000005960464,-30.5],"missed":0}}}},{"phase":"profile-720p60","seconds":6.0156000000089405,"browserCpuSeconds":7.597189,"edges":{"A":{"encoded":316,"encodeMs":34.000000000000256,"decoded":305,"decodeMs":127.73600000000008,"sentFrames":316,"kbps":661.5054192423175,"output":[28,16],"receive":[1280,720],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1280,"frameHeight":720,"framesPerSecond":49.49505049487676,"bitrateKbps":606.9095489292558,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":99,"intervalFramesEncoded":null},"meanQp":12,"senderReports":13,"pli":0},"G0":{"encoded":306,"encodeMs":1674,"decoded":0,"decodeMs":0,"sentFrames":306,"kbps":653.7150076458136,"output":[1280,720],"receive":[null,null],"reason":"none","target":5000000,"retired":false,"meanQp":16,"senderReports":0,"pli":0},"B":{"encoded":316,"encodeMs":35.00000000000014,"decoded":305,"decodeMs":150.319,"sentFrames":316,"kbps":662.8392845259117,"output":[28,16],"receive":[1280,720],"reason":"none","target":4712000,"retired":false,"details":{"frameWidth":1280,"frameHeight":720,"framesPerSecond":49.49505049487676,"bitrateKbps":606.907993243654,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":99,"intervalFramesEncoded":null},"meanQp":12,"senderReports":13,"pli":0}},"viewers":{"A":{"rendered":284,"fresh":284,"backwards":0,"duplicates":0,"maxGapMs":48.19999998807907,"ageP50":38.599999994039536,"ageP95":58.69999998807907,"av":{"attached":true,"videoMinusAudioMs":[-57.79999999701977,-18.299999997019768,-45.099999994039536,-31.600000008940697,-21.799999997019768,-29.900000005960464],"missed":0}},"B":{"rendered":280,"fresh":280,"backwards":0,"duplicates":0,"maxGapMs":46.5,"ageP50":37.900000005960464,"ageP95":61.6000000089407,"av":{"attached":true,"videoMinusAudioMs":[-53.599999994039536,-16,-41.8999999910593,-28,-18,-25.599999994039536],"missed":0}}}},{"phase":"quiet-profile-480p30","seconds":2.5087000000029804,"browserCpuSeconds":0.6765129999999964,"edges":{"A":{"encoded":1,"encodeMs":0,"decoded":1,"decodeMs":0.39400000000000546,"sentFrames":1,"kbps":3.715071550998098,"output":[28,16],"receive":[1280,720],"reason":"none","target":2500000,"retired":false,"details":{"frameWidth":null,"frameHeight":null,"framesPerSecond":null,"bitrateKbps":null,"qualityLimitationReason":null,"nativeEdgeQualityState":"unknown","intervalFramesSent":null,"intervalFramesEncoded":null},"meanQp":12,"senderReports":2,"pli":0},"G0":{"encoded":1,"encodeMs":11.00000000000012,"decoded":0,"decodeMs":0,"sentFrames":1,"kbps":5.443456770432405,"output":[1280,720],"receive":[null,null],"reason":"none","target":2083333,"retired":false,"meanQp":16,"senderReports":0,"pli":0},"B":{"encoded":1,"encodeMs":0,"decoded":1,"decodeMs":0.37999999999999146,"sentFrames":1,"kbps":3.715071550998098,"output":[28,16],"receive":[1280,720],"reason":"none","target":2500000,"retired":false,"details":{"frameWidth":null,"frameHeight":null,"framesPerSecond":null,"bitrateKbps":null,"qualityLimitationReason":null,"nativeEdgeQualityState":"unknown","intervalFramesSent":null,"intervalFramesEncoded":null},"meanQp":12,"senderReports":4,"pli":0}},"viewers":{"A":{"rendered":1,"fresh":1,"backwards":0,"duplicates":0,"maxGapMs":2481,"ageP50":58.5,"ageP95":58.5,"av":{"attached":true,"videoMinusAudioMs":[],"missed":0}},"B":{"rendered":1,"fresh":1,"backwards":0,"duplicates":0,"maxGapMs":2494.300000011921,"ageP50":45.19999998807907,"ageP95":45.19999998807907,"av":{"attached":true,"videoMinusAudioMs":[],"missed":0}}}},{"phase":"resumed-480p30","seconds":6.00729999999702,"browserCpuSeconds":4.036857000000005,"edges":{"A":{"encoded":180,"encodeMs":21.999999999999797,"decoded":179,"decodeMs":64.70500000000001,"sentFrames":180,"kbps":437.87392006413944,"output":[28,16],"receive":[854,480],"reason":"none","target":3000000,"retired":false,"details":{"frameWidth":854,"frameHeight":480,"framesPerSecond":30.001500074914333,"bitrateKbps":368.2205861079436,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":60,"intervalFramesEncoded":null},"meanQp":12,"senderReports":8,"pli":0},"G0":{"encoded":180,"encodeMs":1048,"decoded":0,"decodeMs":0,"sentFrames":180,"kbps":418.32037687500986,"output":[854,480],"receive":[null,null],"reason":"none","target":3000000,"retired":false,"meanQp":16,"senderReports":0,"pli":0},"B":{"encoded":180,"encodeMs":23.00000000000013,"decoded":179,"decodeMs":71.56300000000005,"sentFrames":180,"kbps":438.5344497530183,"output":[28,16],"receive":[854,480],"reason":"none","target":3000000,"retired":false,"details":{"frameWidth":854,"frameHeight":480,"framesPerSecond":30.001500074914333,"bitrateKbps":370.2057601152008,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":60,"intervalFramesEncoded":null},"meanQp":12,"senderReports":8,"pli":0}},"viewers":{"A":{"rendered":179,"fresh":178,"backwards":0,"duplicates":0,"maxGapMs":63.3999999910593,"ageP50":75.5,"ageP95":99.8999999910593,"av":{"attached":true,"videoMinusAudioMs":[9.5,31,-6.299999997019768,30.200000002980232,25.099999994039536],"missed":0}},"B":{"rendered":179,"fresh":178,"backwards":0,"duplicates":0,"maxGapMs":61.8999999910593,"ageP50":68.5,"ageP95":88.59999999403954,"av":{"attached":true,"videoMinusAudioMs":[12.5,33.8999999910593,-3.7999999970197678,16.399999991059303,9.900000005960464],"missed":0}}}},{"phase":"paused-profile-720p30","seconds":2.0082000000029803,"browserCpuSeconds":0.8427919999999993,"edges":{"A":{"encoded":60,"encodeMs":2.000000000000224,"decoded":1,"decodeMs":0.35399999999996545,"sentFrames":60,"kbps":0,"output":[28,16],"receive":[854,480],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":null,"frameHeight":null,"framesPerSecond":null,"bitrateKbps":null,"qualityLimitationReason":null,"nativeEdgeQualityState":"unknown","intervalFramesSent":null,"intervalFramesEncoded":null},"meanQp":12.366666666666667,"senderReports":3,"pli":0},"G0":{"encoded":54,"encodeMs":60.99999999999994,"decoded":0,"decodeMs":0,"sentFrames":54,"kbps":18.04601135342407,"output":[1280,720],"receive":[null,null],"reason":"none","target":4166667,"retired":false,"meanQp":14.24074074074074,"senderReports":0,"pli":0},"B":{"encoded":60,"encodeMs":0,"decoded":1,"decodeMs":0.2989999999999382,"sentFrames":60,"kbps":0,"output":[28,16],"receive":[854,480],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":null,"frameHeight":null,"framesPerSecond":null,"bitrateKbps":null,"qualityLimitationReason":null,"nativeEdgeQualityState":"unknown","intervalFramesSent":null,"intervalFramesEncoded":null},"meanQp":12.366666666666667,"senderReports":1,"pli":0}},"viewers":{"A":{"rendered":1,"fresh":1,"backwards":0,"duplicates":0,"maxGapMs":1983.2000000029802,"ageP50":78.20000000298023,"ageP95":78.20000000298023,"av":{"attached":true,"videoMinusAudioMs":[],"missed":1}},"B":{"rendered":1,"fresh":1,"backwards":0,"duplicates":0,"maxGapMs":1997.2000000029802,"ageP50":64.20000000298023,"ageP95":64.20000000298023,"av":{"attached":true,"videoMinusAudioMs":[],"missed":1}}}},{"phase":"resumed-720p30","seconds":6.007699999988079,"browserCpuSeconds":4.047587999999994,"edges":{"A":{"encoded":180,"encodeMs":20.000000000000018,"decoded":177,"decodeMs":68.89799999999991,"sentFrames":180,"kbps":519.5572348829326,"output":[28,16],"receive":[1280,720],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1280,"frameHeight":720,"framesPerSecond":29.995500674720006,"bitrateKbps":483.6970429142255,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":60,"intervalFramesEncoded":null},"meanQp":12.366666666666667,"senderReports":7,"pli":0},"G0":{"encoded":180,"encodeMs":407.99999999999994,"decoded":0,"decodeMs":0,"sentFrames":180,"kbps":504.99592190123013,"output":[1280,720],"receive":[null,null],"reason":"none","target":4166667,"retired":false,"meanQp":12.366666666666667,"senderReports":0,"pli":0},"B":{"encoded":180,"encodeMs":17.999999999999794,"decoded":177,"decodeMs":83.17099999999999,"sentFrames":180,"kbps":516.5637431972565,"output":[28,16],"receive":[1280,720],"reason":"none","target":4883000,"retired":false,"details":{"frameWidth":1280,"frameHeight":720,"framesPerSecond":29.995500674720006,"bitrateKbps":483.69869595242636,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":60,"intervalFramesEncoded":null},"meanQp":12.366666666666667,"senderReports":8,"pli":0}},"viewers":{"A":{"rendered":177,"fresh":177,"backwards":0,"duplicates":0,"maxGapMs":77.3999999910593,"ageP50":59.29999999701977,"ageP95":78.20000000298023,"av":{"attached":true,"videoMinusAudioMs":[-21,-4.5,-2.5,-8.700000002980232,0,18.900000005960464],"missed":0}},"B":{"rendered":173,"fresh":173,"backwards":0,"duplicates":0,"maxGapMs":77.1000000089407,"ageP50":56.79999999701977,"ageP95":75.1000000089407,"av":{"attached":true,"videoMinusAudioMs":[-34.70000000298023,-2,0,-4.299999997019768,-15,22.900000005960464],"missed":0}}}},{"phase":"replaced-source","seconds":6.0245,"browserCpuSeconds":4.719306000000007,"edges":{"A":{"encoded":182,"encodeMs":1867.9999999999998,"decoded":180,"decodeMs":332.82500000000016,"sentFrames":182,"kbps":451.79649763465846,"output":[28,16],"receive":[1280,720],"reason":"none","target":4795000,"retired":false,"details":{"frameWidth":1280,"frameHeight":720,"framesPerSecond":30.001500074914333,"bitrateKbps":444.5508903446846,"qualityLimitationReason":"none","nativeEdgeQualityState":"unknown","intervalFramesSent":60,"intervalFramesEncoded":null},"meanQp":13.098901098901099,"senderReports":11,"pli":0},"G0":{"encoded":-5,"encodeMs":-12.000000000000455,"decoded":0,"decodeMs":0,"sentFrames":-5,"kbps":-15.333388662959582,"output":[1280,720],"receive":[null,null],"reason":"none","target":4974000,"retired":true,"meanQp":null,"senderReports":0,"pli":0},"G1":{"encoded":163,"encodeMs":732,"decoded":0,"decodeMs":0,"sentFrames":163,"kbps":380.88472072371155,"output":[1280,720],"receive":[null,null],"reason":"none","target":5000000,"retired":false,"meanQp":18.11042944785276,"senderReports":0,"pli":0},"B":{"encoded":182,"encodeMs":1853.0000000000002,"decoded":180,"decodeMs":353.88399999999996,"sentFrames":182,"kbps":455.355299194954,"output":[28,16],"receive":[1280,720],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1280,"frameHeight":720,"framesPerSecond":30.001500074914333,"bitrateKbps":455.2708259051917,"qualityLimitationReason":"none","nativeEdgeQualityState":"unknown","intervalFramesSent":60,"intervalFramesEncoded":null},"meanQp":13.098901098901099,"senderReports":11,"pli":0}},"viewers":{"A":{"rendered":174,"fresh":174,"backwards":0,"duplicates":1,"maxGapMs":290.79999999701977,"ageP50":76.09999999403954,"ageP95":86.8999999910593,"av":{"attached":true,"videoMinusAudioMs":[11.600000008940697,14.100000008940697,9.800000011920929,16.299999997019768,30.399999991059303,32.79999999701977],"missed":0}},"B":{"rendered":175,"fresh":175,"backwards":0,"duplicates":1,"maxGapMs":303.29999999701977,"ageP50":66.30000001192093,"ageP95":77.69999998807907,"av":{"attached":true,"videoMinusAudioMs":[-3.699999988079071,0,-9,18.900000005960464,10.700000002980232,18.599999994039536],"missed":0}}}},{"phase":"ordinary-after-producer-failure","seconds":6.020900000005961,"browserCpuSeconds":4.796816,"edges":{"A":{"encoded":180,"encodeMs":1345.9999999999995,"decoded":180,"decodeMs":60.22400000000005,"sentFrames":180,"kbps":428.75317643499216,"output":[1280,720],"receive":[1280,720],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1280,"frameHeight":720,"framesPerSecond":30,"bitrateKbps":442.0551002028741,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":null,"intervalFramesEncoded":60},"meanQp":16,"senderReports":8,"pli":0},"G0":{"encoded":0,"encodeMs":0,"decoded":0,"decodeMs":0,"sentFrames":0,"kbps":0,"output":[1280,720],"receive":[null,null],"reason":"none","target":4974000,"retired":true,"meanQp":null,"senderReports":0,"pli":0},"G1":{"encoded":-6,"encodeMs":-31.00000000000003,"decoded":0,"decodeMs":0,"sentFrames":-6,"kbps":-13.492999385460575,"output":[1280,720],"receive":[null,null],"reason":"none","target":5000000,"retired":true,"meanQp":null,"senderReports":0,"pli":0},"B":{"encoded":180,"encodeMs":1380.9999999999998,"decoded":181,"decodeMs":60.657000000000075,"sentFrames":180,"kbps":426.27912770473836,"output":[1280,720],"receive":[1280,720],"reason":"none","target":4846000,"retired":false,"details":{"frameWidth":1280,"frameHeight":720,"framesPerSecond":30,"bitrateKbps":434.60852147939914,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":null,"intervalFramesEncoded":60},"meanQp":16,"senderReports":7,"pli":0}},"viewers":{"A":{"rendered":181,"fresh":181,"backwards":0,"duplicates":0,"maxGapMs":63.20000000298023,"ageP50":80.69999998807907,"ageP95":90.8999999910593,"av":{"attached":true,"videoMinusAudioMs":[13.5,-3.7000000029802322,25.5,0,10.799999997019768,10.799999997019768],"missed":0}},"B":{"rendered":181,"fresh":181,"backwards":0,"duplicates":0,"maxGapMs":72.5,"ageP50":66.8999999910593,"ageP95":74.70000000298023,"av":{"attached":true,"videoMinusAudioMs":[0,-0.10000000894069672,11.299999997019768,-15.799999997019768,-9.700000002980232,-3.5],"missed":0}}}}],"controls":[{"phase":"profile-720p60","profile":{"resolution":"720p","maxFramerate":60,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music"},"elapsedMs":1.0999999940395355,"applied":[true,true]},{"phase":"quiet-profile-480p30","profile":{"resolution":"480p","maxFramerate":30,"maxBitrate":3000000,"degradationPreference":"balanced","screenAudioQuality":"music"},"elapsedMs":1.2000000029802322,"applied":[true,true]},{"phase":"paused-profile-720p30","profile":{"resolution":"720p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music"},"elapsedMs":0.9000000059604645,"applied":[true,true]},{"action":"source-replaced","replaced":[true,true]}],"visibility":[],"shaper":null,"qualification":"Lifecycle/media evidence only: retired-group summaries fell back to older samples, so retirement deltas are not cost evidence. The fixture now retains its latest per-instance report."}, + {"file":"build/browser-local-pool/carrier-vp8-late-1080-1788949695492.json","probeSha256":"d5d826a5fb758a7f46ac4339b6247098828b0aae01a53a057719ab855d2af167","mode":"carrier","codec":"VP8","product":true,"high":true,"single":false,"late":true,"network":false,"automatic":false,"av":true,"background":false,"nativeSource":false,"lifecycle":false,"shapedRate":120000,"profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music"},"completed":true,"errors":[],"cleanup":{"browserExited":true,"nativeExited":true,"serverClosed":true,"portsClosed":true,"profileRemoved":true},"phases":[{"phase":"healthy","seconds":6.025200000002981,"browserCpuSeconds":7.878384000000006,"edges":{"A":{"encoded":175,"encodeMs":26.999999999999915,"decoded":176,"decodeMs":351.283,"sentFrames":175,"kbps":1185.6190665864149,"output":[28,16],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":29.34909339430311,"bitrateKbps":1207.0345559298141,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":73,"intervalFramesEncoded":null},"meanQp":2,"senderReports":21,"pli":0},"G0":{"encoded":177,"encodeMs":482,"decoded":0,"decodeMs":0,"sentFrames":177,"kbps":1190.0869680668613,"output":[1920,1080],"receive":[null,null],"reason":"none","target":5000000,"retired":false,"meanQp":2,"senderReports":0,"pli":0},"B":{"encoded":173,"encodeMs":29.000000000000025,"decoded":174,"decodeMs":394.7149999999999,"sentFrames":173,"kbps":1168.459138285288,"output":[28,16],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":29.34909339430311,"bitrateKbps":1218.4321735547087,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":73,"intervalFramesEncoded":null},"meanQp":2,"senderReports":20,"pli":0}},"viewers":{"A":{"rendered":175,"fresh":175,"backwards":0,"duplicates":0,"maxGapMs":100.70000000298023,"ageP50":42.19999998807907,"ageP95":79.59999999403954,"av":{"attached":true,"videoMinusAudioMs":[-50,-61.70000000298023,-29],"missed":0}},"B":{"rendered":174,"fresh":174,"backwards":0,"duplicates":0,"maxGapMs":83.29999999701977,"ageP50":103.8999999910593,"ageP95":157.79999999701977,"av":{"attached":true,"videoMinusAudioMs":[-33.70000000298023,34.80000001192093,73.79999999701977],"missed":0}}}}],"controls":[],"visibility":[],"shaper":null}, + {"file":"build/browser-local-pool/carrier-vp8-1080-relay-1788950482783.json","probeSha256":"fd25678d56e779cc7a82c0c7d43266b043c9b53a948bea63f57c1b5d63e5e43d","mode":"carrier","codec":"VP8","product":true,"high":true,"single":false,"late":false,"network":false,"automatic":false,"av":true,"background":false,"nativeSource":false,"lifecycle":false,"relay":true,"shapedRate":120000,"profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music"},"completed":true,"errors":[],"cleanup":{"browserExited":true,"nativeExited":true,"serverClosed":true,"portsClosed":true,"profileRemoved":true},"phases":[{"phase":"healthy","seconds":6.00629999999702,"browserCpuSeconds":10.461185,"edges":{"U":{"encoded":163,"encodeMs":455,"decoded":163,"decodeMs":320.31399999999996,"sentFrames":163,"kbps":1253.7036112088535,"output":[1920,1080],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":25,"bitrateKbps":1234.736836359395,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":null,"intervalFramesEncoded":52},"meanQp":2,"senderReports":22,"pli":0},"A":{"encoded":161,"encodeMs":54,"decoded":160,"decodeMs":380.178,"sentFrames":161,"kbps":1461.1431330443625,"output":[28,16],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":26.1219022103148,"bitrateKbps":1450.3282159094688,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":39,"intervalFramesEncoded":null},"meanQp":2,"senderReports":28,"pli":0},"B":{"encoded":161,"encodeMs":52.99999999999999,"decoded":160,"decodeMs":398.37,"sentFrames":161,"kbps":1460.262724140378,"output":[28,16],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":26.1219022103148,"bitrateKbps":1447.9879894960247,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":39,"intervalFramesEncoded":null},"meanQp":2,"senderReports":26,"pli":0},"G0":{"encoded":161,"encodeMs":443.00000000000006,"decoded":0,"decodeMs":0,"sentFrames":161,"kbps":1456.5306428257566,"output":[1920,1080],"receive":[null,null],"reason":"none","target":5000000,"retired":false,"meanQp":2.0434782608695654,"senderReports":0,"pli":0}},"viewers":{"A":{"rendered":152,"fresh":152,"backwards":0,"duplicates":0,"maxGapMs":67.6000000089407,"ageP50":52.3999999910593,"ageP95":70.6000000089407,"av":{"attached":true,"videoMinusAudioMs":[-99.40000000596046,-59.3999999910593],"missed":0}},"B":{"rendered":154,"fresh":154,"backwards":0,"duplicates":0,"maxGapMs":77.29999999701977,"ageP50":90.09999999403954,"ageP95":121.70000000298023,"av":{"attached":true,"videoMinusAudioMs":[-39.1000000089407,0],"missed":0}}}}],"controls":[],"visibility":[],"shaper":null}, + {"file":"build/browser-local-pool/carrier-h264-1080-relay-1788951034127.json","probeSha256":"3570b48d7c69ff6df3bda0979244e362abdb8ccb874a2856e67fbd671f3ffee2","mode":"carrier","codec":"H264","product":true,"high":true,"single":false,"late":false,"network":false,"automatic":false,"av":true,"background":false,"nativeSource":false,"lifecycle":false,"relay":true,"shapedRate":120000,"profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music"},"completed":true,"errors":[],"cleanup":{"browserExited":true,"nativeExited":true,"serverClosed":true,"portsClosed":true,"profileRemoved":true},"phases":[{"phase":"healthy","seconds":6.009,"browserCpuSeconds":7.273621,"edges":{"U":{"encoded":177,"encodeMs":698.9999999999999,"decoded":177,"decodeMs":62.765000000000015,"sentFrames":177,"kbps":624.5724746214012,"output":[1920,1080],"receive":[1920,1080],"reason":"none","target":4784000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":28,"bitrateKbps":588.4865371941102,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":null,"intervalFramesEncoded":57},"meanQp":16,"senderReports":15,"pli":0},"A":{"encoded":177,"encodeMs":1380,"decoded":177,"decodeMs":69.484,"sentFrames":177,"kbps":589.3559660509236,"output":[28,16],"receive":[1920,1080],"reason":"none","target":4908000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":28.9666883084884,"bitrateKbps":561.6766674290909,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":58,"intervalFramesEncoded":null},"meanQp":12,"senderReports":12,"pli":0},"B":{"encoded":177,"encodeMs":1770,"decoded":177,"decodeMs":81.18599999999999,"sentFrames":177,"kbps":589.3559660509236,"output":[28,16],"receive":[1920,1080],"reason":"none","target":4908000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":28.9666883084884,"bitrateKbps":561.6744075579182,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":58,"intervalFramesEncoded":null},"meanQp":12,"senderReports":12,"pli":0},"G0":{"encoded":177,"encodeMs":1690.0000000000002,"decoded":0,"decodeMs":0,"sentFrames":177,"kbps":579.6132467964719,"output":[1920,1080],"receive":[null,null],"reason":"none","target":4908000,"retired":false,"meanQp":16.016949152542374,"senderReports":0,"pli":0}},"viewers":{"A":{"rendered":176,"fresh":176,"backwards":0,"duplicates":0,"maxGapMs":71.59999999403954,"ageP50":39.099999994039536,"ageP95":77.59999999403954,"av":{"attached":true,"videoMinusAudioMs":[-69.29999999701977,-104.6000000089407],"missed":0}},"B":{"rendered":176,"fresh":176,"backwards":0,"duplicates":0,"maxGapMs":80.5,"ageP50":43.6000000089407,"ageP95":80.59999999403954,"av":{"attached":true,"videoMinusAudioMs":[-63.79999999701977,-100.29999999701977],"missed":0}}}}],"controls":[],"visibility":[],"shaper":null}, + {"file":"build/browser-local-pool/carrier-vp8-1080-background-1788950655600.json","probeSha256":"2e3957d6154883f815fc7e0400da38329ba2a9e9128b49e902d645954ffd6b66","mode":"carrier","codec":"VP8","product":true,"high":true,"single":false,"late":false,"network":false,"automatic":false,"av":true,"background":true,"nativeSource":true,"lifecycle":false,"relay":false,"shapedRate":120000,"profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"maintain-resolution","screenAudioQuality":"music"},"completed":false,"errors":[],"cleanup":{"browserExited":true,"nativeExited":true,"serverClosed":true,"portsClosed":true,"profileRemoved":false},"phases":[{"phase":"healthy","seconds":6.006199999988079,"browserCpuSeconds":3.8092769999999962,"edges":{"A":{"encoded":120,"encodeMs":27.000000000000004,"decoded":120,"decodeMs":245.259,"sentFrames":120,"kbps":867.9271419550377,"output":[28,16],"receive":[1920,1080],"reason":"none","target":4890000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":19.998000199950205,"bitrateKbps":911.1439230415821,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":40,"intervalFramesEncoded":null},"meanQp":2,"senderReports":16,"pli":0},"B":{"encoded":120,"encodeMs":28.000000000000004,"decoded":120,"decodeMs":253.83800000000002,"sentFrames":120,"kbps":863.250641006009,"output":[28,16],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":19.998000199950205,"bitrateKbps":902.3353367438508,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":40,"intervalFramesEncoded":null},"meanQp":2,"senderReports":15,"pli":0},"G0":{"encoded":120,"encodeMs":331,"decoded":0,"decodeMs":0,"sentFrames":120,"kbps":849.5927541557271,"output":[1920,1080],"receive":[null,null],"reason":"none","target":4075000,"retired":false,"meanQp":11.666666666666666,"senderReports":0,"pli":0}},"viewers":{"A":{"rendered":120,"fresh":null,"backwards":0,"duplicates":0,"maxGapMs":83.29999999701977,"ageP50":null,"ageP95":null,"av":{"attached":true,"videoMinusAudioMs":[],"missed":0}},"B":{"rendered":120,"fresh":null,"backwards":0,"duplicates":0,"maxGapMs":69.5,"ageP50":null,"ageP95":null,"av":{"attached":true,"videoMinusAudioMs":[],"missed":0}}}},{"phase":"hidden","seconds":10.046100000008941,"browserCpuSeconds":7.570709000000004,"edges":{"A":{"encoded":201,"encodeMs":28.999999999999996,"decoded":200,"decodeMs":404.57099999999997,"sentFrames":201,"kbps":903.455072116734,"output":[28,16],"receive":[1920,1080],"reason":"none","target":4774000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":19.90049751243781,"bitrateKbps":889.6019250822796,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":40,"intervalFramesEncoded":null},"meanQp":2,"senderReports":28,"pli":0},"B":{"encoded":201,"encodeMs":33,"decoded":200,"decodeMs":422.652,"sentFrames":201,"kbps":903.7290092664734,"output":[28,16],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":20.094443886325617,"bitrateKbps":860.6695287992766,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":40,"intervalFramesEncoded":null},"meanQp":2,"senderReports":28,"pli":0},"G0":{"encoded":201,"encodeMs":561,"decoded":0,"decodeMs":0,"sentFrames":201,"kbps":895.8302226726779,"output":[1920,1080],"receive":[null,null],"reason":"none","target":4994000,"retired":false,"meanQp":2.0348258706467663,"senderReports":0,"pli":0}},"viewers":{"A":{"rendered":0,"fresh":null,"backwards":0,"duplicates":0,"maxGapMs":10046.10000000894,"ageP50":null,"ageP95":null,"av":{"attached":true,"videoMinusAudioMs":[],"missed":0}},"B":{"rendered":0,"fresh":null,"backwards":0,"duplicates":0,"maxGapMs":10046.10000000894,"ageP50":null,"ageP95":null,"av":{"attached":true,"videoMinusAudioMs":[],"missed":0}}}},{"phase":"visible-again","seconds":6.017599999994039,"browserCpuSeconds":3.822543999999997,"edges":{"A":{"encoded":120,"encodeMs":21.000000000000004,"decoded":120,"decodeMs":235.1850000000001,"sentFrames":120,"kbps":899.8417973952014,"output":[28,16],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":19.99200319875029,"bitrateKbps":915.91272697458,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":40,"intervalFramesEncoded":null},"meanQp":2,"senderReports":17,"pli":0},"B":{"encoded":121,"encodeMs":21.999999999999993,"decoded":120,"decodeMs":236.22599999999994,"sentFrames":121,"kbps":900.0970486581637,"output":[28,16],"receive":[1920,1080],"reason":"none","target":4889000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":19.99200319875029,"bitrateKbps":916.6817993867542,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":40,"intervalFramesEncoded":null},"meanQp":2,"senderReports":18,"pli":0},"G0":{"encoded":120,"encodeMs":336.99999999999983,"decoded":0,"decodeMs":0,"sentFrames":120,"kbps":893.3182664193905,"output":[1920,1080],"receive":[null,null],"reason":"none","target":4994000,"retired":false,"meanQp":2,"senderReports":0,"pli":0}},"viewers":{"A":{"rendered":120,"fresh":null,"backwards":0,"duplicates":0,"maxGapMs":70.40000000596046,"ageP50":null,"ageP95":null,"av":{"attached":true,"videoMinusAudioMs":[],"missed":0}},"B":{"rendered":120,"fresh":null,"backwards":0,"duplicates":0,"maxGapMs":70.30000001192093,"ageP50":null,"ageP95":null,"av":{"attached":true,"videoMinusAudioMs":[],"missed":0}}}}],"controls":[],"visibility":[{"at":9121.10000000894,"requested":"hidden","actual":"hidden"},{"at":19161,"requested":"visible","actual":"visible"}],"shaper":null,"qualification":"Media continued in hidden page; profile cleanup failed. Separate repeat completed cleanup. Native fake source has no timed audio pulses."}, + {"file":"build/browser-local-pool/carrier-vp8-1080-background-1788950957288.json","probeSha256":"2e3957d6154883f815fc7e0400da38329ba2a9e9128b49e902d645954ffd6b66","mode":"carrier","codec":"VP8","product":true,"high":true,"single":false,"late":false,"network":false,"automatic":false,"av":false,"background":true,"nativeSource":true,"lifecycle":false,"relay":false,"shapedRate":120000,"profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"maintain-resolution","screenAudioQuality":"music"},"completed":true,"errors":[],"cleanup":{"browserExited":true,"nativeExited":true,"serverClosed":true,"portsClosed":true,"profileRemoved":true},"phases":[{"phase":"healthy","seconds":6.005100000008941,"browserCpuSeconds":3.365952,"edges":{"A":{"encoded":121,"encodeMs":16,"decoded":110,"decodeMs":220.688,"sentFrames":121,"kbps":771.3496860989997,"output":[28,16],"receive":[1920,1080],"reason":"none","target":1755000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":19.997000449962304,"bitrateKbps":886.3585851231987,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":40,"intervalFramesEncoded":null},"meanQp":2,"senderReports":10,"pli":0},"B":{"encoded":121,"encodeMs":18,"decoded":110,"decodeMs":234.619,"sentFrames":121,"kbps":771.3496860989997,"output":[28,16],"receive":[1920,1080],"reason":"none","target":1755000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":19.997000449962304,"bitrateKbps":886.3603160195337,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":40,"intervalFramesEncoded":null},"meanQp":2,"senderReports":11,"pli":0},"G0":{"encoded":110,"encodeMs":284.00000000000006,"decoded":0,"decodeMs":0,"sentFrames":110,"kbps":770.3958302098404,"output":[1920,1080],"receive":[null,null],"reason":"none","target":1755000,"retired":false,"meanQp":11.218181818181819,"senderReports":0,"pli":0}},"viewers":{"A":{"rendered":110,"fresh":null,"backwards":0,"duplicates":0,"maxGapMs":217.29999999701977,"ageP50":null,"ageP95":null},"B":{"rendered":110,"fresh":null,"backwards":0,"duplicates":0,"maxGapMs":219.6000000089407,"ageP50":null,"ageP95":null}}},{"phase":"hidden","seconds":10.58879999999702,"browserCpuSeconds":7.588892000000001,"edges":{"A":{"encoded":211,"encodeMs":30.999999999999996,"decoded":212,"decodeMs":458.733,"sentFrames":211,"kbps":899.0027198551941,"output":[28,16],"receive":[1920,1080],"reason":"none","target":1755000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":19.99400179951973,"bitrateKbps":891.653341255606,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":40,"intervalFramesEncoded":null},"meanQp":2,"senderReports":25,"pli":0},"B":{"encoded":211,"encodeMs":32,"decoded":212,"decodeMs":479.394,"sentFrames":211,"kbps":899.0027198551941,"output":[28,16],"receive":[1920,1080],"reason":"none","target":1755000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":19.993002449113018,"bitrateKbps":846.7041053825228,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":40,"intervalFramesEncoded":null},"meanQp":2,"senderReports":25,"pli":0},"G0":{"encoded":212,"encodeMs":552,"decoded":0,"decodeMs":0,"sentFrames":212,"kbps":897.9087337566746,"output":[1920,1080],"receive":[null,null],"reason":"none","target":1755000,"retired":false,"meanQp":2,"senderReports":0,"pli":0}},"viewers":{"A":{"rendered":1,"fresh":null,"backwards":0,"duplicates":0,"maxGapMs":10581,"ageP50":null,"ageP95":null},"B":{"rendered":1,"fresh":null,"backwards":0,"duplicates":0,"maxGapMs":10581,"ageP50":null,"ageP95":null}}},{"phase":"visible-again","seconds":6.0205,"browserCpuSeconds":3.7289339999999953,"edges":{"A":{"encoded":121,"encodeMs":18.000000000000004,"decoded":120,"decodeMs":256.247,"sentFrames":121,"kbps":910.2964870027406,"output":[28,16],"receive":[1920,1080],"reason":"none","target":1755000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":20.008003201250688,"bitrateKbps":893.0850614631358,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":40,"intervalFramesEncoded":null},"meanQp":2,"senderReports":12,"pli":0},"B":{"encoded":121,"encodeMs":18.000000000000004,"decoded":120,"decodeMs":269.8039999999999,"sentFrames":121,"kbps":910.2964870027406,"output":[28,16],"receive":[1920,1080],"reason":"none","target":1755000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":20.008003201250688,"bitrateKbps":893.0859339310178,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":40,"intervalFramesEncoded":null},"meanQp":2,"senderReports":16,"pli":0},"G0":{"encoded":121,"encodeMs":303.99999999999994,"decoded":0,"decodeMs":0,"sentFrames":121,"kbps":909.1284777011876,"output":[1920,1080],"receive":[null,null],"reason":"none","target":1755000,"retired":false,"meanQp":2,"senderReports":0,"pli":0}},"viewers":{"A":{"rendered":121,"fresh":null,"backwards":0,"duplicates":0,"maxGapMs":78.59999999403954,"ageP50":null,"ageP95":null},"B":{"rendered":121,"fresh":null,"backwards":0,"duplicates":0,"maxGapMs":79.90000000596046,"ageP50":null,"ageP95":null}}}],"controls":[],"visibility":[{"at":9035.60000000894,"requested":"hidden","actual":"hidden"},{"at":19621.80000001192,"requested":"visible","actual":"visible"}],"shaper":null}, + {"file":"build/browser-local-pool/ordinary-h264-1080-relay-1788952213560.json","probeSha256":"392559cd0976e8ddd4be2e160606b229da64c674e7d6a284a86064c346031366","mode":"ordinary","codec":"H264","product":true,"high":true,"single":false,"late":false,"network":false,"automatic":false,"av":true,"background":false,"nativeSource":false,"lifecycle":false,"relay":true,"shapedRate":120000,"profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music"},"completed":true,"errors":[],"cleanup":{"browserExited":true,"nativeExited":true,"serverClosed":true,"portsClosed":true,"profileRemoved":true},"phases":[{"phase":"healthy","seconds":6.006800000011921,"browserCpuSeconds":5.385208,"edges":{"U":{"encoded":177,"encodeMs":694,"decoded":176,"decodeMs":58.06399999999989,"sentFrames":177,"kbps":623.7610707852041,"output":[1920,1080],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":28,"bitrateKbps":611.0324612905815,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":null,"intervalFramesEncoded":59},"meanQp":16,"senderReports":13,"pli":0},"A":{"encoded":173,"encodeMs":1091.0000000000002,"decoded":173,"decodeMs":76.626,"sentFrames":173,"kbps":567.7445561685475,"output":[1920,1080],"receive":[1920,1080],"reason":"none","target":4930000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":28,"bitrateKbps":577.3349142593227,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":null,"intervalFramesEncoded":56},"meanQp":16,"senderReports":10,"pli":0},"B":{"encoded":173,"encodeMs":1177,"decoded":174,"decodeMs":94.51399999999998,"sentFrames":173,"kbps":566.7803156411472,"output":[1920,1080],"receive":[1920,1080],"reason":"none","target":4805000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":28,"bitrateKbps":576.7416933107202,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":null,"intervalFramesEncoded":56},"meanQp":16,"senderReports":12,"pli":0}},"viewers":{"A":{"rendered":173,"fresh":173,"backwards":0,"duplicates":0,"maxGapMs":66.6000000089407,"ageP50":25.200000002980232,"ageP95":43.400000005960464,"av":{"attached":true,"videoMinusAudioMs":[-102.09999999403954,-86.09999999403954],"missed":0}},"B":{"rendered":174,"fresh":174,"backwards":0,"duplicates":0,"maxGapMs":69,"ageP50":28,"ageP95":48.5,"av":{"attached":true,"videoMinusAudioMs":[-97.90000000596046,-108.29999999701977],"missed":0}}}}],"controls":[],"visibility":[],"shaper":null,"stage":"CPU-resident carrier refinement"}, + {"file":"build/browser-local-pool/carrier-h264-1080-relay-1788952791612.json","probeSha256":"4751687f3fc8468531c4d2c24e2b7baf0f4e800a4b898e30152f0d62869bbbb6","mode":"carrier","codec":"H264","product":true,"high":true,"single":false,"late":false,"network":false,"automatic":false,"av":true,"background":false,"nativeSource":false,"lifecycle":false,"relay":true,"shapedRate":120000,"profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music"},"completed":true,"errors":[],"cleanup":{"browserExited":true,"nativeExited":true,"serverClosed":true,"portsClosed":true,"profileRemoved":true},"phases":[{"phase":"healthy","seconds":6.01179999999702,"browserCpuSeconds":5.357156,"edges":{"U":{"encoded":176,"encodeMs":714.9999999999999,"decoded":177,"decodeMs":74.86900000000001,"sentFrames":176,"kbps":619.0239196250448,"output":[1920,1080],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":30,"bitrateKbps":606.8255621283143,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":null,"intervalFramesEncoded":59},"meanQp":16,"senderReports":12,"pli":0},"A":{"encoded":172,"encodeMs":7.000000000000006,"decoded":173,"decodeMs":71.94900000000001,"sentFrames":172,"kbps":596.3471838720145,"output":[16,16],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":28.860028859986063,"bitrateKbps":601.3017806992517,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":58,"intervalFramesEncoded":null},"meanQp":12,"senderReports":13,"pli":0},"B":{"encoded":169,"encodeMs":4.0000000000000036,"decoded":169,"decodeMs":83.798,"sentFrames":169,"kbps":585.8212182710247,"output":[16,16],"receive":[1920,1080],"reason":"none","target":4753000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":28.362442155503544,"bitrateKbps":600.4609423431568,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":57,"intervalFramesEncoded":null},"meanQp":12,"senderReports":12,"pli":0},"G0":{"encoded":178,"encodeMs":1166.0000000000002,"decoded":0,"decodeMs":0,"sentFrames":178,"kbps":601.5103629531575,"output":[1920,1080],"receive":[null,null],"reason":"none","target":5000000,"retired":false,"meanQp":16.252808988764045,"senderReports":0,"pli":0}},"viewers":{"A":{"rendered":173,"fresh":173,"backwards":0,"duplicates":0,"maxGapMs":90.8999999910593,"ageP50":64.70000000298023,"ageP95":123.90000000596046,"av":{"attached":true,"videoMinusAudioMs":[-67,-41.5],"missed":0}},"B":{"rendered":169,"fresh":169,"backwards":0,"duplicates":0,"maxGapMs":124.5,"ageP50":62,"ageP95":161,"av":{"attached":true,"videoMinusAudioMs":[-50.900000005960464,28.400000005960464],"missed":0}}}}],"controls":[],"visibility":[],"shaper":null,"stage":"CPU-resident carrier refinement"}, + {"file":"build/browser-local-pool/carrier-h264-network-auto-1080-1788953201172.json","probeSha256":"c8dfdc590b1145383df35e857cc53cca69cfbbcf41bfd276389ec665714cc581","mode":"carrier","codec":"H264","product":true,"high":true,"single":false,"late":false,"network":true,"automatic":true,"av":true,"background":false,"nativeSource":false,"lifecycle":false,"relay":false,"shapedRate":400000,"profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music"},"completed":true,"errors":[],"cleanup":{"browserExited":true,"nativeExited":true,"serverClosed":true,"portsClosed":true,"profileRemoved":true},"phases":[{"phase":"healthy","seconds":6.0097999999970195,"browserCpuSeconds":4.298589000000001,"edges":{"A":{"encoded":175,"encodeMs":7.000000000000001,"decoded":176,"decodeMs":51.763000000000005,"sentFrames":175,"kbps":613.8373989154098,"output":[16,16],"receive":[1920,1080],"reason":"none","target":4947000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":29.10039636755479,"bitrateKbps":604.9309600939629,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":58,"intervalFramesEncoded":null},"meanQp":12,"senderReports":12,"pli":0},"B":{"encoded":175,"encodeMs":9.000000000000002,"decoded":175,"decodeMs":68.26100000000001,"sentFrames":175,"kbps":614.8158008589025,"output":[16,16],"receive":[1920,1080],"reason":"none","target":4954000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":28.598665395700397,"bitrateKbps":593.284838256322,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":57,"intervalFramesEncoded":null},"meanQp":12,"senderReports":11,"pli":0},"G0":{"encoded":177,"encodeMs":706.9999999999999,"decoded":0,"decodeMs":0,"sentFrames":177,"kbps":612.0842623717634,"output":[1920,1080],"receive":[null,null],"reason":"none","target":4829000,"retired":false,"meanQp":16,"senderReports":0,"pli":0}},"viewers":{"A":{"rendered":176,"fresh":176,"backwards":0,"duplicates":0,"maxGapMs":69.69999998807907,"ageP50":60.70000000298023,"ageP95":95.5,"av":{"attached":true,"videoMinusAudioMs":[-47.5,-39.8999999910593,-4.4000000059604645],"missed":0}},"B":{"rendered":175,"fresh":175,"backwards":0,"duplicates":0,"maxGapMs":70.40000000596046,"ageP50":61.30000001192093,"ageP95":132.70000000298023,"av":{"attached":true,"videoMinusAudioMs":[-41.79999999701977,-18.100000008940697,-0.20000000298023224],"missed":0}}}},{"phase":"limited","seconds":14.00640000000596,"browserCpuSeconds":12.215103,"edges":{"A":{"encoded":393,"encodeMs":20,"decoded":366,"decodeMs":146.02800000000002,"sentFrames":393,"kbps":206.24742974631386,"output":[16,16],"receive":[960,540],"reason":"none","target":246000,"retired":false,"details":{"frameWidth":960,"frameHeight":540,"framesPerSecond":29.691211401250026,"bitrateKbps":235.73576892184045,"qualityLimitationReason":"bandwidth","nativeEdgeQualityState":"degraded","intervalFramesSent":45,"intervalFramesEncoded":null},"meanQp":12,"senderReports":17,"pli":0},"B":{"encoded":408,"encodeMs":5.999999999999998,"decoded":407,"decodeMs":168.99899999999997,"sentFrames":408,"kbps":614.1917980349226,"output":[16,16],"receive":[1920,1080],"reason":"none","target":4917000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":29.341555599587338,"bitrateKbps":632.7696058372677,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":59,"intervalFramesEncoded":null},"meanQp":12,"senderReports":26,"pli":0},"G0":{"encoded":414,"encodeMs":2182,"decoded":0,"decodeMs":0,"sentFrames":414,"kbps":610.4546493029159,"output":[1920,1080],"receive":[null,null],"reason":"none","target":4024167,"retired":false,"meanQp":16.036231884057973,"senderReports":0,"pli":0},"G1":{"encoded":379,"encodeMs":1546,"decoded":0,"decodeMs":0,"sentFrames":379,"kbps":174.30374685850475,"output":[960,540],"receive":[null,null],"reason":"bandwidth","target":191667,"retired":false,"meanQp":36.66490765171504,"senderReports":0,"pli":0}},"viewers":{"A":{"rendered":364,"fresh":364,"backwards":0,"duplicates":0,"maxGapMs":443.20000000298023,"ageP50":305.90000000596046,"ageP95":375.5,"av":{"attached":true,"videoMinusAudioMs":[108.1000000089407,-66.20000000298023,-17.100000008940697,9.699999988079071,-90.90000000596046,-81.5],"missed":0}},"B":{"rendered":407,"fresh":407,"backwards":0,"duplicates":0,"maxGapMs":154.80000001192093,"ageP50":52.400000005960464,"ageP95":134.5,"av":{"attached":true,"videoMinusAudioMs":[-43.5,-65,-34.79999999701977,-32.20000000298023,-7.200000002980232,45.70000000298023],"missed":0}}}},{"phase":"released","seconds":40.01470000000298,"browserCpuSeconds":28.638084000000003,"edges":{"A":{"encoded":1159,"encodeMs":46.99999999999999,"decoded":1165,"decodeMs":424.49099999999993,"sentFrames":1159,"kbps":530.3720882575258,"output":[16,16],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":28.461742123153766,"bitrateKbps":671.2310681988289,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":43,"intervalFramesEncoded":null},"meanQp":12,"senderReports":72,"pli":0},"B":{"encoded":1158,"encodeMs":41.00000000000001,"decoded":1158,"decodeMs":453.004,"sentFrames":1158,"kbps":632.2228580996012,"output":[16,16],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":27.848227161884733,"bitrateKbps":650.0541581390266,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":56,"intervalFramesEncoded":null},"meanQp":12,"senderReports":84,"pli":0},"G0":{"encoded":1178,"encodeMs":4859.000000000001,"decoded":0,"decodeMs":0,"sentFrames":1178,"kbps":631.6948521417908,"output":[1920,1080],"receive":[null,null],"reason":"none","target":4829000,"retired":false,"meanQp":16.091680814940577,"senderReports":0,"pli":0},"G1":{"encoded":0,"encodeMs":0,"decoded":0,"decodeMs":0,"sentFrames":0,"kbps":0,"output":[960,540],"receive":[null,null],"reason":"bandwidth","target":191667,"retired":true,"meanQp":null,"senderReports":0,"pli":0}},"viewers":{"A":{"rendered":1159,"fresh":1159,"backwards":0,"duplicates":0,"maxGapMs":97.29999999701977,"ageP50":254.09999999403954,"ageP95":319.1000000089407,"av":{"attached":true,"videoMinusAudioMs":[-66.5,0,18.69999998807907,62.400000005960464,58.20000000298023,158.19999998807907,124.29999999701977,137.5,142.3999999910593,201.59999999403954,90,198.69999998807907,132.79999999701977,126.5,240.40000000596046,92.20000000298023,-61.79999999701977,-50.5,-9.900000005960464,-34.20000000298023],"missed":0}},"B":{"rendered":1156,"fresh":1156,"backwards":0,"duplicates":0,"maxGapMs":96.6000000089407,"ageP50":143.6000000089407,"ageP95":188.3999999910593,"av":{"attached":true,"videoMinusAudioMs":[86.6000000089407,75,90.20000000298023,112.5,126.09999999403954,-2.5,33.099999994039536,30.799999997019768,27.700000002980232,101.40000000596046,48.400000005960464,86.20000000298023,-9.800000011920929,-2.5,57.5,44,44.20000000298023,61.900000005960464,89.8999999910593,-0.10000000894069672],"missed":0}}}}],"controls":[],"visibility":[],"shaper":{"forwardedBytes":4074668,"droppedBytes":7626,"packets":8618,"reverseBytes":56658,"maxQueueBytes":14119,"queuedBytes":0,"rate":0},"stage":"CPU-resident carrier refinement"}, + {"file":"build/browser-local-pool/carrier-vp8-network-auto-1080-1788954788642.json","probeSha256":"83a39bf077d54b5225fc79b2c2bdefcddb86a6112a14ba152c904fe80dd2ff92","mode":"carrier","codec":"VP8","product":true,"high":true,"single":false,"late":false,"network":true,"automatic":true,"av":true,"background":false,"nativeSource":false,"lifecycle":false,"relay":false,"shapedRate":400000,"profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music"},"completed":true,"errors":[],"cleanup":{"browserExited":true,"nativeExited":true,"serverClosed":true,"portsClosed":true,"profileRemoved":true},"phases":[{"phase":"healthy","seconds":6.0145,"browserCpuSeconds":8.092805999999998,"edges":{"A":{"encoded":174,"encodeMs":9.000000000000002,"decoded":173,"decodeMs":336.65,"sentFrames":174,"kbps":1019.3991187962424,"output":[16,16],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":28.490028489986052,"bitrateKbps":982.1958464013254,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":57,"intervalFramesEncoded":null},"meanQp":2,"senderReports":19,"pli":0},"B":{"encoded":172,"encodeMs":11.000000000000002,"decoded":172,"decodeMs":344.96399999999994,"sentFrames":172,"kbps":1003.4337018871062,"output":[16,16],"receive":[1920,1080],"reason":"none","target":4828000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":28.490028489986052,"bitrateKbps":980.8746128882345,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":57,"intervalFramesEncoded":null},"meanQp":2,"senderReports":18,"pli":0},"G0":{"encoded":177,"encodeMs":464,"decoded":0,"decodeMs":0,"sentFrames":177,"kbps":1023.8643278743037,"output":[1920,1080],"receive":[null,null],"reason":"none","target":5000000,"retired":false,"meanQp":4.429378531073446,"senderReports":0,"pli":0},"G1":{"encoded":0,"encodeMs":0,"decoded":0,"decodeMs":0,"sentFrames":0,"kbps":0,"output":[1920,1080],"receive":[null,null],"reason":"none","target":3471667,"retired":true,"meanQp":null,"senderReports":0,"pli":0}},"viewers":{"A":{"rendered":174,"fresh":174,"backwards":0,"duplicates":0,"maxGapMs":98,"ageP50":68.5,"ageP95":116.80000001192093,"av":{"attached":true,"videoMinusAudioMs":[-52.29999999701977,-0.10000000894069672],"missed":0}},"B":{"rendered":172,"fresh":172,"backwards":0,"duplicates":0,"maxGapMs":97.6000000089407,"ageP50":72.8999999910593,"ageP95":149.3999999910593,"av":{"attached":true,"videoMinusAudioMs":[-18.600000008940697,37.20000000298023],"missed":0}}}},{"phase":"limited","seconds":14.02460000000894,"browserCpuSeconds":17.264088000000005,"edges":{"A":{"encoded":228,"encodeMs":13.999999999999998,"decoded":195,"decodeMs":56.506,"sentFrames":228,"kbps":219.92670022660425,"output":[16,16],"receive":[480,270],"reason":"none","target":69000,"retired":false,"details":{"frameWidth":480,"frameHeight":270,"framesPerSecond":14.54898157134258,"bitrateKbps":72.97799364787112,"qualityLimitationReason":"bandwidth","nativeEdgeQualityState":"degraded","intervalFramesSent":36,"intervalFramesEncoded":null},"meanQp":2,"senderReports":21,"pli":0},"B":{"encoded":403,"encodeMs":28,"decoded":402,"decodeMs":601.766,"sentFrames":403,"kbps":1016.5925587889075,"output":[16,16],"receive":[1920,1080],"reason":"none","target":4902000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":29.170648292554915,"bitrateKbps":1049.5096554247637,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":58,"intervalFramesEncoded":null},"meanQp":2,"senderReports":40,"pli":0},"G0":{"encoded":409,"encodeMs":1020,"decoded":0,"decodeMs":0,"sentFrames":409,"kbps":1018.210858063039,"output":[1920,1080],"receive":[null,null],"reason":"none","target":4902000,"retired":false,"meanQp":2.139364303178484,"senderReports":0,"pli":0},"G1":{"encoded":0,"encodeMs":0,"decoded":0,"decodeMs":0,"sentFrames":0,"kbps":0,"output":[1920,1080],"receive":[null,null],"reason":"none","target":3471667,"retired":true,"meanQp":null,"senderReports":0,"pli":0},"G2":{"encoded":197,"encodeMs":191,"decoded":0,"decodeMs":0,"sentFrames":197,"kbps":115.81078961246413,"output":[480,270],"receive":[null,null],"reason":"bandwidth","target":57500,"retired":false,"meanQp":39.736040609137056,"senderReports":0,"pli":0}},"viewers":{"A":{"rendered":189,"fresh":186,"backwards":0,"duplicates":0,"maxGapMs":985.9000000059605,"ageP50":219.69999998807907,"ageP95":272.30000001192093,"av":{"attached":true,"videoMinusAudioMs":[-100,-180.5,-145.5,-95.8999999910593,-131.30000001192093,-123.29999999701977],"missed":0}},"B":{"rendered":397,"fresh":397,"backwards":0,"duplicates":0,"maxGapMs":91.3999999910593,"ageP50":93.3999999910593,"ageP95":172.40000000596046,"av":{"attached":true,"videoMinusAudioMs":[-12,23.099999994039536,108.5,118.09999999403954,0,-18.599999994039536],"missed":0}}}},{"phase":"released","seconds":40.0105,"browserCpuSeconds":47.22574,"edges":{"A":{"encoded":983,"encodeMs":82,"decoded":981,"decodeMs":1384.9,"sentFrames":983,"kbps":858.6426063158422,"output":[16,16],"receive":[1920,1080],"reason":"none","target":4850000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":27.63758711838216,"bitrateKbps":978.9387177910844,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":69,"intervalFramesEncoded":null},"meanQp":2,"senderReports":104,"pli":0},"B":{"encoded":1042,"encodeMs":89.00000000000001,"decoded":1041,"decodeMs":1609.982,"sentFrames":1042,"kbps":950.3187413304008,"output":[16,16],"receive":[1920,1080],"reason":"none","target":4873000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":28.50285028507098,"bitrateKbps":979.2022500878941,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":57,"intervalFramesEncoded":null},"meanQp":2,"senderReports":117,"pli":0},"G0":{"encoded":1113,"encodeMs":2677,"decoded":0,"decodeMs":0,"sentFrames":1113,"kbps":1001.6934554679397,"output":[1920,1080],"receive":[null,null],"reason":"none","target":4060833,"retired":false,"meanQp":2.0539083557951483,"senderReports":0,"pli":0},"G1":{"encoded":0,"encodeMs":0,"decoded":0,"decodeMs":0,"sentFrames":0,"kbps":0,"output":[1920,1080],"receive":[null,null],"reason":"none","target":3471667,"retired":true,"meanQp":null,"senderReports":0,"pli":0},"G2":{"encoded":0,"encodeMs":0,"decoded":0,"decodeMs":0,"sentFrames":0,"kbps":0,"output":[480,270],"receive":[null,null],"reason":"bandwidth","target":57500,"retired":true,"meanQp":null,"senderReports":0,"pli":0}},"viewers":{"A":{"rendered":944,"fresh":944,"backwards":0,"duplicates":0,"maxGapMs":135.29999999701977,"ageP50":266.70000000298023,"ageP95":370.20000000298023,"av":{"attached":true,"videoMinusAudioMs":[-30.100000008940697,-17.100000008940697,80,117,195.40000000596046,160.40000000596046,150.5,106.59999999403954,156.5,91.1000000089407,37.400000005960464,110.90000000596046,95.20000000298023,62.79999999701977,118.70000000298023,197,116.20000000298023,63.6000000089407,118.79999999701977,112.29999999701977],"missed":0}},"B":{"rendered":978,"fresh":978,"backwards":0,"duplicates":0,"maxGapMs":116.5,"ageP50":131.5,"ageP95":212.40000000596046,"av":{"attached":true,"videoMinusAudioMs":[36,0,56.5,72.79999999701977,72.6000000089407,153.29999999701977,48.5,31.5,111.30000001192093,24.100000008940697,0,90.8999999910593,65.20000000298023,21.299999997019768,44.599999994039536,142.90000000596046,50.5,-0.10000000894069672,33.900000005960464,92.30000001192093],"missed":0}}}}],"controls":[],"visibility":[],"shaper":{"forwardedBytes":6037249,"droppedBytes":53486,"packets":9087,"reverseBytes":58835,"maxQueueBytes":14507,"queuedBytes":0,"rate":0},"stage":"CPU-resident carrier refinement"}, + {"file":"build/browser-local-pool/carrier-vp8-1080-lifecycle-1788953044969.json","probeSha256":"4751687f3fc8468531c4d2c24e2b7baf0f4e800a4b898e30152f0d62869bbbb6","mode":"carrier","codec":"VP8","product":true,"high":true,"single":false,"late":false,"network":false,"automatic":false,"av":true,"background":false,"nativeSource":false,"lifecycle":true,"relay":false,"shapedRate":120000,"profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music"},"completed":true,"errors":[],"cleanup":{"browserExited":true,"nativeExited":true,"serverClosed":true,"portsClosed":true,"profileRemoved":true},"phases":[{"phase":"healthy","seconds":6.0175,"browserCpuSeconds":7.665229999999996,"edges":{"A":{"encoded":180,"encodeMs":11.999999999999998,"decoded":181,"decodeMs":367.79499999999996,"sentFrames":180,"kbps":1297.572746157042,"output":[16,16],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":30.0510868477307,"bitrateKbps":1338.6510547735793,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":60,"intervalFramesEncoded":null},"meanQp":2,"senderReports":25,"pli":0},"B":{"encoded":181,"encodeMs":4.999999999999999,"decoded":182,"decodeMs":383.462,"sentFrames":181,"kbps":1307.381470710428,"output":[16,16],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":29.899835550860065,"bitrateKbps":1336.7271129405676,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":60,"intervalFramesEncoded":null},"meanQp":2,"senderReports":22,"pli":0},"G0":{"encoded":181,"encodeMs":448.00000000000006,"decoded":0,"decodeMs":0,"sentFrames":181,"kbps":1300.35529705027,"output":[1920,1080],"receive":[null,null],"reason":"none","target":5000000,"retired":false,"meanQp":2,"senderReports":0,"pli":0}},"viewers":{"A":{"rendered":180,"fresh":180,"backwards":0,"duplicates":0,"maxGapMs":66.40000000596046,"ageP50":60.29999999701977,"ageP95":77.20000000298023,"av":{"attached":true,"videoMinusAudioMs":[17.900000005960464,-5.100000008940697,24.299999997019768,-16.299999997019768,34.20000000298023],"missed":0}},"B":{"rendered":181,"fresh":181,"backwards":0,"duplicates":0,"maxGapMs":61.29999999701977,"ageP50":28.599999994039536,"ageP95":40.099999994039536,"av":{"attached":true,"videoMinusAudioMs":[-28.5,-21.299999997019768,-35.8999999910593,-49.1000000089407,-3.5],"missed":0}}}},{"phase":"profile-720p60","seconds":6.021699999988079,"browserCpuSeconds":8.659687000000005,"edges":{"A":{"encoded":312,"encodeMs":23,"decoded":311,"decodeMs":260.96799999999996,"sentFrames":312,"kbps":1132.6276632866968,"output":[16,16],"receive":[1280,720],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1280,"frameHeight":720,"framesPerSecond":49.796287913232135,"bitrateKbps":1049.868236597522,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":99,"intervalFramesEncoded":null},"meanQp":2,"senderReports":22,"pli":0},"B":{"encoded":309,"encodeMs":19.000000000000004,"decoded":309,"decodeMs":268.929,"sentFrames":309,"kbps":1117.3084012842419,"output":[16,16],"receive":[1280,720],"reason":"none","target":4860000,"retired":false,"details":{"frameWidth":1280,"frameHeight":720,"framesPerSecond":49.51485445618929,"bitrateKbps":1038.1129199905422,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":99,"intervalFramesEncoded":null},"meanQp":2,"senderReports":21,"pli":0},"G0":{"encoded":317,"encodeMs":733,"decoded":0,"decodeMs":0,"sentFrames":317,"kbps":1131.86508793422,"output":[1280,720],"receive":[null,null],"reason":"none","target":5000000,"retired":false,"meanQp":3.817034700315457,"senderReports":0,"pli":0}},"viewers":{"A":{"rendered":279,"fresh":279,"backwards":0,"duplicates":0,"maxGapMs":47.79999999701977,"ageP50":43.099999994039536,"ageP95":91.29999999701977,"av":{"attached":true,"videoMinusAudioMs":[-20.099999994039536,13.700000002980232,21.399999991059303,-21.899999991059303,-21.19999998807907,-21.900000005960464],"missed":0}},"B":{"rendered":282,"fresh":282,"backwards":0,"duplicates":0,"maxGapMs":49.099999994039536,"ageP50":58.400000005960464,"ageP95":97,"av":{"attached":true,"videoMinusAudioMs":[-36.69999998807907,11.100000008940697,-8.200000002980232,-35.29999999701977,31.400000005960464,-38.70000000298023],"missed":0}}}},{"phase":"quiet-profile-480p30","seconds":2.52220000000298,"browserCpuSeconds":0.9993919999999932,"edges":{"A":{"encoded":3,"encodeMs":0,"decoded":3,"decodeMs":3.88299999999997,"sentFrames":3,"kbps":15.840139560682259,"output":[16,16],"receive":[854,480],"reason":"none","target":2500000,"retired":false,"details":{"frameWidth":null,"frameHeight":null,"framesPerSecond":null,"bitrateKbps":null,"qualityLimitationReason":null,"nativeEdgeQualityState":"unknown","intervalFramesSent":null,"intervalFramesEncoded":null},"meanQp":2,"senderReports":4,"pli":0},"B":{"encoded":3,"encodeMs":0,"decoded":2,"decodeMs":3.001999999999949,"sentFrames":3,"kbps":15.840139560682259,"output":[16,16],"receive":[854,480],"reason":"none","target":2500000,"retired":false,"details":{"frameWidth":null,"frameHeight":null,"framesPerSecond":null,"bitrateKbps":null,"qualityLimitationReason":null,"nativeEdgeQualityState":"unknown","intervalFramesSent":null,"intervalFramesEncoded":null},"meanQp":2,"senderReports":6,"pli":0},"G0":{"encoded":2,"encodeMs":4.0000000000000036,"decoded":0,"decodeMs":0,"sentFrames":2,"kbps":15.824280390116899,"output":[854,480],"receive":[null,null],"reason":"none","target":2083333,"retired":false,"meanQp":23,"senderReports":0,"pli":0}},"viewers":{"A":{"rendered":3,"fresh":3,"backwards":0,"duplicates":1,"maxGapMs":2187.7000000029802,"ageP50":39.69999998807907,"ageP95":334.79999999701977,"av":{"attached":true,"videoMinusAudioMs":[],"missed":0}},"B":{"rendered":2,"fresh":2,"backwards":0,"duplicates":1,"maxGapMs":2185.3999999910593,"ageP50":337.1000000089407,"ageP95":337.1000000089407,"av":{"attached":true,"videoMinusAudioMs":[],"missed":0}}}},{"phase":"resumed-480p30","seconds":6.0156000000089405,"browserCpuSeconds":3.7564910000000076,"edges":{"A":{"encoded":179,"encodeMs":15,"decoded":179,"decodeMs":146.66100000000003,"sentFrames":179,"kbps":648.2598577023413,"output":[16,16],"receive":[854,480],"reason":"none","target":3000000,"retired":false,"details":{"frameWidth":854,"frameHeight":480,"framesPerSecond":30.003000300074717,"bitrateKbps":651.0378529192881,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":60,"intervalFramesEncoded":null},"meanQp":2.547486033519553,"senderReports":12,"pli":0},"B":{"encoded":179,"encodeMs":10.999999999999996,"decoded":180,"decodeMs":158.79100000000003,"sentFrames":179,"kbps":649.3769532539055,"output":[16,16],"receive":[854,480],"reason":"none","target":3000000,"retired":false,"details":{"frameWidth":854,"frameHeight":480,"framesPerSecond":29.995500674943454,"bitrateKbps":654.3979136362487,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":60,"intervalFramesEncoded":null},"meanQp":2.6815642458100557,"senderReports":11,"pli":0},"G0":{"encoded":180,"encodeMs":312.99999999999994,"decoded":0,"decodeMs":0,"sentFrames":180,"kbps":627.531085842541,"output":[854,480],"receive":[null,null],"reason":"none","target":3000000,"retired":false,"meanQp":2.861111111111111,"senderReports":0,"pli":0}},"viewers":{"A":{"rendered":179,"fresh":179,"backwards":0,"duplicates":0,"maxGapMs":64.70000000298023,"ageP50":21.69999998807907,"ageP95":35.29999999701977,"av":{"attached":true,"videoMinusAudioMs":[-40.599999994039536,-32.79999999701977,-27.600000008940697,-19,-25.799999997019768,-49.79999999701977],"missed":0}},"B":{"rendered":180,"fresh":180,"backwards":0,"duplicates":0,"maxGapMs":64.09999999403954,"ageP50":23.5,"ageP95":34.5,"av":{"attached":true,"videoMinusAudioMs":[-52.79999999701977,-48.599999994039536,-31.700000002980232,-24.799999997019768,-38.6000000089407,-40.6000000089407],"missed":0}}}},{"phase":"paused-profile-720p30","seconds":2.0155999999940395,"browserCpuSeconds":0.7734729999999956,"edges":{"A":{"encoded":0,"encodeMs":0,"decoded":1,"decodeMs":0.7139999999999924,"sentFrames":0,"kbps":0,"output":[16,16],"receive":[854,480],"reason":"none","target":4080833,"retired":false,"details":{"frameWidth":null,"frameHeight":null,"framesPerSecond":null,"bitrateKbps":null,"qualityLimitationReason":null,"nativeEdgeQualityState":"unknown","intervalFramesSent":null,"intervalFramesEncoded":null},"meanQp":null,"senderReports":2,"pli":0},"B":{"encoded":0,"encodeMs":0,"decoded":0,"decodeMs":0,"sentFrames":0,"kbps":0,"output":[16,16],"receive":[854,480],"reason":"none","target":4166667,"retired":false,"details":{"frameWidth":null,"frameHeight":null,"framesPerSecond":null,"bitrateKbps":null,"qualityLimitationReason":null,"nativeEdgeQualityState":"unknown","intervalFramesSent":null,"intervalFramesEncoded":null},"meanQp":null,"senderReports":3,"pli":0},"G0":{"encoded":61,"encodeMs":64.00000000000006,"decoded":0,"decodeMs":0,"sentFrames":61,"kbps":59.43639611051512,"output":[1280,720],"receive":[null,null],"reason":"none","target":4166000,"retired":false,"meanQp":9.885245901639344,"senderReports":0,"pli":0}},"viewers":{"A":{"rendered":0,"fresh":0,"backwards":0,"duplicates":0,"maxGapMs":2015.5999999940395,"ageP50":null,"ageP95":null,"av":{"attached":true,"videoMinusAudioMs":[],"missed":2}},"B":{"rendered":0,"fresh":0,"backwards":0,"duplicates":0,"maxGapMs":2015.5999999940395,"ageP50":null,"ageP95":null,"av":{"attached":true,"videoMinusAudioMs":[],"missed":2}}}},{"phase":"resumed-720p30","seconds":6.016,"browserCpuSeconds":4.668126000000001,"edges":{"A":{"encoded":179,"encodeMs":10.999999999999996,"decoded":177,"decodeMs":156.76100000000005,"sentFrames":179,"kbps":722.7353723404256,"output":[16,16],"receive":[1280,720],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1280,"frameHeight":720,"framesPerSecond":30.004500675056548,"bitrateKbps":693.313311264441,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":60,"intervalFramesEncoded":null},"meanQp":2.2458100558659218,"senderReports":13,"pli":0},"B":{"encoded":179,"encodeMs":6.999999999999999,"decoded":177,"decodeMs":164.54099999999994,"sentFrames":179,"kbps":720.6037234042553,"output":[16,16],"receive":[1280,720],"reason":"none","target":4887000,"retired":false,"details":{"frameWidth":1280,"frameHeight":720,"framesPerSecond":29.513280976527394,"bitrateKbps":693.3144966637698,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":59,"intervalFramesEncoded":null},"meanQp":2.2905027932960893,"senderReports":16,"pli":0},"G0":{"encoded":180,"encodeMs":399,"decoded":0,"decodeMs":0,"sentFrames":180,"kbps":705.6130319148937,"output":[1280,720],"receive":[null,null],"reason":"none","target":5000000,"retired":false,"meanQp":2.2555555555555555,"senderReports":0,"pli":0}},"viewers":{"A":{"rendered":178,"fresh":178,"backwards":0,"duplicates":0,"maxGapMs":77.90000000596046,"ageP50":79.5,"ageP95":103.8999999910593,"av":{"attached":true,"videoMinusAudioMs":[9.900000005960464,13.700000002980232,23.700000002980232,45.29999999701977,45.29999999701977],"missed":0}},"B":{"rendered":178,"fresh":178,"backwards":0,"duplicates":0,"maxGapMs":75.20000000298023,"ageP50":83.5,"ageP95":102.59999999403954,"av":{"attached":true,"videoMinusAudioMs":[13.799999997019768,0,19.600000008940697,11.799999997019768,40.5],"missed":0}}}},{"phase":"replaced-source","seconds":6.03220000000298,"browserCpuSeconds":5.592870999999995,"edges":{"A":{"encoded":182,"encodeMs":123.00000000000001,"decoded":181,"decodeMs":142.99700000000004,"sentFrames":182,"kbps":685.3884155031261,"output":[16,16],"receive":[1280,720],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1280,"frameHeight":720,"framesPerSecond":30.00750187546887,"bitrateKbps":678.3601410998893,"qualityLimitationReason":"none","nativeEdgeQualityState":"unknown","intervalFramesSent":60,"intervalFramesEncoded":null},"meanQp":4.483516483516484,"senderReports":12,"pli":0},"B":{"encoded":181,"encodeMs":136,"decoded":178,"decodeMs":141.05800000000013,"sentFrames":181,"kbps":685.8220881267126,"output":[16,16],"receive":[1280,720],"reason":"none","target":4883000,"retired":false,"details":{"frameWidth":1280,"frameHeight":720,"framesPerSecond":30,"bitrateKbps":694.424857818135,"qualityLimitationReason":"none","nativeEdgeQualityState":"unknown","intervalFramesSent":60,"intervalFramesEncoded":null},"meanQp":4.558011049723757,"senderReports":13,"pli":0},"G0":{"encoded":0,"encodeMs":0,"decoded":0,"decodeMs":0,"sentFrames":0,"kbps":0,"output":[1280,720],"receive":[null,null],"reason":"none","target":5000000,"retired":true,"meanQp":null,"senderReports":0,"pli":0},"G1":{"encoded":170,"encodeMs":416,"decoded":0,"decodeMs":0,"sentFrames":170,"kbps":657.4636119488811,"output":[1280,720],"receive":[null,null],"reason":"none","target":5000000,"retired":false,"meanQp":7.5,"senderReports":0,"pli":0}},"viewers":{"A":{"rendered":180,"fresh":180,"backwards":0,"duplicates":0,"maxGapMs":62.400000005960464,"ageP50":34.19999998807907,"ageP95":57.900000005960464,"av":{"attached":true,"videoMinusAudioMs":[13.400000005960464,-7.799999997019768,-14.599999994039536,-38.3999999910593,0,9.599999994039536],"missed":0}},"B":{"rendered":176,"fresh":176,"backwards":0,"duplicates":0,"maxGapMs":92.70000000298023,"ageP50":94,"ageP95":133.5,"av":{"attached":true,"videoMinusAudioMs":[0,-27.400000005960464,31.200000002980232,41.1000000089407,50.099999994039536,77.29999999701977],"missed":0}}}},{"phase":"ordinary-after-producer-failure","seconds":6.0162999999970195,"browserCpuSeconds":4.975574999999999,"edges":{"A":{"encoded":180,"encodeMs":459,"decoded":179,"decodeMs":133.23399999999987,"sentFrames":180,"kbps":676.7641241297836,"output":[1280,720],"receive":[1280,720],"reason":"none","target":4899000,"retired":false,"details":{"frameWidth":1280,"frameHeight":720,"framesPerSecond":30,"bitrateKbps":694.7647391913897,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":null,"intervalFramesEncoded":60},"meanQp":4.516666666666667,"senderReports":14,"pli":0},"B":{"encoded":180,"encodeMs":469,"decoded":179,"decodeMs":138.61900000000006,"sentFrames":180,"kbps":676.4489802706008,"output":[1280,720],"receive":[1280,720],"reason":"none","target":4710000,"retired":false,"details":{"frameWidth":1280,"frameHeight":720,"framesPerSecond":29,"bitrateKbps":695.0844495636925,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":null,"intervalFramesEncoded":60},"meanQp":4.561111111111111,"senderReports":11,"pli":0},"G0":{"encoded":0,"encodeMs":0,"decoded":0,"decodeMs":0,"sentFrames":0,"kbps":0,"output":[1280,720],"receive":[null,null],"reason":"none","target":5000000,"retired":true,"meanQp":null,"senderReports":0,"pli":0},"G1":{"encoded":0,"encodeMs":0,"decoded":0,"decodeMs":0,"sentFrames":0,"kbps":0,"output":[1280,720],"receive":[null,null],"reason":"none","target":5000000,"retired":true,"meanQp":null,"senderReports":0,"pli":0}},"viewers":{"A":{"rendered":179,"fresh":179,"backwards":0,"duplicates":0,"maxGapMs":65.8999999910593,"ageP50":63.19999998807907,"ageP95":74.09999999403954,"av":{"attached":true,"videoMinusAudioMs":[0,0,-2.9000000059604645,31.299999997019768,26.69999998807907,16.700000002980232],"missed":0}},"B":{"rendered":179,"fresh":179,"backwards":0,"duplicates":0,"maxGapMs":69.5,"ageP50":62.69999998807907,"ageP95":74.29999999701977,"av":{"attached":true,"videoMinusAudioMs":[-13.299999997019768,-2.8999999910593033,-0.10000000894069672,26.200000002980232,10.399999991059303,0],"missed":0}}}}],"controls":[{"phase":"profile-720p60","profile":{"resolution":"720p","maxFramerate":60,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music"},"elapsedMs":1.9000000059604645,"applied":[true,true]},{"phase":"quiet-profile-480p30","profile":{"resolution":"480p","maxFramerate":30,"maxBitrate":3000000,"degradationPreference":"balanced","screenAudioQuality":"music"},"elapsedMs":2.5999999940395355,"applied":[true,true]},{"phase":"paused-profile-720p30","profile":{"resolution":"720p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"balanced","screenAudioQuality":"music"},"elapsedMs":1.8999999910593033,"applied":[true,true]},{"action":"source-replaced","replaced":[true,true]}],"visibility":[],"shaper":null,"stage":"CPU-resident carrier refinement"}, + {"file":"build/browser-local-pool/carrier-vp8-1080-background-1788953713463.json","probeSha256":"527a85a05de511c49a102cd82c92ffb46bf0d2c8fcb23f44bc0086bb44300e94","mode":"carrier","codec":"VP8","product":true,"high":true,"single":false,"late":false,"network":false,"automatic":false,"av":false,"background":true,"nativeSource":true,"lifecycle":false,"relay":false,"shapedRate":120000,"profile":{"resolution":"1080p","maxFramerate":30,"maxBitrate":5000000,"degradationPreference":"maintain-resolution","screenAudioQuality":"music"},"completed":true,"errors":[],"cleanup":{"browserExited":true,"nativeExited":true,"serverClosed":true,"portsClosed":true,"profileRemoved":true},"phases":[{"phase":"healthy","seconds":6.00709999999404,"browserCpuSeconds":3.540231000000002,"edges":{"A":{"encoded":121,"encodeMs":8,"decoded":122,"decodeMs":243.46899999999997,"sentFrames":121,"kbps":893.2736262098723,"output":[16,16],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":19.99500124968758,"bitrateKbps":883.3653051364103,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":40,"intervalFramesEncoded":null},"meanQp":2,"senderReports":12,"pli":0},"B":{"encoded":121,"encodeMs":5,"decoded":122,"decodeMs":236.682,"sentFrames":121,"kbps":887.882672172145,"output":[16,16],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":19.99500124968758,"bitrateKbps":883.3648739107114,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":40,"intervalFramesEncoded":null},"meanQp":2,"senderReports":15,"pli":0},"G0":{"encoded":120,"encodeMs":303,"decoded":0,"decodeMs":0,"sentFrames":120,"kbps":886.813270963574,"output":[1920,1080],"receive":[null,null],"reason":"none","target":5000000,"retired":false,"meanQp":2.4916666666666667,"senderReports":0,"pli":0}},"viewers":{"A":{"rendered":122,"fresh":null,"backwards":0,"duplicates":0,"maxGapMs":70.09999999403954,"ageP50":null,"ageP95":null},"B":{"rendered":122,"fresh":null,"backwards":0,"duplicates":0,"maxGapMs":70,"ageP50":null,"ageP95":null}}},{"phase":"hidden","seconds":10.10770000000298,"browserCpuSeconds":7.762432999999994,"edges":{"A":{"encoded":201,"encodeMs":11.000000000000002,"decoded":201,"decodeMs":419.235,"sentFrames":201,"kbps":892.0795037444069,"output":[16,16],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":19.997000449962304,"bitrateKbps":862.4236066496638,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":40,"intervalFramesEncoded":null},"meanQp":2,"senderReports":26,"pli":0},"B":{"encoded":201,"encodeMs":13.000000000000002,"decoded":201,"decodeMs":436.756,"sentFrames":201,"kbps":892.0795037444069,"output":[16,16],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":20.00200020004981,"bitrateKbps":850.7787364690024,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":40,"intervalFramesEncoded":null},"meanQp":2,"senderReports":25,"pli":0},"G0":{"encoded":201,"encodeMs":520,"decoded":0,"decodeMs":0,"sentFrames":201,"kbps":890.9959733665764,"output":[1920,1080],"receive":[null,null],"reason":"none","target":5000000,"retired":false,"meanQp":2,"senderReports":0,"pli":0}},"viewers":{"A":{"rendered":0,"fresh":null,"backwards":0,"duplicates":0,"maxGapMs":10107.70000000298,"ageP50":null,"ageP95":null},"B":{"rendered":0,"fresh":null,"backwards":0,"duplicates":0,"maxGapMs":10107.70000000298,"ageP50":null,"ageP95":null}}},{"phase":"visible-again","seconds":6.01829999999702,"browserCpuSeconds":3.808156000000004,"edges":{"A":{"encoded":121,"encodeMs":4.999999999999997,"decoded":121,"decodeMs":248.294,"sentFrames":121,"kbps":910.1852682655754,"output":[16,16],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":19.99500124968758,"bitrateKbps":904.5511266691717,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":40,"intervalFramesEncoded":null},"meanQp":2,"senderReports":17,"pli":0},"B":{"encoded":121,"encodeMs":6.999999999999999,"decoded":121,"decodeMs":263.157,"sentFrames":121,"kbps":910.1852682655754,"output":[16,16],"receive":[1920,1080],"reason":"none","target":5000000,"retired":false,"details":{"frameWidth":1920,"frameHeight":1080,"framesPerSecond":19.99500124968758,"bitrateKbps":904.5506851077109,"qualityLimitationReason":"none","nativeEdgeQualityState":"healthy","intervalFramesSent":40,"intervalFramesEncoded":null},"meanQp":2,"senderReports":15,"pli":0},"G0":{"encoded":121,"encodeMs":296.00000000000006,"decoded":0,"decodeMs":0,"sentFrames":121,"kbps":909.0367711816807,"output":[1920,1080],"receive":[null,null],"reason":"none","target":5000000,"retired":false,"meanQp":2,"senderReports":0,"pli":0}},"viewers":{"A":{"rendered":121,"fresh":null,"backwards":0,"duplicates":0,"maxGapMs":70.29999999701977,"ageP50":null,"ageP95":null},"B":{"rendered":121,"fresh":null,"backwards":0,"duplicates":0,"maxGapMs":69.3999999910593,"ageP50":null,"ageP95":null}}}],"controls":[],"visibility":[{"at":9196.40000000596,"requested":"hidden","actual":"hidden"},{"at":19297.5,"requested":"visible","actual":"visible"}],"shaper":null,"stage":"CPU-resident carrier refinement"} + ] +} diff --git a/docs/research/diagnostic-feedback.md b/docs/research/diagnostic-feedback.md new file mode 100644 index 0000000..bbecf34 --- /dev/null +++ b/docs/research/diagnostic-feedback.md @@ -0,0 +1,53 @@ +# Diagnostic Feedback + +Reviewed 2026-09-09. [Configuration](../reference/configuration.md#diagnostics) +owns current activation, contents, bounds and disclosure. This note records the +references and integration decisions; it is not another logging API contract. + +## Mature References + +| Reference | Verified practice | Applied here | +| --- | --- | --- | +| [OBS application logging](https://github.com/obsproject/obs-studio/blob/012c6c23c73283ee5591ae00d08af14d9eeb8279/frontend/obs-main.cpp) | Session logs and separate crash/profiler reports retain diagnostic context; repetition and retention are managed. | Preserve selected startup facts, bounded history and related report files. OBS implementation is study-only; no GPL code is copied. | +| [Chromium WebRTC internals](https://chromium.googlesource.com/chromium/src/+/refs/tags/152.0.7977.82/content/browser/webrtc/webrtc_internals.cc) and [dump export](https://chromium.googlesource.com/chromium/src/+/refs/tags/152.0.7977.82/content/browser/webrtc/resources/dump_creator.js) | Connection/capture API events and RTCStats are kept together with timestamps and Browser context. Raw dumps can contain sensitive URLs/configuration. | Observe existing connection owners and stats samples; include requested versus applied settings and errors. Do not copy raw dumps as a redaction policy. | +| [Tailscale bug reports](https://tailscale.com/docs/account/bug-report) | A timestamped marker associates the report with health/network context; record mode can bracket reproduction. | Put a marker and current runtime metadata into a local bundle. No cloud collector, device account or support backend is needed. | + +## Smallest Coherent Integration + +The existing Go recorder, rotating file, ZIP writer, runtime memory statistics +and pprof snapshots remain. Existing Browser diagnostic recording and Native +request IDs remain the owning surfaces. Complete the missing evidence at those +boundaries instead of constructing a second application event bus. + +Native async request completion is recorded by the existing completion owner. +Capture subprocess stderr is a bounded line stream alongside its existing media +pipe, with no media/wire format change. Windows reads its existing VSE observer +on input processing cadence; no diagnostic callback chooses bitrate or routes. +Browser state listeners attach at current PeerConnection creation sites and +consume existing stats calls; no global prototype replacement or new poller is +introduced. Export-time platform metadata has a bounded wait and can fail +without deleting the event history. + +Pion's existing logger factory and LiveKit's LogR adapter feed the same slog +handler. Existing Zap object/array marshalers retain RTP/BWE fields that plain +JSON would otherwise silently encode as empty objects. Critical credential +filtering precedes persistence. Pinned Pion formats include named ICE secrets, +space-separated ufrag extensions, hexadecimal username comparisons and an opaque +remote-track dump; those forms need explicit handling, not just URL filtering. +Other technical identities and network/system details are disclosed as residual +information rather than promised to be fully anonymous. + +Optional profile failures produce a partial report with a file/error manifest. +The ZIP still fails if its destination cannot be written. Whole-process memory, +screen/audio recording, remote collection and a crash-uploader service add cost +and disclosure beyond the diagnostic result; none is required by this design. + +## Verification Boundary + +Focused checks exercise split stderr chunks, actual Pion secret formats, +structured media statistics, log rotation/reopen and partial export. Existing +Native/Browser owner checks retain asynchronous setting and media behavior. +Physical acceptance must inspect an exported bundle from the running code, not +only assert that instrumentation methods were called. A report is not guaranteed +to reconstruct events that occurred before Debug was enabled or outside its +explicit retained history. diff --git a/docs/status.md b/docs/status.md index dc56d4b..2fa2a64 100644 --- a/docs/status.md +++ b/docs/status.md @@ -2,157 +2,84 @@ Last updated: 2026-09-09 -This is the current execution index. Git history owns completed timelines; -[verification status](./verification-status.md) owns evidence boundaries. +This is the compact execution/deployment index. Product modules own behavior, +[verification status](./verification-status.md) owns unresolved physical limits, +and Git/PRs own completed history. -## Recorded Pre-Cutover Production Check +## Production -Read-only verification on 2026-09-08 confirmed the pre-cutover production state -below. The runtime release descriptor and deployment record own later cutover -identity; this historical check is not a live service-status endpoint. +The coordinated embedded-media cutover is complete. The immutable release +descriptor, runtime REVISION and operator deployment record own exact identity. -- The running Go application and public Browser asset at - `https://share.bonfire.icu` retain the strict `screener-v21` contract. - Current revision, release, artifact, - manifest, and asset identity are - retained by the immutable release descriptor, runtime `REVISION`, and - deployment record rather than copied into this source snapshot. -- The latest scoped check found application health and the immutable Browser - asset available, with Screener, LiveKit, coturn, and nginx active and without - restarts. -- Production enables SQLite room authority at - `/var/lib/screener/rooms.sqlite`; live participants, routes and media remain - process-only. A controlled restart retained the room authority and Host-owned - capture while the Viewer rebuilt media without a page refresh. Lightweight - mode remains available when `ROOM_DATABASE_PATH` is unset and reacquires a - room rather than persisting the old authority. -- Site access uses a stateless 24-hour rolling idle cookie. An active - site-authorized page renews it hourly through the existing status request; - Viewer-grant admission remains independent and cannot create or renew it. -- Public media listeners remain STUN-only UDP 3478 and LiveKit UDP 7882. - Production enables optional NAT prediction with self-hosted STUN-only UDP - 3479 and 3480; ordinary `STUN_URLS` and media routes remain unchanged. Web - ingress is TCP 80/443; Screener 8787 and LiveKit control/signaling 7880 are - private. TURN, ICE/TCP, media TCP, TLS relay, port 5349, and relay ranges are - disabled. -- Browser video uses the content-independent H.264 sender gate with VP8 - fallback, plus the locked pre-share `VP8 | Auto | H264` Host selector and - resolved codec display. Video retains `contentHint = "motion"`; audio uses - `music`. The SFU publisher uses the Host's one codec decision, leaves - representation construction to pinned LiveKit, - enables Dynacast, uses server send-side BWE, keeps AdaptiveStream disabled, - and configures no external ICE servers on Browser SFU PCs. -- Screen audio provides live 64/128/192 kbps ceilings with 128 default. SFU - publication uses stereo, DTX off, and RED off. Early autoplay presentation is - gated by current media connection state. -- The living-room presentation is deployed with Chinese, English, and - pure-visual modes, light/dark themes, one Host/Viewer stage language, - a Host-first identity roster, progressive per-Viewer diagnostics, - container-responsive topology, and reduced-motion behavior. Fresh Browsers - start in visual mode; explicit choices persist locally. -- Native-edge local convergence defaults on for each new share and remains a - pre-share Host opt-out. The adjacent default-off peer-only policy excludes all - SFU paths for that share generation. Both policies lock while sharing. +- One Go process serves the Web application, room authority/signaling, + Binding-only STUN on UDP 3478/3479/3480 and optional SFU on UDP 7882. + External LiveKit/coturn services are stopped and disabled. nginx retains HTTPS. +- Browser/server v22, Native control v9 and capture v7 form the current private + contract. Old pages and Clients must use matching artifacts. +- SQLite persists stable room authority; participants, routes and media remain + process-only. Lightweight mode remains available without a database path. +- Public health/assets and all three STUN Binding endpoints pass postflight. + Synthetic H264/VP8 media crosses the public SFU, retains decoded Opus through + 720p-to-480p changes, and retires cleanly. Public signaling also passes a direct + Peer check; both direct endpoints were on the acceptance machine. +- Initial SFU startup exposed the systemd address-family restriction on Linux + interface discovery. The old release/unit/environment/proxy/media-service + state was restored successfully, then the corrected unit passed media + postflight. AF_NETLINK is needed by Go/Pion interface enumeration; no new + administrative capability was granted. The template correction is carried + in the current Debug supplement. +- This private deployment is not a public demonstration service. -## Current Source +## Current Supplement -- The candidate uses one shared Go core and the strict `screener-v22` - Browser/server contract, Native control v9 and capture v7. These artifacts - form one private contract. Optional SQLite persists stable room authority; - participants, routes and media remain process-only. -- Hosted Screener owns configured Binding-only STUN and optional SFU UDP - listeners in-process. SFU SDP/ICE and direct-subscriber demand use authenticated - room signaling. There is no external SFU room service, Browser LiveKit SDK, - media token or second signaling connection. Startup and shutdown own all - configured listeners; Local and public-link Clients remain P2P-only. -- One controller still owns the committed graph and one serial child operation. - Endpoint copy limits, first-decoded-frame commits, bounded direct acquisition - and candidate-relative quality proof remain in - [routing and transport](./product/routing-transport.md). Exact physical - subscription/publication closure now releases the matching SFU reservation. -- Native parents and embedded SFU share a Pion/LiveKit media adapter for - forwarding, bandwidth estimation, allocation, pacing and recovery. A Native - parent reuses a suitable encoded output and derives a missing lower output - only on direct-child demand. Source groups retain the highest needed output - and lower fallbacks; each child receives one selected output. Native Host SFU - publication reuses the source directly and has one aggregate upstream budget. - [Media quality](./product/media-quality.md) owns this behavior and its limits. -- Browser senders retain source-owned clones and stock WebRTC adaptation. - Current SFU demand updates the publisher's active output prefix; live quality - changes and ICE recovery retain their connection identities. Healthy media - survives transient room-signaling loss, and transport teardown cannot stop - the Host's original capture track. -- Host tab authority is separate from the persistent origin resume hint. The - Client admits two independent native control sessions; reconnect, room - replacement and source retirement remain fenced by their current owners. - Public-link startup reserves its local listener before starting a tunnel. -- Viewer presentation retains current-media identity and decoded-frame proof - across lifecycle changes. Diagnostic capture, signaling and native-request - events are opt-in and bounded; [configuration](./reference/configuration.md) - owns activation and export. Diagnostics do not add media or routing authority. -- The runtime-only container recipe reuses the Server release rather than - rebuilding the application. Non-root/read-only execution, memory/SQLite - restart behavior, diagnostic export, STUN and UDP lifecycle pass in the local - Linux VM. Public-network media and production cutover remain separate. +Browser node-local pooling, the detailed Debug supplement and scoped Native +ablation passed the combined checks and matching Client/Server packaging. The +owner authorized complete integration and deployment on 2026-09-09, with later +hands-on feedback. Runtime release metadata owns the actual cutover identity. -## Source/Production Relationship +The supplement retains the existing local recorder/export owners and adds +correlated requests, meaningful error causes, media/dependency observations, +selected runtime context and explicit partial-report/history information. +[Configuration](./reference/configuration.md#diagnostics) owns exact activation, +report contents and disclosure. The Linux service-template correction belongs +to this same supplement. -The source change requires a coordinated production cutover. The recorded check -above retains the external-service history; exact release identity belongs to -the deployment record. The -[self-hosting transaction](./operations/self-hosting.md#coordinated-embedded-media-cutover) -replaces those services and their configuration together. The routine application -wrapper requires an existing embedded Go deployment and cannot perform or roll -back the initial infrastructure change. +English/Chinese README, Quick Start, documentation map and license guidance are +prepared. The piik naming/visual discussion, static GitHub Pages website and +whole-repository ablation review follow closeout. No public site or bulk rename +is part of this release. [TODO](./todo.md) is the only work ledger. -## Active Boundaries +## Media Evidence And Limits -- Native shared-output implementation follows the - [encoder-pool review](./research/webrtc-encoder-pool.md); remaining release - acceptance is held in TODO. Windows capture and derivation now attach stock - WebRTC output pipelines; both codecs pass bounded process-level shrinking and - recovery checks. Incompatible weak-consumer grouping now has real relay - split/rejoin and independent-dimension delivery evidence; exact throughput, - representative-network behavior and extreme-overload limits remain explicit. - The extreme single-core check reaches WebRTC's sample-reset boundary; it does - not justify another application resource controller. - Independent Client/service fixes remain preserved. -- Browser and Native H264/VP8 publication through embedded SFU pass Browser decoding, - live-profile and cleanup checks; the Native path also delivers Opus. Bounded - Native VP8 forwarding/derivation and stopped-upper recovery have synthetic - codec and shaped-network evidence. Hardware - overload, below-lowest-output, public-network and packaged acceptance remain open. -- Current Windows VP8 and H264 owned-window minimize/profile/restore checks pass, - including live and paused changes and the H264 source-switch/restart path. - The owner confirmed Windows 10 display sharing resolved. Distinct game - HWND/device-loss behavior, 60 fps endurance and hardware overload remain - separate evidence boundaries. The reported - machine freeze has no confirmed cause; [TODO](./todo.md) owns the current - authorization and serialization of physical workloads. -- Linux capture producers compile. Updated macOS producers still require an - SDK build; neither platform's current capture/audio/recovery has physical - acceptance. Prior package and single-output results do not prove the new - multi-output contract. The owner defers these secondary-platform matrices; - Windows Client and Browser remain this phase's acceptance targets. Two Native - control sessions and rooms now pass concurrent media and independent retirement. -- Matched platform artifacts, - representative packet loss/congestion, 20-Viewer endurance, public-link - Browser delivery and restricted-NAT success remain in the - [TODO ledger](./todo.md) and [verification status](./verification-status.md). - Browser/OS suspension remains a physical limit, not a keepalive promise. -- Windows Native Host, Native embedded-SFU and Browser-to-Client fanout pass the - current combined profile, pause/recovery and closure checks. Pure Browser - reuse was then tested separately: late-join recovery works, but legacy fanout - fails independent quality and sender accounting; standard transforms reject - cross-source frames. The experiment adds no product path. Normal Browser - senders and optional Client fanout remain the supported composition. - -## Release Acceptance - -The phase requires matching current Web/Server/Client/capture artifacts and the -remaining media acceptance before one owner-accepted integration. The first -production move also needs an active-session check, accepted share interruption, -released old UDP listeners, and verified unit/environment/proxy/service recovery. -Restoring only an application symlink cannot roll back that protocol and -infrastructure transaction. No deployment or current-platform acceptance is -claimed by these source documents. +- Compatible Native consumers share one complete stock WebRTC encoding and + adaptation pipeline per local output. Different weak demands stay independent; + suitable received media can be forwarded without decoding/re-encoding. + Pion/LiveKit retain transport, estimation, pacing and forwarding ownership. +- Windows H264/VP8 have bounded encoding/adaptation evidence. Actual Native + relay checks cover compatible sharing, weak-budget separation and rejoining, + source retirement and large encoded-frame delivery. These results do not + establish a universal encoder count, CPU saving or Browser-equivalent quality. +- Windows Host acceptance covers live and paused settings, quiet-source + preparation/restore, source changes and subsequent sharing. The owner + confirmed Windows 10 whole-display capture resolved. Specific new game/device + failures should be reopened from new evidence. +- Native Host publication through embedded SFU retains video/audio; Browser to + Client fanout and Client-exit fallback have bounded physical checks. Two + independent Native sessions have simultaneous media/retirement evidence. +- The Linux runtime container has non-root/read-only, memory/SQLite, diagnostic + export, STUN and listener-lifecycle evidence. This does not establish every + operator's proxy/firewall or public-media configuration. +- The independent Browser producer in ADR-0014 is integrated on the candidate. + Bounded H264/VP8 checks cover direct and received-track sharing, weak-child + isolation/recovery, live/quiet settings, pause, source change and ordinary + fallback. Its native allocation and complete-frame output replace the earlier + payload-only Worker path. Fewer full encodes are established; total CPU gains + depend on codec/hardware. [Browser research](./research/browser-local-encoding-pool.md) + owns these measurements and failed controls. The owner accepted this scoped + Browser composition for release; actual deployment identity stays in the + runtime descriptor. +- Slow adaptation recovery, the extreme CPU-overload sampling limit and missing + broad device/network/endurance results remain explicit in + [encoder research](./research/webrtc-encoder-pool.md) and verification status. + macOS/Linux physical capture is deferred by the owner; Windows Client and + Browser are this phase's primary targets. diff --git a/docs/todo.md b/docs/todo.md index 4efe0d5..5bd4fb4 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -7,115 +7,84 @@ and Git/PRs own completed history. A parked idea is not implementation authority ## Now -Owner-authorized phase: [embedded STUN/SFU and node-local media](./adr/0013-embedded-node-local-media.md). -Finish this phase as one integration, preserving the completed mainline checkpoint. +The owner authorized full closeout of Browser encoding pooling, detailed Debug, +Native ablation and release-entry documentation on 2026-09-09. Implementation, +bounded media checks and matching Client/Server package checks are complete. +Finish one squash integration, application-only deployment, scoped postflight +and cleanup; the owner's later hands-on acceptance does not block this closeout. +Runtime descriptors and deployment output own exact released identity. -1. **Release integration.** One complete PR must contain the Native/embedded - media work, retained Client fixes, final corrections, current documentation - and research outcomes. Build matching Web/Server/Client/capture artifacts - from the accepted revision; main remains a low-frequency integration branch. -2. **Coordinated production cutover.** Recheck active sessions, preserve the - exact old release/unit/environment/proxy/media-service state and SQLite - recovery material, then replace external LiveKit/coturn with the embedded - process. Verify HTTP/assets, STUN, SFU media, listener retirement and rollback - using [self-hosting operations](./operations/self-hosting.md). - The routine application updater alone cannot perform this first cutover. -3. **Matched delivery.** Provide the current Windows Client package and Server - descriptor/archive with matching hashes and notices. Production pages and - old Clients cannot be mixed across the private protocol change. Formal - public GitHub Release/image publication remains a separate explicit action. +Immediately afterward, enter the **piik discussion**. The owner owns **piik.tv**. +Agree on casing, TV mascot/wordmark treatment, repository and runtime identifiers, +release names and the GitHub Pages website before implementing the rename. +Keep one current internal contract and remove replaced names in the coordinated +change; no private compatibility aliases. Existing sketches are input, not an +accepted identity. Do not configure DNS/Pages or publish the brand before that +design discussion. The private production service is not a public demo. -Current evidence and limits: +Include the owner-requested frontend/backend ablation review in that phase. +Review duplicated behavior/state, module boundaries, interface clarity, lifecycle +ownership, cohesion and coupling. Reuse mature modules and stable shared behavior. +The primary goal is lower total complexity, not perfect performance: small gains +do not justify permanent mechanisms or larger maintenance cost. Preserve viewing +quality and product behavior; fewer lines alone do not justify a rewrite. -- Windows/Browser are the primary targets. Native Host, Native embedded SFU and - Browser-to-Client fanout pass profile, audio, pause/recovery and cleanup checks. - Local container checks cover non-root/read-only operation, memory/SQLite, - diagnostics, STUN and media listener lifecycle; they do not prove target-network - media. macOS/Linux physical matrices may follow user feedback. -- Compatible Native consumers share one complete encoding/adaptation pipeline; - incompatible direct-child demands remain independent. Reuse suitable received - encodings and derive missing outputs locally. No ancestor-cache discovery, - extra upstream layer subscription, custom BWE or resource controller is accepted. -- Extended weak-network recovery passes. Preserve the earlier missed twelve-second - observation and extreme single-core sampling-reset limitation in - [encoder research](./research/webrtc-encoder-pool.md); neither establishes a - universal recovery deadline or a missing callback. -- The owner closed Win10 whole-display sharing. Quiet-source settings pass real - WGC/Browser acceptance. Reopen distinct game/display failures only from new - evidence, not a failed automation focus precondition. -- [Browser encoded reuse](./research/advanced-peer-distribution.md#september-9-results-and-decision) - completed after the mainline checkpoint. Late join can recover with one keyframe - request, but legacy fanout fails independent quality and sender accounting. - Standard Transform rejects cross-source frames. No product adapter is accepted. - Keep normal Browser senders and the verified optional Client fanout. -- Run one bounded capture/codec workload at a time, with cleanup before the next. - Keep VM workloads out of that interval and use stable executable build paths. +English/Chinese Quick Start, documentation map and license guidance are prepared. +Review final release assets, update/replacement guidance and public distribution +alongside the new brand; existing packagers and Docker recipe are the baseline. +Keep expensive packaging manual and branch CI quiet. No broad media redesign is +part of naming or documentation work. + +[Verification status](./verification-status.md) owns broader physical limits. +NAT and Auto are complete features; further statistical/hardware research is not +required to finish this phase. Run any new physical workload serially, with stable +executable paths and cleanup before the next. ## Parked Product Work -1. **Comprehensive Debug feedback.** Authorized as follow-up on 2026-09-09 for - Client and Server. Use mature projects' feedback-bundle practice. A first - report should contain enough context to diagnose without another build just - to add missing logging: operation start/end and identity, requested/applied - settings, capture/codec/ICE/transport transitions, resource and queue pressure, - and precise failed checks with safe expected/actual values. - Debug-on completeness takes priority over minimal log volume; keep explicit - resource bounds without silently removing essential context. Exclude - credentials, access/media tokens and private keys. Do not promise complete - anonymization; document residual identifiers, network/system information and - export contents clearly. Keep local opt-in collection and user-controlled - sharing. Design one observability surface, not incident-specific patches. - Assess stack and memory snapshots by diagnostic value and document their - contents; do not silently include raw media or secret-bearing memory. - Existing exports are owned by [configuration](./reference/configuration.md). -2. **Public distribution and updates.** Candidate packagers, immutable descriptors, +1. **Public distribution and updates.** Candidate packagers, immutable descriptors, notices, the runtime-only OCI recipe and release checks exist. Choose the public asset set and release notes before publishing a full-SHA GitHub Release or image. Do not add automatic installation, container self-update, Watchtower, compatibility ranges or active-share interruption without distribution and recovery evidence. Ordinary branch pushes must not run expensive CI packaging. -3. **Representative device/network acceptance.** Exercise public-network direct +2. **Representative device/network acceptance.** Exercise public-network direct and relay paths, two-room SFU, real-game A/V, twenty-Viewer endurance, sustained loss/recovery and all-UDP-blocked bounded failure. Correlate freezes with publisher/receiver evidence before changing representation policy. Physical Android/iOS work includes autoplay, background/lock, rotation, network migration and relay survival. Secondary desktop platform matrices remain explicitly deferred; compilation does not prove capture. -4. **Browser encoded-input API.** Reopen reuse only when a supported API supplies - usable injection, feedback and timing ownership. Chromium's experimental - RTCEncodedSource is a lead, not current M152 availability or automatic - adaptation. An adapter must pass independent quality, recovery and truthful - statistics before product integration. -5. **Broader quality work.** Reopen from measured benefit at acceptable complexity. +3. **Broader quality work.** Reopen from measured benefit at acceptable complexity. Preserve chosen profiles, bitrate ceilings, endpoint capacity and P2P-first routing unless a new accepted decision supports changing them. No weighted score, all-pairs probes, periodic rebalancing, parent-wide prediction or room-wide minimum. Check whether a quality move merely shifts pressure to another parent's siblings before widening policy. -6. **Startup codec budget.** Compare bounded Auto and first-frame behavior on - representative hardware. Throughput qualification does not guarantee perceptual - quality or later GPU load. Do not silently weaken Auto or introduce - comparative/dual probing without evidence. -7. **NAT field evidence.** Preserve [ADR-0009](./adr/0009-optional-nat-prediction.md). - Validate selected predicted/mapped paths and bounded resource cost, including - a Native case rescued beyond ordinary STUN. Measure the three-attempt budget - before expanding it; new connections do not guarantee independent mappings. - Candidates remain connection-local and diagnostic, not participant labels, - hard skips, route scores or SFU preference. -8. **Control/resource fairness and input review.** Reassess the authenticated +4. **Control/resource fairness and input review.** Reassess the authenticated WebSocket/SFU owner, HTTP/body/resource bounds, authorization and error/log handling before adding a queue or limiter. No parallel security framework, accounts, risk score or speculative policy layer. -9. **Reachable ownership/refactor work.** Extract page media-session owners only +5. **Reachable ownership/refactor work.** Extract page media-session owners only alongside behavior changes; file size alone is not a rewrite reason. Reopen C=3 structural-intent retention, SFU failure during unrelated prepare and multi-child evidence ownership only with current-contract reproductions. No new revision namespace, failure-state mirror or topology queue by default. -10. **Storage fault recovery.** Ordinary transactions persist before memory +6. **Storage fault recovery.** Ordinary transactions persist before memory changes; failures must preserve authority. Message-handler panics must unwind locks. Damaged-disk/COMMIT/ROLLBACK recovery is not established; choose its policy explicitly rather than adding catch-and-continue or retries. -11. **Platform output.** System/tab mirroring needs no adapter. Remote Playback, +7. **Platform output.** System/tab mirroring needs no adapter. Remote Playback, Cast and AirPlay do not provide a portable live MediaStream receiver. Reopen for a registered receiver acting as an ordinary Viewer after the [platform-output gate](./research/platform-output.md) passes. +8. **Public presentation and piik rename.** The owner chose the future product + name piik; final casing and its integration with the TV mascot remain a design + review. Keep the two i letters readable while exploring eyes/antenna/feet. + Prepare short, friendly English/Chinese README and Quick Start content; + detailed implementation and deployment reference belong in developer docs. + Draft a static GitHub Pages homepage with tutorials and self-contained visual + demonstrations matching the living-room UI. The private production service + is not a public demo and must not be advertised as one. Inventory names, + package/module/config identifiers and assets before one coordinated rename; + do not perform it during the Debug supplement or preserve old private aliases. diff --git a/docs/verification-status.md b/docs/verification-status.md index 98691f2..f4d34b5 100644 --- a/docs/verification-status.md +++ b/docs/verification-status.md @@ -1,193 +1,99 @@ # Verification Status -Last updated: 2026-09-05 +Last updated: 2026-09-09 -This file owns only cross-module physical evidence that still changes how the -current product may be described. [Status](./status.md) owns exact source and -production identity; product modules and ADRs own accepted behavior; tests and -Git own completed implementation evidence. +This file owns physical evidence limits that change how the product may be +described. [Status](./status.md) owns the execution/deployment index. +Product modules own behavior; research owns measurements; Git owns completed +implementation and obsolete package history. -## Route And Network +## Current Evidence -Still required on representative public networks: +Windows Client and Browser are the owner's primary acceptance targets. Current +Go packages embed their Web assets and do not run a Node backend. The current +contract is Browser/server v22, Native control v9 and capture v7; pre-port +Client/Node package checks do not prove this package. -- direct Host-to-Viewer P2P; -- Peer-fed and SFU-fed Browser relay, including a second-level child; -- Host and Viewer TUN/VPN cases; -- relay-ingress failure with subtree retention; -- disconnect and effective-capacity drain; -- two simultaneous rooms with independent SFU publications; -- measured endpoint upload and server ingress/egress during steady routing and - make-before-break overlap; -- 20-Viewer queue tail, route depth, resource use, and endurance; and -- all-UDP-blocked bounded failure. +Bounded current checks cover: -The route model, capacity `C=1..3`, first-frame commit, rollback, SFU admission, -and managed-room lifecycle have automated invariant coverage. That coverage does -not prove target-network quality, latency, or interoperability. See -[ADR-0005](./adr/0005-automatic-hybrid-media-routing.md) and the -[routing contract](./product/routing-transport.md). +- Native H264/VP8 output, compatible encoding reuse, different weak-budget + grouping, keyframe-safe retarget and independent retirement; +- Windows live/paused/quiet-source settings, source replacement and subsequent + sharing, including media-object continuity where required; +- Native H264 SFU video and decoded Opus through a profile change and closure; +- Browser-to-Client fanout and Client-exit fallback; +- concurrent Native sessions with independent media and retirement; +- complete large encoded access-unit transport and bounded queue/cache behavior; +- Linux container startup as non-root with a read-only filesystem, memory/SQLite + restart semantics, diagnostics export, STUN and listener lifecycle; and +- public embedded-SFU H264/VP8, audio, live profile changes and closure after the + coordinated production cutover. -## Media Quality +The owner confirmed Windows 10 whole-display capture resolved. Chromium +WebRTC/IP-handling policy explains the verified local-media failure; it is a +Browser policy limitation, not a Vivaldi-specific transport workaround. -Still required: +NAT traversal and Auto codec selection are implemented features, not unfinished +functionality. On 2026-09-09 the owner confirmed that the new NAT traversal is +useful in actual use. That field confirmation is accepted; it does not claim a +population success percentage or identify which candidate won without a trace. +Neither a statistical NAT campaign nor exhaustive Auto hardware benchmarking +is required to close this phase. Investigate further only from a new failure +or a measured improvement worth its implementation and maintenance cost. -- real games at 720p30, 1080p30, and 1080p60 on weaker Hosts; -- actual H.264/VP8 encoder paths and sustained CPU/GPU contention on Windows, - macOS, and Linux; -- mixed P2P/SFU quality and recovery on heterogeneous public networks; -- screen-audio presence, stereo correctness, weak-network behavior, and A/V - synchronization after SFU RED was disabled; and -- long-running encode/decode cost, thermals, frame delivery, and resource - admission at the 20-Viewer bound. +## Remaining Device And Network Acceptance -Current controlled Browser evidence supports the H.264/VP8 gate, `motion` -content intent, startup-quality workaround, LiveKit-owned SFU adaptation, native -edge convergence, and same-edge connection regeneration invariants. The -2026-08-30 production comparison confirms recovery on a fresh same-route sender, -but does not establish its quality or resource benefit on heterogeneous public -networks. Room 8489 confirms synchronized SFU stalls and representation churn, -but retained logs do not prove whether Dynacast layer disablement, subscriber -forwarding, or keyframe reacquisition owned each freeze. See -[media quality](./product/media-quality.md) -and [realtime quality research](./research/realtime-quality-adaptation.md). +The following are broader coverage and endurance tasks, not automatically new +implementation mechanisms: -## Browser Lifecycle +- heterogeneous public-network P2P and multi-hop Browser/Native/SFU-fed relays; +- Host/Viewer TUN/VPN combinations, migration and all-UDP-blocked bounded failure; +- two-room public SFU load, real-game audio/video synchronization and sustained + packet loss/recovery; +- weaker Hosts at 720p30, 1080p30 and 1080p60, CPU/GPU contention, thermals, + long-running resources and the 20-Viewer bound; and +- physical mobile autoplay, background/lock, page reclamation, rotation, + Wi-Fi/cellular migration and relay survival. -Still required on physical Android Chrome and iOS Safari: +A short 20-Viewer topology run from an earlier revision proves only its tested +invariants; it is not current-contract endurance or a public-network quality +guarantee. A direct postflight with both endpoints on one machine proves public +signaling and local media, not restricted-NAT traversal. -- autoplay and user-gesture recovery; -- foreground/background audio; -- foreground video recovery after background or lock; -- page reclamation and BFCache behavior; -- rotation and viewport changes; -- Wi-Fi/cellular migration; and -- survival or controller recovery when the mobile Viewer is a relay. +macOS/Linux physical capture/audio/recovery remains explicitly deferred by the +owner. Linux producer compilation and older Mac SDK/package evidence do not +prove the current Windows multi-output adaptation contract on those platforms. -Desktop Host background/minimized capture also remains unconfirmed under a -current-production real game. Web code cannot promise OS background execution, -page retention, or capture keepalive. See -[presentation and lifecycle](./product/presentation-lifecycle.md) and -[background capture research](./research/browser-background-capture.md). +## Adaptation Boundaries -## Screener Client +The [encoder-pool research](./research/webrtc-encoder-pool.md) retains both +successful and unsuccessful observations: -The Go Client has unit coverage for persistent Local/Site configuration, LAN -address selection, Browser launch commands, and its loopback -port/Origin/Host/session contract. The same source cross-builds for Windows -amd64, macOS arm64, and Linux amd64. +- A prolonged shaped-network run recovered original output about 1.5 seconds + after release and stayed near 30 fps; an earlier twelve-second observation + missed recovery. The cause of that difference is not established. +- H264's stock QP adaptation can take tens of seconds to restore full size. +- Extreme single-core VP8 overload produced multi-second encode gaps that reset + the stock CPU detector's samples. That result does not establish normal-device + or Browser parity and does not authorize custom CPU thresholds. +- Equal effective demands can share; incompatible demands may need more encoders. + Extra fallback outputs, buffers and recovery keyframes have real costs. +- The 360-to-187 encode-call result compares a bounded prototype with independent + encoders. It is not an old-versus-new Native product CPU benchmark. -Every packaged-Client result in this section is pre-port evidence from the -package that bundled a Node runtime and an application tree. Capture, media, -discovery, and network results remain applicable; package assembly, startup, and -shutdown claims must be re-verified on the single-binary Client before it is -accepted. +The earlier borrowed-child Browser experiment failed independent quality and +accounting. The independent producer in ADR-0014 now has bounded H264/VP8 +direct/relay, weak-path, lifecycle and A/V evidence on the acceptance candidate; +[its research](./research/browser-local-encoding-pool.md) owns results and limits. +The owner authorized its release on 2026-09-09. Browser relay shares local outputs +among its direct children; it still re-encodes received media. Native/SFU healthy +forwarding remains encoded. -On Windows, Chrome 151.0.7922.175 completed the built Local page's -fragment access, Host surface, LAN invitation, and clean Client/Node/Browser -shutdown. A separate synthetic run formed a P2P-only Host-plus-three-Viewer tree -with one Browser relay and advancing frames at every Viewer. The Hosted Site -completed v8 loopback `hello`/`ping` after CDP granted `loopback-network`; without -that permission Chrome blocked it as expected. -The pre-port clean-revision Windows package assembled with Node 24.19.0 passed -the package checks recorded for that revision; changing `app/REVISION` made it -fail before opening a listener. Its fresh Site loopback gate closed the Client, -Browser, port, and temporary profile. -The same explicit-target assembly produced a Linux amd64 package that ran its -bundled Node application and loopback runtime on an independent Ubuntu host. -Its full package also created a public link reachable from another network and -closed that link and all local ports on exit. The macOS arm64 output contains -matching Mach-O arm64 Client and Node binaries. Its prior fixed-profile capture -baseline and current variable-profile/audio sidecar compile on the macOS runner; -the latter passes the synthetic hardware-H.264 IDR self-test. The full package -and ScreenCaptureKit video/audio path have not run on a physical Mac. Linux CI -also compiles, probes, packages, starts, and stops its Portal/PipeWire/system- -GStreamer hardware-H.264 adapter; it has not captured on a physical desktop. +## Interpretation -Still required are physical second-device LAN playback, first-run LNA prompt, -no-STUN mDNS behavior, macOS package execution, and equivalent non-Windows -capture, audio, firewall, and process-lifecycle tests. Windows native capture, hardware H.264, -one shared Pion source feeding two Chrome transports, STUN candidate gathering, -and a Local Client Host path have bounded physical gates. A remote Pion gate -also received 30 video packets over a selected `srflx`-to-`srflx` pair; its -signaling used a temporary reverse SSH test path. The Windows media gate now -also receives non-zero process/system-loopback Opus on both native edges and -verifies decoded audio energy. The native Host gate passes display and window -source selection and bounded previews; its window arm closes the captured -source, observes the current share end, restarts capture in the same room, and -requires the existing Viewer to receive a different media object plus 30 new -frames. The same product gate changes a live Native Host from default 1080p30 -to 1440p30, then changes resolution while paused and resumes the same Viewer -media object through the Host UI. A separate two-Viewer media gate changes -720p30 to 1440p60, then changes to 480p15 while paused and resumes both -connections. Display-source lifecycle is not claimed by that arm. -Native P2P quality evidence and the Browser-mediated native-source SFU happy -path now have bounded gates. A focused unit gate proves that a quality candidate -from a current Native sender edge selects the stock Browser sender, while ordinary -candidates remain Native. Controlled weak-path commit/rollback, production -package integration, SFU recovery and endurance, and physical non-Windows -capture remain unproved. - -A five-second headless topology run with 20 Viewers passed the existing route -capacity, no-orphan-publication, quality-propagation, and decoded-frame checks; -it is topology evidence, not a long-running or public-network quality claim. -An isolated LiveKit Server 1.13.6 run also delivered default 1080p native media, -then 15 decoded 854x480 frames after a live source/publisher profile change, -through the existing Browser SFU publisher and completed cleanup. - -The one-link gate started a session-scoped public origin from a Windows Client, -confirmed that its ordinary invitation used that origin, and served the Viewer -page plus the existing `/signal` WebSocket upgrade to an independent Linux host. -A native Host then used that public signaling path with an independent Linux -Pion Viewer; repeated runs delivered 30+ H.264 RTP packets over selected direct -paths using a reflexive candidate. Client exit stopped Node and the public link. -This still does not prove decoded Browser media on a physical second device. - -On 2026-09-05, a local Windows gate proved a Browser Host feeding a Client- -activated Native Viewer: the Viewer held the v8 control session while Chrome -decoded 621 frames at 1280x720. A separate Pion integration gate proves that the -same inbound source forwards H.264 and Opus RTP to one downstream edge without -re-encoding. A physical mixed-device relay and public-link Browser Viewer remain -open. - -On 2026-09-05, a Native preflight showed that Pion's ordinary srflx gatherer -used three temporary local ports for three public STUN destinations. The current -Universal UDP mux adapter instead emitted an srflx observation related to the -Engine's sole media port. A public-link run then delivered 35 H.264 RTP packets -from that Windows Native Host to the independent Linux Pion Viewer over a -selected direct host-to-srflx pair. The active TUN path exposed only one distinct -mapping, so this proves shared-socket discovery and transport, not prediction. - -On 2026-09-04, a bounded Windows physical check held a static Notepad source -open beyond seven seconds, produced a requested recovery keyframe from its -retained image, and exited cleanly. The Native Host crash gate also terminated the -Client process and observed the Host return to its start-share control within -the bounded check. These results cover the current Windows build only. - -That same pre-port clean revision assembled into a self-contained Windows package -with a matching Node runtime, application tree, and native capture process. -Chrome 151 passed the package's Local startup gate and both Site -loopback-permission arms; revision mismatch remains fail-closed before a listener -starts. - -## Interpretation Rules - -The current Windows Native candidate additionally passed manual VP8 and Auto- -selected H264 playback, with actual Host/Viewer RTP codec checks across live -presets, paused changes and source replacement. A three-attempt NAT acquisition -has deterministic controller/signaling coverage, not a field success-rate -claim. Windows 10 monitor sharing was confirmed resolved by the owner on -2026-09-09. Game-specific aspect/window-replacement behavior is not established -by those checks. The matching bundle -must be accepted before any coordinated private-wire deployment. - -- Configuration, unit tests, loopback, and synthetic signaling prove invariants, - not target-network or device behavior. -- An automated/package gate is evidence from its scripted runner; a physical - gate requires the named target device, network, or desktop environment. -- Missing RTCStats fields are unknown, not zero. -- Requested quality values are ceilings; actual sender/receiver stats are truth. -- A page-lifecycle correction is recovery logic, not a keepalive guarantee. -- Expensive evidence remains applicable only while its protocol, component, - configuration, environment, and acceptance boundary remain materially the - same. +Configuration/unit/loopback checks prove their named invariants. Physical results +belong to the tested revision, device and workload. Missing counters remain +unknown. A new diagnostic collector must be checked in a real exported report, +including retention and failed-collector information; logging a method call alone +does not establish useful evidence. Browser/OS suspension remains a physical +limit rather than a keepalive guarantee. diff --git a/go.mod b/go.mod index b893f5c..88d1fd9 100644 --- a/go.mod +++ b/go.mod @@ -9,9 +9,11 @@ require ( github.com/charmbracelet/x/ansi v0.11.8 github.com/charmbracelet/x/term v0.2.2 github.com/coder/websocket v1.8.15 + github.com/go-logr/logr v1.4.4 github.com/livekit/livekit-server v1.13.6 github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095 github.com/livekit/protocol v1.50.5-0.20260821144838-17c16cf496fd + github.com/livekit/server-sdk-go/v2 v2.14.0 github.com/netbirdio/go-nat v0.0.0-20260821095157-6b2c8c5c74e8 github.com/pion/ice/v4 v4.4.0 github.com/pion/interceptor v0.1.47 @@ -24,8 +26,10 @@ require ( github.com/pion/turn/v5 v5.0.12 github.com/pion/webrtc/v4 v4.2.18 github.com/thesyncim/gopus v0.1.1 + go.uber.org/zap v1.28.0 golang.org/x/crypto v0.54.0 golang.org/x/text v0.41.0 + google.golang.org/protobuf v1.36.11 modernc.org/sqlite v1.58.0 ) @@ -50,7 +54,6 @@ require ( github.com/frostbyte73/core v0.1.1 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/gammazero/deque v1.2.1 // indirect - github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/cel-go v0.30.0 // indirect @@ -71,7 +74,6 @@ require ( github.com/lithammer/shortuuid/v4 v4.2.0 // indirect github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // indirect github.com/livekit/psrpc v0.7.3 // indirect - github.com/livekit/server-sdk-go/v2 v2.14.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.1 // indirect github.com/mackerelio/go-osstat v0.2.8 // indirect github.com/mattn/go-isatty v0.0.24 // indirect @@ -109,7 +111,6 @@ require ( go.opentelemetry.io/otel/trace v1.45.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.28.0 // indirect go.uber.org/zap/exp v0.3.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 // indirect @@ -120,7 +121,6 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect google.golang.org/grpc v1.83.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.75.6 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/internal/client/clientapp/app.go b/internal/client/clientapp/app.go index 85d7648..8d2e13d 100644 --- a/internal/client/clientapp/app.go +++ b/internal/client/clientapp/app.go @@ -70,7 +70,7 @@ func Run(ctx context.Context, options Options) (returnedErr error) { defer func() { cancel() if recorder != nil { - slog.Debug("screener-client", "event", "stopped", "failed", returnedErr != nil) + slog.Debug("screener-client", "event", "stopped", "failed", returnedErr != nil, diagnostics.Error(returnedErr)) if options.console.program == nil { path, err := recorder.Export() options.console.send(consoleExportResult{path: path, err: err}) @@ -88,15 +88,17 @@ func Run(ctx context.Context, options Options) (returnedErr error) { return fmt.Errorf("Screener Client diagnostics are unavailable: %w", err) } previous, previousWriter, previousFlags := slog.Default(), log.Writer(), log.Flags() + var dependencyLog *diagnostics.LineWriter restoreLogger = func() { + _ = dependencyLog.Close() slog.SetDefault(previous) log.SetOutput(previousWriter) log.SetFlags(previousFlags) } options.logger = recorder.Logger() slog.SetDefault(options.logger) - // Only structured application events belong in the persisted log. - log.SetOutput(previousWriter) + dependencyLog = diagnostics.Writer("stdlib") + log.SetOutput(dependencyLog) log.SetFlags(previousFlags) options.console.send(consoleDebug{logPath: recorder.LogPath(), export: recorder.Export}) } @@ -131,6 +133,12 @@ func Run(ctx context.Context, options Options) (returnedErr error) { } } nativeMedia := discoverNativeMedia(ctx, options.CaptureProcess) + if recorder != nil { + recorder.Context("configuration", map[string]any{"siteConfigured": config.Site != "", "local": options.Local, + "link": options.Link, "port": options.Port, "localAccessProtected": config.LocalAccessPassword != ""}) + recorder.Context("capture", nativeMedia.capture) + recorder.Binary("captureExecutable", nativeMedia.captureProcess) + } slog.Debug("screener-client", "event", "native-capabilities", "video", nativeMedia.capabilities.Video, "processAudio", nativeMedia.capabilities.ProcessAudio, "systemAudio", nativeMedia.capabilities.SystemAudio, "hardwareH264", nativeMedia.capabilities.HardwareH264, diff --git a/internal/client/mediaedge/edge.go b/internal/client/mediaedge/edge.go index 6e96b85..595833f 100644 --- a/internal/client/mediaedge/edge.go +++ b/internal/client/mediaedge/edge.go @@ -2,13 +2,13 @@ package mediaedge import ( "errors" - "fmt" "io" "log/slog" "strconv" "strings" "sync" + "github.com/TNTcraftHIM/Screener/internal/diagnostics" "github.com/TNTcraftHIM/Screener/internal/media/forwarding" "github.com/pion/ice/v4" "github.com/pion/webrtc/v4" @@ -89,6 +89,7 @@ func (engine *Engine) NewEdge(source *Source, options EdgeOptions) (*Edge, error return nil, err } connection := transport.PC + log := slog.Default().With("connectionId", diagnostics.ID(options.ConnectionID), "rtcPeerId", diagnostics.ID(connection.ID())) transport.SetAudioBitrate(options.Audio.configuredBitrate()) edge := &Edge{ connectionID: options.ConnectionID, @@ -137,22 +138,22 @@ func (engine *Engine) NewEdge(source *Source, options EdgeOptions) (*Edge, error edge.localCandidates.addPion(candidate) }) connection.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) { - slog.Debug("screener-client", "event", "media-ice-state", "direction", "outbound", "local", options.Local, "state", state.String()) + log.Debug("screener-client", "event", "media-ice-state", "direction", "outbound", "local", options.Local, "state", state.String()) }) if dtls := edge.sender.Transport(); dtls != nil { dtls.OnStateChange(func(state webrtc.DTLSTransportState) { - slog.Debug("screener-client", "event", "media-dtls-state", "direction", "outbound", "local", options.Local, "state", state.String()) + log.Debug("screener-client", "event", "media-dtls-state", "direction", "outbound", "local", options.Local, "state", state.String()) }) } connection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { debug := slog.Default().Enabled(engine.ctx, slog.LevelDebug) if debug { - slog.Debug("screener-client", "event", "media-connection-state", "direction", "outbound", "local", options.Local, "state", state.String()) + log.Debug("screener-client", "event", "media-connection-state", "direction", "outbound", "local", options.Local, "state", state.String()) } if state == webrtc.PeerConnectionStateConnected { if err := edge.transport.SetConnected(); err != nil { if debug { - slog.Debug("screener-client", "event", "media-activation-failed", "errorType", fmt.Sprintf("%T", err)) + log.Debug("screener-client", "event", "media-activation-failed", diagnostics.Error(err)) } _ = connection.Close() return @@ -164,7 +165,7 @@ func (engine *Engine) NewEdge(source *Source, options EdgeOptions) (*Edge, error if pair, pairErr := edge.SelectedPair(); pairErr == nil { selected = &pair if debug { - slog.Debug("screener-client", "event", "media-selected-path", "direction", "outbound", "local", options.Local, + log.Debug("screener-client", "event", "media-selected-path", "direction", "outbound", "local", options.Local, "localType", pair.Local.String(), "remoteType", pair.Remote.String(), "natTraversalPath", pair.NatTraversalPath) } } @@ -251,13 +252,16 @@ func (edge *Edge) SetAnswer(answer webrtc.SessionDescription) error { for _, candidate := range pending { // Candidates are disposable edge input. The connection state callback // remains the authority for a real media failure. - _ = edge.connection.AddICECandidate(candidate) + if err := edge.connection.AddICECandidate(candidate); err != nil { + slog.Debug("media-candidate-rejected", "connectionId", diagnostics.ID(edge.connectionID), "stage", "drain", diagnostics.Error(err)) + } } return nil } func (edge *Edge) AddRemoteCandidate(candidate *webrtc.ICECandidateInit) error { if malformedRemoteCandidate(candidate) { + slog.Debug("media-candidate-rejected", "connectionId", diagnostics.ID(edge.connectionID), "stage", "malformed") return nil } value := webrtc.ICECandidateInit{} @@ -272,6 +276,7 @@ func (edge *Edge) AddRemoteCandidate(candidate *webrtc.ICECandidateInit) error { if !edge.remoteDescriptionSet { if len(edge.pendingCandidates) >= maxPendingCandidates { edge.mu.Unlock() + slog.Debug("media-candidate-rejected", "connectionId", diagnostics.ID(edge.connectionID), "stage", "queue-full", "capacity", maxPendingCandidates) return nil } edge.pendingCandidates = append(edge.pendingCandidates, value) @@ -282,7 +287,9 @@ func (edge *Edge) AddRemoteCandidate(candidate *webrtc.ICECandidateInit) error { // A candidate may become stale between validation and delivery. Dropping // that one edge input keeps the shared control session alive; ICE state // events still report whether the edge itself can connect. - _ = edge.connection.AddICECandidate(value) + if err := edge.connection.AddICECandidate(value); err != nil { + slog.Debug("media-candidate-rejected", "connectionId", diagnostics.ID(edge.connectionID), "stage", "apply", diagnostics.Error(err)) + } return nil } diff --git a/internal/client/mediaedge/engine.go b/internal/client/mediaedge/engine.go index 62ca4b1..8214402 100644 --- a/internal/client/mediaedge/engine.go +++ b/internal/client/mediaedge/engine.go @@ -3,6 +3,7 @@ package mediaedge import ( "context" "errors" + "log/slog" "net" "net/netip" "strconv" @@ -12,11 +13,11 @@ import ( "time" "github.com/TNTcraftHIM/Screener/internal/client/portmapping" + "github.com/TNTcraftHIM/Screener/internal/diagnostics" "github.com/TNTcraftHIM/Screener/internal/media/encoded" "github.com/TNTcraftHIM/Screener/internal/media/forwarding" "github.com/pion/ice/v4" "github.com/pion/interceptor" - "github.com/pion/logging" "github.com/pion/rtcp" "github.com/pion/stun/v3" "github.com/pion/webrtc/v4" @@ -86,7 +87,7 @@ func NewEngine(options EngineOptions) (*Engine, error) { if err != nil { return nil, errors.New("native media UDP socket is unavailable") } - loggerFactory := logging.NewDefaultLoggerFactory() + loggerFactory := diagnostics.PionLoggerFactory() ready := make(chan struct{}) mux := ice.NewUniversalUDPMuxDefault(ice.UniversalUDPMuxParams{ Logger: loggerFactory.NewLogger("screener-ice"), @@ -190,6 +191,7 @@ func (engine *Engine) surveySTUN( } addresses, err := net.DefaultResolver.LookupNetIP(ctx, "ip4", uri.Host) if err != nil || len(addresses) == 0 { + slog.DebugContext(ctx, "nat-survey", "event", "resolve-failed", "host", uri.Host, diagnostics.Error(err)) continue } serverAddress, err := net.ResolveUDPAddr( @@ -207,6 +209,7 @@ func (engine *Engine) surveySTUN( results := make(chan mappedAddress, len(targets)) for _, target := range targets { go func(address *net.UDPAddr) { + started := time.Now() mapped, err := engine.mux.GetXORMappedAddrContext( surveyContext, address, @@ -214,8 +217,13 @@ func (engine *Engine) surveySTUN( ) if err != nil || mapped == nil || mapped.IP.To4() == nil || mapped.Port < 1 || mapped.Port > 65_535 { + slog.DebugContext(surveyContext, "nat-survey", "event", "binding-failed", "serverPort", address.Port, + "durationMs", time.Since(started).Milliseconds(), diagnostics.Error(err)) return } + slog.DebugContext(surveyContext, "nat-survey", "event", "binding", "serverPort", address.Port, + "localPort", engine.localPort, "mappedAddress", diagnostics.ID(mapped.IP.String()), "mappedPort", mapped.Port, + "durationMs", time.Since(started).Milliseconds()) select { case results <- mappedAddress{address: mapped.IP.String(), port: mapped.Port}: case <-surveyContext.Done(): diff --git a/internal/client/mediaedge/groups.go b/internal/client/mediaedge/groups.go index 15d1a5c..e6a8518 100644 --- a/internal/client/mediaedge/groups.go +++ b/internal/client/mediaedge/groups.go @@ -2,8 +2,10 @@ package mediaedge import ( "errors" + "log/slog" "time" + "github.com/TNTcraftHIM/Screener/internal/diagnostics" "github.com/TNTcraftHIM/Screener/internal/media/encoded" "github.com/TNTcraftHIM/Screener/internal/media/forwarding" "github.com/pion/rtcp" @@ -93,6 +95,18 @@ func (source *Source) planGroups(demands []outputDemand) (OutputPlan, error) { delete(source.memberships, consumer) } } + // The previous frame's active bit alone may outlive its last handoff. + inUse := func(group *outputGroup) bool { + if group == nil || !group.active { + return false + } + for _, demand := range demands { + if demand.active && (demand.consumer.CurrentSource() == group.media.Source || source.memberships[demand.consumer] == group) { + return true + } + } + return false + } for _, demand := range demands { if !demand.active || !demand.lower { if source.memberships[demand.consumer] != nil { @@ -105,14 +119,27 @@ func (source *Source) planGroups(demands []outputDemand) (OutputPlan, error) { for slot := 0; slot < len(source.outputBitrates); slot++ { group := source.groups[slot] if group != nil && source.outputBitrates[slot] > 0 && group.budget == demand.budget { - selected = group - break + if selected == nil || inUse(group) { + selected = group + } + if inUse(group) { + break + } } } - if selected == nil { + if selected == nil || !inUse(selected) { // A group's rates can change in place only when no other current or // requested member still needs a different budget. - ordered := []*outputGroup{source.memberships[demand.consumer], source.groupForMedia(demand.consumer.CurrentSource())} + owned := []*outputGroup{source.memberships[demand.consumer], source.groupForMedia(demand.consumer.CurrentSource())} + var ordered []*outputGroup + for _, group := range owned { + if inUse(group) { + ordered = append(ordered, group) + } + } + // Keep an owned live pipeline before reviving an inactive cached match. + ordered = append(ordered, selected) + ordered = append(ordered, owned...) for slot := 0; slot < len(source.outputBitrates); slot++ { if group := source.groups[slot]; group != nil { ordered = append(ordered, group) @@ -156,6 +183,12 @@ func (source *Source) planGroups(demands []outputDemand) (OutputPlan, error) { // membership. No timer, retry queue or temporary capacity exemption. continue } + if selected.budget != demand.budget || source.memberships[demand.consumer] != selected { + if slog.Default().Enabled(source.engine.ctx, slog.LevelDebug) { + slog.Debug("encoding-group", "event", "demand", "slot", selected.slot, "budget", demand.budget, + "localPort", source.engine.localPort, "rtcPeerId", groupConsumerID(demand.consumer)) + } + } selected.budget = demand.budget if !selected.active { selected.start = source.inputPTS @@ -176,7 +209,11 @@ func (source *Source) planGroups(demands []outputDemand) (OutputPlan, error) { active = true } } - group.active = active && source.outputBitrates[group.slot] > 0 + nextActive := active && source.outputBitrates[group.slot] > 0 + if group.active != nextActive { + slog.Debug("encoding-group", "event", "activity", "slot", group.slot, "active", nextActive, "localPort", source.engine.localPort) + } + group.active = nextActive plan.Active[group.slot] = group.active if group.active { plan.Bitrates[group.slot] = uint32(max(1000, min(group.media.CodecBudget(0, int64(group.budget)), int64(source.outputBitrates[group.slot])))) @@ -231,11 +268,29 @@ func (source *Source) installGroup(group *outputGroup, original bool) { } for consumer, wanted := range source.memberships { if wanted == group && (original || group != nil) && consumer.CurrentSource() != next { - _ = consumer.ReplaceSource(next) + err := consumer.ReplaceSource(next) + if slog.Default().Enabled(source.engine.ctx, slog.LevelDebug) { + slot := -1 + if group != nil { + slot = group.slot + } + slog.Debug("encoding-group", "event", "attachment", "slot", slot, "original", original, + "localPort", source.engine.localPort, "rtcPeerId", groupConsumerID(consumer), diagnostics.Error(err)) + } } } } +func groupConsumerID(consumer groupConsumer) string { + switch consumer := consumer.(type) { + case *forwarding.Transport: + return diagnostics.ID(consumer.PC.ID()) + case *forwarding.Publication: + return diagnostics.ID(consumer.PC.ID()) + } + return "" +} + func (source *Source) beginGroupFrame(pts time.Duration, at time.Time) error { if err := source.media.BeginFrame(pts, at); err != nil { return err diff --git a/internal/client/mediaedge/groups_test.go b/internal/client/mediaedge/groups_test.go index 25f4c64..06cd11d 100644 --- a/internal/client/mediaedge/groups_test.go +++ b/internal/client/mediaedge/groups_test.go @@ -95,6 +95,11 @@ func TestOutputGroupsShareSplitRejoinAndRetire(t *testing.T) { if retired.Active[gb.slot] { t.Fatal("last lower member did not release its encoder") } + demands[0].active = false + if inactive := plan(); inactive.Active[ga.slot] || inactive.Active[gb.slot] { + t.Fatal("disconnected consumer kept an output active") + } + demands[0].active = true demands[0].lower = true demands[0].original = false demands[0].budget = 300_000 @@ -102,6 +107,26 @@ func TestOutputGroupsShareSplitRejoinAndRetire(t *testing.T) { if !reactivated.Active[gb.slot] || reactivated.Active[1] || source.memberships[a] != gb { t.Fatal("inactive compatible group could not reactivate") } + source.installGroup(gb, false) + started := gb.start + source.groupRecovery.Store(0) + demands[0].budget = ga.budget + repriced := plan() + if source.memberships[a] != gb || a.CurrentSource() != gb.media.Source || + !repriced.Active[gb.slot] || repriced.Active[ga.slot] || repriced.Bitrates[gb.slot] != ga.budget || + gb.start != started || source.groupRecovery.Load() != 0 { + t.Fatalf("inactive budget match replaced live pipeline: member=%v source=%v active=%v rate=%d want=%d start=%v/%v recovery=%d", source.memberships[a] == gb, a.CurrentSource() == gb.media.Source, repriced.Active, repriced.Bitrates[gb.slot], ga.budget, gb.start, started, source.groupRecovery.Load()) + } + c := &groupTestConsumer{source.media.Source} + demands = append(demands, outputDemand{consumer: c, budget: ga.budget, active: true, lower: true}) + plan() + if source.memberships[c] != gb { + t.Fatal("inactive budget match took precedence over a compatible active output") + } + source.installGroup(gb, false) + if settled := plan(); settled.Active[ga.slot] || c.CurrentSource() != gb.media.Source { + t.Fatal("last handoff retained an unneeded output") + } if err = gb.media.BeginFrame(tick+time.Nanosecond, time.Unix(0, 0).Add(tick+time.Nanosecond)); err != nil { t.Fatal(err) } diff --git a/internal/client/mediaedge/quality.go b/internal/client/mediaedge/quality.go index a69f420..03c3e83 100644 --- a/internal/client/mediaedge/quality.go +++ b/internal/client/mediaedge/quality.go @@ -1,8 +1,10 @@ package mediaedge import ( + "log/slog" "time" + "github.com/TNTcraftHIM/Screener/internal/diagnostics" "github.com/TNTcraftHIM/Screener/internal/media/forwarding" "github.com/pion/webrtc/v4" ) @@ -68,6 +70,15 @@ func (edge *Edge) QualitySample(now time.Time) (QualitySample, bool) { Width: uint32(format >> 32), Height: uint32(format), } + if slog.Default().Enabled(edge.engine.ctx, slog.LevelDebug) { + defer func() { + slog.Debug("media-sample", "connectionId", diagnostics.ID(edge.connectionID), "local", edge.local, + "rtcPeerId", diagnostics.ID(edge.connection.ID()), "windowMs", sample.SampleWindowMs, + "fps", sample.FramesPerSecond, "bitrateKbps", sample.BitrateKbps, "width", sample.Width, "height", sample.Height, + "availableOutgoingKbps", sample.AvailableOutgoingKbps, "state", sample.State, "reason", sample.Reason, + "egress", edge.transport.Egress(), "output", edge.transport.Output.State()) + }() + } target, observed := edge.targetBitrate() if edge.State() != webrtc.PeerConnectionStateConnected || !observed || frames == 0 || bytes == 0 { diff --git a/internal/client/mediaedge/relay_derivation.go b/internal/client/mediaedge/relay_derivation.go index 4ad4625..1ca0494 100644 --- a/internal/client/mediaedge/relay_derivation.go +++ b/internal/client/mediaedge/relay_derivation.go @@ -5,7 +5,6 @@ import ( "errors" "io" "sync" - "sync/atomic" "time" "github.com/TNTcraftHIM/Screener/internal/client/nativecapture" @@ -43,11 +42,10 @@ func relayBackend(codec string, options *RelayOptions) (nativecapture.EncodedVid // relayDerivation owns only the optional missing lower representation. Raw RTP // never waits for it. A retired process must exit before another one can start. type relayDerivation struct { - source *Source - options RelayOptions - recovery atomic.Bool - mu sync.Mutex - run *relayRun + source *Source + options RelayOptions + mu sync.Mutex + run *relayRun } type relayPlan struct { @@ -182,7 +180,6 @@ func (relay *relayDerivation) push(packet *rtp.Packet) { done: make(chan struct{}), packets: make(chan *rtp.Packet, relayPacketLimit), anchorRTP: packet.Timestamp, anchorTime: at, } - relay.recovery.Store(true) go relay.run.serve() // Earlier packets of this frame used the raw path only. Begin assembly // with the next complete frame, not this frame's trailing fragment. @@ -310,11 +307,6 @@ func (run *relayRun) write(stream *nativecapture.Stream) error { if !plan.wanted || plan.profile != run.plan.profile || plan.format != run.plan.format { return context.Canceled } - if run.owner.recovery.Swap(false) { - if err = stream.RequestKeyFrame(-1); err != nil { - return err - } - } if err = stream.WriteFrame(frame); err != nil { return err } diff --git a/internal/client/mediaedge/source.go b/internal/client/mediaedge/source.go index ee6ea4d..bd9b741 100644 --- a/internal/client/mediaedge/source.go +++ b/internal/client/mediaedge/source.go @@ -603,9 +603,6 @@ func (source *Source) detach(edge *Edge) { } func (source *Source) requestLayerKeyFrame(layer int) { - if source.relay != nil && layer < len(source.formats)-1 { - source.relay.recovery.Store(true) - } source.groupRecovery.Or(uint32(1) << layer) source.RequestRecoveryFrame() } diff --git a/internal/client/nativecapture/probe.go b/internal/client/nativecapture/probe.go index 00408ad..94e63e9 100644 --- a/internal/client/nativecapture/probe.go +++ b/internal/client/nativecapture/probe.go @@ -7,12 +7,15 @@ import ( "errors" "fmt" "io" + "log/slog" "os" "os/exec" "path/filepath" "runtime" "strings" "time" + + "github.com/TNTcraftHIM/Screener/internal/diagnostics" ) const ( @@ -93,12 +96,14 @@ func Discover(parent context.Context, executable string) (Capabilities, error) { ctx, cancel := context.WithTimeout(parent, probeTimeout) defer cancel() stdout := &boundedBuffer{limit: maxProbeOutputBytes} - stderr := &boundedBuffer{limit: maxProbeErrorBytes} + stderr := diagnostics.Writer("native-capture-probe") + defer stderr.Close() command := exec.CommandContext(ctx, executable, "--probe") command.Stdout = stdout command.Stderr = stderr hideWindow(command) if err := command.Run(); err != nil { + slog.DebugContext(ctx, "screener-client", "event", "capture-probe-failed", diagnostics.Error(err), "canceled", ctx.Err() != nil) if errors.Is(ctx.Err(), context.DeadlineExceeded) { return Capabilities{}, errors.New("native capture probe timed out") } @@ -178,12 +183,27 @@ func decodeProbe(payload []byte) (Capabilities, error) { } type boundedBuffer struct { - buffer bytes.Buffer - limit int + buffer bytes.Buffer + limit int + discardOverflow bool } func (buffer *boundedBuffer) Write(payload []byte) (int, error) { remaining := buffer.limit - buffer.buffer.Len() + if buffer.discardOverflow { + // Keep terminal failure codes even after a long stream of encoder statistics. + if len(payload) >= buffer.limit { + buffer.buffer.Reset() + payloadTail := payload[len(payload)-buffer.limit:] + _, _ = buffer.buffer.Write(payloadTail) + } else { + if len(payload) > remaining { + buffer.buffer.Next(len(payload) - remaining) + } + _, _ = buffer.buffer.Write(payload) + } + return len(payload), nil + } if remaining <= 0 { return 0, errors.New("native capture probe output exceeded its bound") } diff --git a/internal/client/nativecapture/stream.go b/internal/client/nativecapture/stream.go index 06dacad..fd26eef 100644 --- a/internal/client/nativecapture/stream.go +++ b/internal/client/nativecapture/stream.go @@ -14,6 +14,8 @@ import ( "sync" "time" "unicode/utf8" + + "github.com/TNTcraftHIM/Screener/internal/diagnostics" ) const ( @@ -125,6 +127,9 @@ type Stream struct { controlMu sync.Mutex closed bool closeOnce sync.Once + readEnd sync.Once + logger *slog.Logger + ctx context.Context } func ListSources(parent context.Context, executable string) ([]CaptureTarget, error) { @@ -133,9 +138,12 @@ func ListSources(parent context.Context, executable string) ([]CaptureTarget, er stdout := &boundedBuffer{limit: maxProbeOutputBytes} command := exec.CommandContext(ctx, executable, "--list") command.Stdout = stdout - command.Stderr = &boundedBuffer{limit: maxProbeErrorBytes} + stderr := diagnostics.Writer("native-source-list") + defer stderr.Close() + command.Stderr = stderr hideWindow(command) if err := command.Run(); err != nil { + slog.DebugContext(ctx, "screener-client", "event", "capture-source-list-failed", diagnostics.Error(err), "canceled", ctx.Err() != nil) return nil, errors.New("native capture source list is unavailable") } var targets []CaptureTarget @@ -165,9 +173,12 @@ func PreviewSource(parent context.Context, executable string, target CaptureTarg zeroWhenEmpty(target.CreationTime), ) command.Stdout = stdout - command.Stderr = &boundedBuffer{limit: maxProbeErrorBytes} + stderr := diagnostics.Writer("native-source-preview") + defer stderr.Close() + command.Stderr = stderr hideWindow(command) if err := command.Run(); err != nil { + slog.DebugContext(ctx, "screener-client", "event", "capture-source-preview-failed", diagnostics.Error(err), "canceled", ctx.Err() != nil) return nil, errors.New("native capture preview is unavailable") } preview := stdout.Bytes() @@ -381,7 +392,14 @@ func startStreamWithEnvironment( return nil, errors.New("native capture process is unavailable") } ctx, cancel := context.WithCancel(parent) + started := time.Now() + // A diagnostic-only process label correlates stderr while old/new captures overlap. + captureID := strconv.FormatInt(started.UnixNano(), 36) + logger := slog.Default().With("capture", captureID) command := exec.CommandContext(ctx, executable, arguments...) + if logger.Enabled(ctx, slog.LevelDebug) { + environment = append(environment, "SCREENER_CAPTURE_DEBUG=1") + } if len(environment) > 0 { command.Env = append(os.Environ(), environment...) } @@ -395,22 +413,40 @@ func startStreamWithEnvironment( cancel() return nil, err } - stderr := &boundedBuffer{limit: maxProbeErrorBytes} - command.Stderr = stderr + stderr := &boundedBuffer{limit: maxProbeErrorBytes, discardOverflow: true} + trace := diagnostics.Writer("native-capture/" + captureID) + command.Stderr = io.MultiWriter(stderr, trace) hideWindow(command) if err = command.Start(); err != nil { + _ = trace.Close() cancel() + logger.DebugContext(ctx, "screener-client", "event", "capture-process-start-failed", diagnostics.Error(err)) return nil, fmt.Errorf("start native capture process: %w", err) } + logger = logger.With("pid", command.Process.Pid) + mode := "capture" + if len(arguments) > 0 { + switch arguments[0] { + case "--capture-video", "--capture-audio", "--encoded-video": + mode = strings.TrimPrefix(arguments[0], "--") + } + } + logger.DebugContext(ctx, "screener-client", "event", "capture-process-started", "mode", mode) stream := &Stream{ cancel: cancel, input: stdout, key: stdin, done: make(chan error, 1), + logger: logger, + ctx: ctx, } go func() { waitErr := command.Wait() + _ = trace.Close() logCaptureFailure(ctx, waitErr, command.ProcessState.ExitCode(), stderr.Bytes()) + logger.DebugContext(ctx, "screener-client", "event", "capture-process-ended", "mode", mode, + "durationMs", time.Since(started).Milliseconds(), "exitCode", command.ProcessState.ExitCode(), + "canceled", ctx.Err() != nil, "cancelReason", diagnostics.SafeText(fmt.Sprint(context.Cause(ctx))), diagnostics.Error(waitErr)) stream.done <- waitErr close(stream.done) }() @@ -442,7 +478,14 @@ func logCaptureFailure(ctx context.Context, err error, exitCode int, stderr []by } func (stream *Stream) Read() (Frame, error) { - return readFrame(stream.input) + frame, err := readFrame(stream.input) + if err != nil && stream.logger != nil { + stream.readEnd.Do(func() { + stream.logger.DebugContext(stream.ctx, "screener-client", "event", "capture-stream-ended", + "eof", errors.Is(err, io.EOF), "canceled", stream.ctx.Err() != nil, diagnostics.Error(err)) + }) + } + return frame, err } // WriteFrame shares the existing stdin lock with control. Only encoded video diff --git a/internal/client/nativecapture/stream_test.go b/internal/client/nativecapture/stream_test.go index e62eae5..c0c0668 100644 --- a/internal/client/nativecapture/stream_test.go +++ b/internal/client/nativecapture/stream_test.go @@ -5,11 +5,28 @@ import ( "context" "encoding/json" "errors" + "io" "log/slog" "strings" "testing" ) +func TestDiagnosticStderrOverflowDoesNotStopTheChildReader(t *testing.T) { + for _, discard := range []bool{false, true} { + buffer := &boundedBuffer{limit: 4, discardOverflow: discard} + var forwarded bytes.Buffer + writer := io.MultiWriter(buffer, &forwarded) + _, err := io.Copy(writer, strings.NewReader("stage=capture-closed\ndetail=more evidence\n")) + if discard { + if err != nil || string(buffer.Bytes()) != "nce\n" || !strings.Contains(forwarded.String(), "more evidence") { + t.Fatalf("diagnostic stderr stopped or lost its bounded tail: %q, %q, %v", buffer.Bytes(), forwarded.String(), err) + } + } else if err == nil { + t.Fatal("strict probe output no longer rejects overflow") + } + } +} + func TestVideoOutputGroupCountIsBoundedBeforeProcessStartup(t *testing.T) { options := VideoOptions{ Target: CaptureTarget{Kind: "display", SourceID: "1", Title: "Display"}, diff --git a/internal/client/nativecontrol/publication.go b/internal/client/nativecontrol/publication.go index 0911734..a9b8ebd 100644 --- a/internal/client/nativecontrol/publication.go +++ b/internal/client/nativecontrol/publication.go @@ -30,7 +30,7 @@ func (session *Session) handlePublication(envelope requestEnvelope, payload []by } offer, media, err := host.PreparePublication(identity.PublicationGeneration, identity.ConnectionID, servers) if err != nil { - return operationFailure(envelope), nil + return operationFailure(envelope, err), nil } result.responseEnvelope, result.SDP, result.Media = response(envelope, "publication-offer"), offer.SDP, media return result, nil @@ -44,7 +44,7 @@ func (session *Session) handlePublication(envelope requestEnvelope, payload []by } publication, err := host.Publication(identity.PublicationGeneration, identity.ConnectionID) if err != nil { - return operationFailure(envelope), nil + return operationFailure(envelope, err), nil } accepted := "" switch envelope.Type { @@ -77,7 +77,7 @@ func (session *Session) handlePublication(envelope requestEnvelope, payload []by accepted = "publication-layers-accepted" } if err != nil { - return operationFailure(envelope), nil + return operationFailure(envelope, err), nil } return response(envelope, accepted), nil } diff --git a/internal/client/nativecontrol/session.go b/internal/client/nativecontrol/session.go index aa3aaa5..05572f9 100644 --- a/internal/client/nativecontrol/session.go +++ b/internal/client/nativecontrol/session.go @@ -6,7 +6,6 @@ import ( "encoding/base64" "encoding/json" "errors" - "fmt" "io" "log/slog" "net/url" @@ -14,6 +13,7 @@ import ( "strconv" "strings" "sync" + "time" "unicode" "github.com/TNTcraftHIM/Screener/internal/client/loopback" @@ -21,6 +21,7 @@ import ( "github.com/TNTcraftHIM/Screener/internal/client/nativecapture" "github.com/TNTcraftHIM/Screener/internal/client/nativehost" "github.com/TNTcraftHIM/Screener/internal/client/nativeviewer" + "github.com/TNTcraftHIM/Screener/internal/diagnostics" "github.com/pion/webrtc/v4" ) @@ -82,11 +83,18 @@ func (session *Session) Events() <-chan any { return session.events } -func (session *Session) Handle(ctx context.Context, payload []byte) (any, error) { +func (session *Session) Handle(ctx context.Context, payload []byte) (result any, returnedErr error) { var envelope requestEnvelope if err := decodeEnvelope(payload, &envelope); err != nil { return nil, err } + complete := session.traceRequest(envelope, payload) + asynchronous := false + defer func() { + if !asynchronous { + complete(result, returnedErr) + } + }() switch envelope.Type { case "prepare-publication", "publication-media", "publication-answer", "publication-candidate", "publication-layers", "close-publication": return session.handlePublication(envelope, payload) @@ -106,7 +114,7 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (any, error) } targets, err := nativecapture.ListSources(session.ctx, session.captureProcess) if err != nil { - return operationFailure(envelope), nil + return operationFailure(envelope, err), nil } return sourceListResponse{ responseEnvelope: response(envelope, "source-list"), @@ -125,6 +133,8 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (any, error) data := "" if err == nil { data = base64.StdEncoding.EncodeToString(preview) + } else { + operationFailure(envelope, err) } return sourcePreviewResponse{ responseEnvelope: response(envelope, "source-preview"), @@ -143,6 +153,9 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (any, error) !validQualitySettings(request.Profile) { return nil, errors.New("native start-share request is invalid") } + slog.Debug("screener-client", "event", "native-profile-requested", "requestId", envelope.ID, + "share", diagnostics.ID(request.ShareID), "profile", nativeQualityProfile(request.Profile), "codec", request.Codec, "audio", request.Audio, + "sourceKind", request.Source.Kind, "adapterIndex", request.AdapterIndex, "encoderIndex", request.EncoderIndex) return session.startShare(ctx, envelope, request) case "update-share": var request updateShareRequest @@ -153,9 +166,13 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (any, error) return nil, errors.New("native update-share request is invalid") } profile := nativeQualityProfile(request.Profile) + slog.Debug("screener-client", "event", "native-profile-requested", "requestId", envelope.ID, + "share", diagnostics.ID(request.ShareID), "profile", profile) var err error if host := session.current(request.ShareID); host != nil { - return session.updateHost(host, envelope, request.ShareID, profile), nil + result = session.updateHost(host, envelope, request.ShareID, profile, complete) + asynchronous = result == nil + return result, nil } else if viewer := session.currentViewer(request.ShareID); viewer != nil { err = viewer.UpdateProfile(profile.Video) } else { @@ -177,22 +194,24 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (any, error) updating := session.updateDone != nil session.mu.Unlock() if updating { - return operationFailure(envelope), nil + return operationFailure(envelope, errors.New("native quality update is still active")), nil } audio := request.Audio && session.capabilities.Summary().AudioFor( request.Source.Kind, ) + slog.Debug("screener-client", "event", "native-source-requested", "requestId", envelope.ID, + "share", diagnostics.ID(request.ShareID), "sourceKind", request.Source.Kind, "audio", audio, + "adapterIndex", request.AdapterIndex, "encoderIndex", request.EncoderIndex) err := host.ReplaceSource(ctx, nativecapture.VideoOptions{ Target: request.Source, AdapterIndex: request.AdapterIndex, EncoderIndex: request.EncoderIndex, }, audio) - if slog.Default().Enabled(session.ctx, slog.LevelDebug) { - slog.Debug("screener-client", "event", "share-source-replace", "failed", err != nil, "errorType", fmt.Sprintf("%T", err)) - } if err != nil { - return operationFailure(envelope), nil + return operationFailure(envelope, err), nil } + slog.Debug("screener-client", "event", "native-source-applied", "requestId", envelope.ID, + "share", diagnostics.ID(request.ShareID), "sourceKind", request.Source.Kind, "audio", audio) return shareSourceReplacedResponse{ responseEnvelope: response(envelope, "share-source-replaced"), ShareID: request.ShareID, @@ -312,9 +331,6 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (any, error) return nil, errors.New("native stop-share request is invalid") } err := session.stopShare(request.ShareID) - if slog.Default().Enabled(session.ctx, slog.LevelDebug) { - slog.Debug("screener-client", "event", "share-stop", "failed", err != nil, "errorType", fmt.Sprintf("%T", err)) - } if err != nil { return nil, err } @@ -331,6 +347,8 @@ func (session *Session) Handle(ctx context.Context, payload []byte) (any, error) return nil, errors.New("native share does not exist") } host.SetPaused(request.Paused) + slog.Debug("screener-client", "event", "native-share-paused", "requestId", envelope.ID, + "share", diagnostics.ID(request.ShareID), "paused", request.Paused) return response(envelope, "share-paused"), nil default: return nil, errors.New("native control message is unsupported") @@ -363,11 +381,11 @@ func (session *Session) Close() error { return nil } -func (session *Session) updateHost(host *nativehost.Session, request requestEnvelope, shareID string, profile nativehost.QualityProfile) any { +func (session *Session) updateHost(host *nativehost.Session, request requestEnvelope, shareID string, profile nativehost.QualityProfile, complete func(any, error)) any { session.mu.Lock() if session.closed || session.host != host || session.updateDone != nil { session.mu.Unlock() - return operationFailure(request) + return operationFailure(request, errors.New("native quality update is unavailable or already active")) } done := make(chan struct{}) session.updateDone = done @@ -380,6 +398,7 @@ func (session *Session) updateHost(host *nativehost.Session, request requestEnve err = errors.New("native share stopped during quality update") } result := session.shareUpdateResult(request, shareID, profile, err) + complete(result, err) close(done) if session.closed { session.updateDone = nil @@ -407,14 +426,11 @@ func (session *Session) updateHost(host *nativehost.Session, request requestEnve } func (session *Session) shareUpdateResult(request requestEnvelope, shareID string, profile nativehost.QualityProfile, err error) any { - if slog.Default().Enabled(session.ctx, slog.LevelDebug) { - slog.Debug("screener-client", "event", "share-update", "failed", err != nil, "errorType", fmt.Sprintf("%T", err), - "width", profile.Video.Width, "height", profile.Video.Height, "fps", profile.Video.Framerate, - "bitrate", profile.Video.Bitrate, "audioBitrate", profile.AudioBitrate) - } if err != nil { - return operationFailure(request) + return operationFailure(request, err) } + slog.Debug("screener-client", "event", "native-profile-applied", "requestId", request.ID, + "share", diagnostics.ID(shareID), "profile", profile) return shareUpdatedResponse{responseEnvelope: response(request, "share-updated"), ShareID: shareID} } @@ -422,21 +438,12 @@ func (session *Session) startShare( ctx context.Context, envelope requestEnvelope, request startShareRequest, -) (result any, returnedErr error) { +) (any, error) { profile := nativeQualityProfile(request.Profile) - codec := request.Codec - defer func() { - if slog.Default().Enabled(session.ctx, slog.LevelDebug) { - _, rejected := result.(requestFailedResponse) - slog.Debug("screener-client", "event", "share-start", "failed", returnedErr != nil || rejected, "errorType", fmt.Sprintf("%T", returnedErr), - "codec", codec, "width", profile.Video.Width, "height", profile.Video.Height, - "fps", profile.Video.Framerate, "bitrate", profile.Video.Bitrate) - } - }() session.mu.Lock() if session.updateDone != nil { session.mu.Unlock() - return operationFailure(envelope), nil + return operationFailure(envelope, errors.New("native quality update is still active")), nil } if session.closed || session.host != nil || session.viewer != nil { session.mu.Unlock() @@ -472,7 +479,9 @@ func (session *Session) startShare( } session.host = host session.mu.Unlock() - codec = host.Codec() + codec := host.Codec() + slog.Debug("screener-client", "event", "native-profile-applied", "requestId", envelope.ID, + "share", diagnostics.ID(request.ShareID), "profile", profile, "codec", codec, "audio", host.HasAudio()) go session.watchHost(host) return shareStartedResponse{ responseEnvelope: response(envelope, "share-started"), @@ -929,9 +938,37 @@ func response(request requestEnvelope, responseType string) responseEnvelope { } } -func operationFailure(request requestEnvelope) requestFailedResponse { +func operationFailure(request requestEnvelope, causes ...error) requestFailedResponse { + if len(causes) > 0 { + slog.Debug("screener-client", "event", "native-operation-failed", "requestId", request.ID, + "requestType", diagnostics.SafeText(request.Type), diagnostics.Error(causes[0])) + } return requestFailedResponse{ responseEnvelope: response(request, "request-failed"), Code: "operation-failed", } } + +func (session *Session) traceRequest(request requestEnvelope, payload []byte) func(any, error) { + if !slog.Default().Enabled(session.ctx, slog.LevelDebug) { + return func(any, error) {} + } + // Read only identities: source titles, SDP, candidates and media never enter the log. + var identity struct { + ShareID string `json:"shareId"` + ConnectionID string `json:"connectionId"` + SourceConnectionID string `json:"sourceConnectionId"` + PublicationGeneration string `json:"publicationGeneration"` + } + _ = json.Unmarshal(payload, &identity) + logger := slog.Default().With("requestId", request.ID, "requestType", diagnostics.SafeText(request.Type), + "share", diagnostics.ID(identity.ShareID), "connection", diagnostics.ID(identity.ConnectionID), + "sourceConnection", diagnostics.ID(identity.SourceConnectionID), "publication", diagnostics.ID(identity.PublicationGeneration)) + started := time.Now() + logger.Debug("screener-client", "event", "native-request-started") + return func(result any, err error) { + failure, rejected := result.(requestFailedResponse) + logger.Debug("screener-client", "event", "native-request-ended", "durationMs", time.Since(started).Milliseconds(), + "failed", err != nil || rejected, "code", failure.Code, diagnostics.Error(err)) + } +} diff --git a/internal/client/nativecontrol/session_test.go b/internal/client/nativecontrol/session_test.go index abb3ea5..998e639 100644 --- a/internal/client/nativecontrol/session_test.go +++ b/internal/client/nativecontrol/session_test.go @@ -1,10 +1,12 @@ package nativecontrol import ( + "bytes" "context" "encoding/binary" "encoding/json" "io" + "log/slog" "os" "path/filepath" "strconv" @@ -94,6 +96,12 @@ func runQuietCaptureFixture(directory string) { func TestQuietHostUpdatePreservesControlAndCancelsCleanly(t *testing.T) { for _, outcome := range []string{"complete", "stop", "disconnect", "first-input-timeout", "backpressure-stop"} { t.Run(outcome, func(t *testing.T) { + var trace bytes.Buffer + if outcome == "complete" { + previous := slog.Default() + t.Cleanup(func() { slog.SetDefault(previous) }) + slog.SetDefault(slog.New(slog.NewJSONHandler(&trace, &slog.HandlerOptions{Level: slog.LevelDebug}))) + } directory := t.TempDir() t.Setenv("SCREENER_QUIET_CAPTURE_FIXTURE", directory) executable, err := os.Executable() @@ -239,6 +247,30 @@ func TestQuietHostUpdatePreservesControlAndCancelsCleanly(t *testing.T) { if _, ok := responseFor("request_followup").(shareUpdatedResponse); !ok { t.Fatal("later update could not complete") } + _ = session.Close() + var started, ended int + decoder := json.NewDecoder(&trace) + for decoder.More() { + var record map[string]any + if err := decoder.Decode(&record); err != nil { + t.Fatal(err) + } + if record["requestId"] != "request_update" { + continue + } + switch record["event"] { + case "native-request-started": + started++ + case "native-request-ended": + ended++ + if record["durationMs"].(float64) < 5_000 || record["failed"] != false || record["share"] == "share_123456" { + t.Fatalf("asynchronous request lost duration, outcome or private identity handling: %+v", record) + } + } + } + if started != 1 || ended != 1 { + t.Fatalf("async update logged %d starts and %d completions", started, ended) + } } }) } diff --git a/internal/client/nativehost/session.go b/internal/client/nativehost/session.go index b567f4f..f2b7017 100644 --- a/internal/client/nativehost/session.go +++ b/internal/client/nativehost/session.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "io" "log/slog" "slices" @@ -16,6 +15,7 @@ import ( "github.com/TNTcraftHIM/Screener/internal/client/mediaedge" "github.com/TNTcraftHIM/Screener/internal/client/nativecapture" + "github.com/TNTcraftHIM/Screener/internal/diagnostics" "github.com/TNTcraftHIM/Screener/internal/media/encoded" "github.com/pion/webrtc/v4" ) @@ -122,11 +122,15 @@ func Start(parent context.Context, options Options) (*Session, error) { } var audioStream *nativecapture.Stream if options.AudioEnabled { - audioStream, _ = startAudioCapture( + var audioErr error + audioStream, audioErr = startAudioCapture( parent, options.CaptureProcess, options.Video.Target, ) + if audioErr != nil { + slog.DebugContext(parent, "screener-client", "event", "capture-audio-unavailable", "share", diagnostics.ID(options.ShareID), diagnostics.Error(audioErr)) + } } ctx, cancel := context.WithCancel(parent) session := &Session{ @@ -151,6 +155,7 @@ func Start(parent context.Context, options Options) (*Session, error) { options.EdgeCapacity, options.Profile.AudioBitrate, ) if audioErr != nil { + slog.DebugContext(ctx, "screener-client", "event", "capture-audio-source-failed", "share", diagnostics.ID(options.ShareID), diagnostics.Error(audioErr)) _ = audioStream.Close() audioStream = nil session.audioStream = nil @@ -637,7 +642,7 @@ func (session *Session) run() { _ = audioStream.Close() } if slog.Default().Enabled(session.ctx, slog.LevelDebug) { - slog.Debug("screener-client", "event", "share-ended", "failed", result != nil, "errorType", fmt.Sprintf("%T", result)) + slog.Debug("screener-client", "event", "share-ended", "share", diagnostics.ID(session.shareID), "failed", result != nil, diagnostics.Error(result)) } session.done <- result close(session.done) @@ -674,6 +679,8 @@ func (session *Session) runVideo() error { } continue } + slog.DebugContext(session.ctx, "screener-client", "event", "capture-video-read-ended", + "share", diagnostics.ID(session.shareID), "canceled", session.ctx.Err() != nil, "eof", errors.Is(err, io.EOF), diagnostics.Error(err)) return fail(errors.New("native capture process stopped unexpectedly")) } if next := session.currentStream(); next != current { @@ -735,7 +742,10 @@ func (session *Session) runVideo() error { } session.mu.Unlock() slog.Debug("screener-client", "event", "capture-state", "state", status.State, "codec", status.Codec, - "width", status.Width, "height", status.Height, "fps", status.FPS) + "share", diagnostics.ID(session.shareID), "width", status.Width, "height", status.Height, "fps", status.FPS, + "hardware", status.HardwareOnly, "adapterIndex", status.AdapterIndex, "encoderIndex", status.EncoderIndex, + "adapterName", diagnostics.SafeText(status.AdapterName), "encoderName", diagnostics.SafeText(status.EncoderName), + "profileLevelId", status.ProfileLevelID, "outputs", status.Outputs) session.emit(Event{Type: "capture-state", ShareID: session.shareID, State: status.State}) if !ready { ready = true @@ -780,6 +790,8 @@ func (session *Session) runVideo() error { } } case nativecapture.FrameLayerUnavailable: + slog.DebugContext(session.ctx, "screener-client", "event", "capture-output-unavailable", + "share", diagnostics.ID(session.shareID), "layer", frame.Layer, "detail", diagnostics.SafeText(string(frame.Data))) if session.source == nil { return fail(errors.New("native output ended before its source state")) } @@ -853,7 +865,7 @@ func (session *Session) currentStream() *nativecapture.Stream { func captureProfileFailure(ctx context.Context, profile nativecapture.VideoProfile, stage string, err error) error { slog.DebugContext(ctx, "screener-client", "event", "capture-profile-rejected", "stage", stage, - "width", profile.Width, "height", profile.Height, "fps", profile.Framerate, "bitrate", profile.Bitrate) + "width", profile.Width, "height", profile.Height, "fps", profile.Framerate, "bitrate", profile.Bitrate, diagnostics.Error(err)) return err } @@ -908,6 +920,10 @@ func waitForCaptureProfile( send(result{stage: "status-invalid", err: err}) return } + slog.DebugContext(ctx, "screener-client", "event", "capture-profile-observed", "state", state.State, + "expectedCodec", codec, "actualCodec", state.Codec, "expectedProfile", profile, + "actualWidth", state.Width, "actualHeight", state.Height, "actualFPS", state.FPS, + "expectedOutputs", stream.Outputs(), "actualOutputs", state.Outputs) if !slices.Equal(state.Outputs, stream.Outputs()) { send(result{stage: "outputs-mismatch", err: errors.New("native capture outputs were not applied")}) return @@ -1020,10 +1036,17 @@ func (session *Session) runAudio() { session.mu.Lock() paused := session.paused session.mu.Unlock() - if paused || session.audioSource.WritePCM(frame.Data, frame.Duration) == nil { + if paused { + continue + } + err = session.audioSource.WritePCM(frame.Data, frame.Duration) + if err == nil { continue } } + slog.DebugContext(session.ctx, "screener-client", "event", "capture-audio-stream-ended", + "share", diagnostics.ID(session.shareID), "frameKind", frame.Kind, "eof", errors.Is(err, io.EOF), + "canceled", session.ctx.Err() != nil, diagnostics.Error(err)) // Audio failure leaves video live. Only an explicit source replacement // changes this owner; its buffered wake also covers an earlier commit. for session.currentAudioStream() == current { diff --git a/internal/client/nativehost/session_test.go b/internal/client/nativehost/session_test.go index bd29a36..88806a9 100644 --- a/internal/client/nativehost/session_test.go +++ b/internal/client/nativehost/session_test.go @@ -277,12 +277,12 @@ func TestCandidateForRetiredEdgeIsIgnored(t *testing.T) { } } -func TestCaptureProfileFailureLogsFixedStageWithoutPrivateError(t *testing.T) { +func TestCaptureProfileFailurePreservesCauseWithoutCredentials(t *testing.T) { previous := slog.Default() t.Cleanup(func() { slog.SetDefault(previous) }) var output bytes.Buffer slog.SetDefault(slog.New(slog.NewJSONHandler(&output, &slog.HandlerOptions{Level: slog.LevelDebug}))) - failure := errors.New("private capture path and window title") + failure := errors.New("profile failed: hresult=0x887a0005 token=private-capture-token") profile := nativecapture.VideoProfile{Width: 1280, Height: 720, Framerate: 30, Bitrate: 3_000_000} if captureProfileFailure(t.Context(), profile, "wait-timeout", failure) != failure { t.Fatal("diagnostic changed the failure result") @@ -293,7 +293,8 @@ func TestCaptureProfileFailureLogsFixedStageWithoutPrivateError(t *testing.T) { } if record["event"] != "capture-profile-rejected" || record["stage"] != "wait-timeout" || record["width"] != float64(1280) || record["height"] != float64(720) || - record["fps"] != float64(30) || record["bitrate"] != float64(3_000_000) || strings.Contains(output.String(), "private") { + record["fps"] != float64(30) || record["bitrate"] != float64(3_000_000) || + !strings.Contains(output.String(), "0x887a0005") || strings.Contains(output.String(), "private-capture-token") { t.Fatalf("profile diagnostic changed or leaked: %s", output.String()) } } diff --git a/internal/client/portmapping/mapping.go b/internal/client/portmapping/mapping.go index b366685..f176112 100644 --- a/internal/client/portmapping/mapping.go +++ b/internal/client/portmapping/mapping.go @@ -2,9 +2,11 @@ package portmapping import ( "context" + "log/slog" "sync" "time" + "github.com/TNTcraftHIM/Screener/internal/diagnostics" nat "github.com/netbirdio/go-nat" ) @@ -95,14 +97,18 @@ func (mapping *Mapping) Close() { } ctx, cancel := context.WithTimeout(context.Background(), deleteTimeout) defer cancel() - _ = gateway.DeletePortMapping(ctx, "udp", mapping.localPort) + err := gateway.DeletePortMapping(ctx, "udp", mapping.localPort) + slog.Debug("nat-mapping", "event", "released", "localPort", mapping.localPort, diagnostics.Error(err)) } func (mapping *Mapping) discover(parent context.Context) { defer close(mapping.ready) ctx, cancel := context.WithTimeout(parent, attemptTimeout) defer cancel() + started := time.Now() gateway, err := discoverGateway(ctx) + slog.Debug("nat-mapping", "event", "discovery", "localPort", mapping.localPort, + "durationMs", time.Since(started).Milliseconds(), "available", err == nil, diagnostics.Error(err)) if err != nil { return } @@ -119,6 +125,8 @@ func (mapping *Mapping) mapPortLocked(ctx context.Context) { externalPort, err := mapping.gateway.AddPortMapping( ctx, "udp", mapping.localPort, "Screener", leaseDuration, ) + slog.Debug("nat-mapping", "event", "mapping-result", "localPort", mapping.localPort, + "externalPort", externalPort, "leaseSeconds", leaseDuration.Seconds(), diagnostics.Error(err)) if err != nil || externalPort < 1 || externalPort > 65535 { mapping.mapped = false mapping.externalPort = 0 diff --git a/internal/diagnostics/logging.go b/internal/diagnostics/logging.go new file mode 100644 index 0000000..94bfc74 --- /dev/null +++ b/internal/diagnostics/logging.go @@ -0,0 +1,221 @@ +package diagnostics + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "log/slog" + "regexp" + "strings" + "sync" + "syscall" + + "github.com/go-logr/logr" + medialog "github.com/livekit/protocol/logger" + "github.com/pion/logging" + "go.uber.org/zap/zapcore" +) + +const maxLineBytes = 64 << 10 + +var ( + privateKey = regexp.MustCompile(`(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)`) + bearer = regexp.MustCompile(`(?i)\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+`) + secretPair = regexp.MustCompile(`(?i)((?:[a-z_-]*(?:password|passwd|pwd|ufrag|credential|token|secret|authorization|cookie)|a=ice-(?:pwd|ufrag))["']?\s*[:=]\s*["']?)([^\s,"';}]+)`) + urlCredentials = regexp.MustCompile(`(?i)(https?|wss?)://[^\s/@]+:[^\s/@]+@`) + inviteFragment = regexp.MustCompile(`(?i)(https?://[^\s#"']+)#[^\s"']*`) + diagnosticID = regexp.MustCompile(`^id-[0-9a-f]{16}$`) + iceUsernameMismatch = regexp.MustCompile(`(?i)(username[^\r\n]*?expected\()[0-9a-f]+(\)\s+actual\()[0-9a-f]+(\))`) + iceUfragExtension = regexp.MustCompile(`(?i)(\bufrag\s+)[A-Za-z0-9+/=_-]+`) + iceUnlabelledCredentials = regexp.MustCompile(`(?i)((?:get(?: all)? TCP connections by ufrag|get port of UDPAddr from ListenUDPInPortRange):)[^\r\n]*`) + remoteTrackDump = regexp.MustCompile(`(?i)got new track:[^\r\n]*`) +) + +// SafeText preserves technical causes and system codes, not authentication material. +// It is deliberately not a promise to anonymize system paths or network addresses. +func SafeText(text string) string { + text = privateKey.ReplaceAllString(text, "[private key redacted]") + text = remoteTrackDump.ReplaceAllString(text, "got new track: [opaque object omitted; see RTP statistics]") + // Pinned Pion also reports ICE usernames as hex and unlabelled candidate extensions. + text = iceUsernameMismatch.ReplaceAllString(text, "${1}[redacted]${2}[redacted]${3}") + text = iceUnlabelledCredentials.ReplaceAllString(text, "${1} [redacted ICE credentials]") + text = iceUfragExtension.ReplaceAllString(text, "${1}[redacted]") + text = bearer.ReplaceAllString(text, "$1 [redacted]") + text = secretPair.ReplaceAllString(text, "${1}[redacted]") + text = urlCredentials.ReplaceAllString(text, "$1://[redacted]@") + return inviteFragment.ReplaceAllString(text, "$1#[redacted]") +} + +// ID permits matching one opaque media identity across separately exported reports. +func ID(value string) string { + if value == "" || diagnosticID.MatchString(value) { + return value + } + sum := sha256.Sum256([]byte(value)) + return fmt.Sprintf("id-%x", sum[:8]) +} + +func Error(err error) slog.Attr { + if err == nil { + return slog.Attr{} + } + fields := []any{"type", fmt.Sprintf("%T", err), "message", SafeText(err.Error())} + var code syscall.Errno + if errors.As(err, &code) { + fields = append(fields, "systemCode", uint64(code)) + } + return slog.Group("error", fields...) +} + +func privateField(key string) bool { + key = strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key)) + return strings.Contains(key, "password") || strings.Contains(key, "token") || + strings.Contains(key, "secret") || strings.Contains(key, "privatekey") || + key == "authorization" || key == "cookie" || key == "setcookie" || + strings.HasSuffix(key, "pwd") || strings.HasSuffix(key, "ufrag") || key == "sdp" || key == "credential" +} + +func identityField(key string) bool { + switch strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key)) { + case "peerid", "shareid", "connectionid", "trackid", "streamid", "subid", "publicationgeneration", "sid", "trackidentifier", "rtpstatsid", "rtcpeerid": + return true + } + return false +} + +func safeValue(key string, value any) any { + if privateField(key) { + return "[redacted]" + } + switch value := value.(type) { + case string: + if identityField(key) { + return ID(value) + } + return SafeText(value) + case map[string]any: + for name, field := range value { + value[name] = safeValue(name, field) + } + case []any: + for i, field := range value { + value[i] = safeValue("", field) + } + } + return value +} + +// ReplaceAttr applies the same secret policy to application and dependency records. +func ReplaceAttr(_ []string, attr slog.Attr) slog.Attr { + attr.Value = attr.Value.Resolve() + if privateField(attr.Key) { + return slog.String(attr.Key, "[redacted]") + } + if attr.Value.Kind() == slog.KindString { + return slog.Any(attr.Key, safeValue(attr.Key, attr.Value.String())) + } + if attr.Value.Kind() == slog.KindAny { + if err, ok := attr.Value.Any().(error); ok { + return slog.String(attr.Key, SafeText(err.Error())) + } + value := attr.Value.Any() + // LiveKit's RTP/BWE wrappers expose Zap fields, not JSON-visible struct fields. + switch object := value.(type) { + case zapcore.ObjectMarshaler: + encoder := zapcore.NewMapObjectEncoder() + if err := object.MarshalLogObject(encoder); err != nil { + return slog.String(attr.Key, "[diagnostic field failed: "+SafeText(err.Error())+"]") + } + value = encoder.Fields + case zapcore.ArrayMarshaler: + encoder := zapcore.NewMapObjectEncoder() + if err := encoder.AddArray("items", object); err != nil { + return slog.String(attr.Key, "[diagnostic field failed: "+SafeText(err.Error())+"]") + } + value = encoder.Fields["items"] + } + data, err := json.Marshal(value) + if err != nil { + return slog.String(attr.Key, "[diagnostic value unavailable]") + } + value = nil + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + if decoder.Decode(&value) == nil { + return slog.Any(attr.Key, safeValue(attr.Key, value)) + } + } + return attr +} + +// LineWriter turns dependency stderr into bounded records without writing on the TUI. +type LineWriter struct { + mu sync.Mutex + logger *slog.Logger + line []byte + omitted int + privateKey bool +} + +func Writer(scope string) *LineWriter { + return &LineWriter{logger: slog.Default().With("library", scope)} +} + +func (writer *LineWriter) Write(data []byte) (int, error) { + writer.mu.Lock() + defer writer.mu.Unlock() + for _, char := range data { + if char == '\n' { + writer.flush() + } else if len(writer.line) < maxLineBytes { + writer.line = append(writer.line, char) + } else { + writer.omitted++ + } + } + return len(data), nil +} + +func (writer *LineWriter) flush() { + line := strings.TrimSuffix(string(writer.line), "\r") + if strings.Contains(line, "-----BEGIN ") && strings.Contains(line, "PRIVATE KEY-----") { + writer.privateKey = true + } + if writer.privateKey { + writer.privateKey = !(strings.Contains(line, "-----END ") && strings.Contains(line, "PRIVATE KEY-----")) + line = "[private key redacted]" + } + if line != "" || writer.omitted > 0 { + writer.logger.Debug("dependency", "line", SafeText(line), "omittedBytes", writer.omitted) + } + writer.line = writer.line[:0] + writer.omitted = 0 +} + +func (writer *LineWriter) Close() error { + writer.mu.Lock() + defer writer.mu.Unlock() + writer.flush() + return nil +} + +func PionLoggerFactory(fields ...any) logging.LoggerFactory { + factory := logging.NewDefaultLoggerFactory() + if slog.Default().Enabled(context.Background(), slog.LevelDebug) { + factory.DefaultLogLevel = logging.LogLevelDebug + writer := Writer("pion") + writer.logger = writer.logger.With(fields...) + factory.Writer = writer + } + return factory +} + +func MediaLogger(scope string) medialog.Logger { + if !slog.Default().Enabled(context.Background(), slog.LevelDebug) { + return medialog.GetDiscardLogger() + } + return medialog.LogRLogger(logr.FromSlogHandler(slog.Default().With("library", "livekit", "scope", scope).Handler())) +} diff --git a/internal/diagnostics/recorder.go b/internal/diagnostics/recorder.go index 61654ba..93f4fbf 100644 --- a/internal/diagnostics/recorder.go +++ b/internal/diagnostics/recorder.go @@ -3,6 +3,8 @@ package diagnostics import ( "archive/zip" + "bytes" + "crypto/sha256" "encoding/json" "errors" "fmt" @@ -11,6 +13,7 @@ import ( "os" "path/filepath" "runtime" + "runtime/debug" "runtime/pprof" "sync" "time" @@ -25,6 +28,8 @@ type Recorder struct { component string revision string started time.Time + context map[string]any + closed bool } // Open appends to the component's current log, retaining one rotated backup. @@ -57,9 +62,10 @@ func Open(directory, component, revision string) (*Recorder, error) { if err != nil { return nil, errors.Join(err, file.Close()) } - log := &rotatingLog{file: file, path: path, size: info.Size()} + _, backupErr := os.Stat(path + ".1") + log := &rotatingLog{file: file, path: path, size: info.Size(), preexisting: info.Size() > 0 || backupErr == nil} return &Recorder{ - log: log, logger: slog.New(slog.NewJSONHandler(log, &slog.HandlerOptions{Level: slog.LevelDebug})), + log: log, logger: slog.New(slog.NewJSONHandler(log, &slog.HandlerOptions{Level: slog.LevelDebug, ReplaceAttr: ReplaceAttr})), component: component, revision: revision, started: time.Now(), }, nil } @@ -67,16 +73,54 @@ func Open(directory, component, revision string) (*Recorder, error) { func (r *Recorder) Logger() *slog.Logger { return r.logger } func (r *Recorder) LogPath() string { return r.log.path } -// Export samples this process without forcing GC or collecting raw memory, -// configuration, environment variables, media, or arbitrary directory contents. +// Context snapshots explicitly selected startup facts; it never reads environment files. +func (r *Recorder) Context(name string, value any) { + r.exportMu.Lock() + defer r.exportMu.Unlock() + if r.context == nil { + r.context = make(map[string]any) + } + r.context[name] = ReplaceAttr(nil, slog.Any(name, value)).Value.Any() +} + +// Binary records only the identity of an explicitly selected runtime executable. +func (r *Recorder) Binary(name, path string) { + if path == "" { + return + } + identity := map[string]any{"name": filepath.Base(path)} + file, err := os.Open(path) + if err == nil { + hash := sha256.New() + var size int64 + size, err = io.Copy(hash, file) + _ = file.Close() + if err == nil { + identity["bytes"], identity["sha256"] = size, fmt.Sprintf("%x", hash.Sum(nil)) + } + } + if err != nil { + identity["error"] = SafeText(err.Error()) + } + r.Context(name, identity) +} + +// Export retains useful evidence even when an optional collector fails. It never +// forces GC or collects raw process memory, media or arbitrary directory contents. func (r *Recorder) Export() (path string, returnedErr error) { r.exportMu.Lock() defer r.exportMu.Unlock() - logs, err := r.log.snapshot() - if err != nil { - return "", fmt.Errorf("snapshot diagnostics logs: %w", err) + if r.closed { + return "", os.ErrClosed } now := time.Now() + reportID := fmt.Sprintf("%d-%d", os.Getpid(), now.UnixNano()) + r.logger.Debug("diagnostic-report", "reportId", reportID) + logs, logErr := r.log.snapshot() + collectorErrors := []string{} + if logErr != nil { + collectorErrors = append(collectorErrors, SafeText(logErr.Error())) + } var memory runtime.MemStats runtime.ReadMemStats(&memory) file, err := os.CreateTemp(filepath.Dir(r.LogPath()), r.component+"-diagnostics-"+now.UTC().Format("20060102T150405Z")+"-*.zip") @@ -91,33 +135,33 @@ func (r *Recorder) Export() (path string, returnedErr error) { path = "" } }() - for _, log := range logs { - entry, err := archive.Create(log.name) + files := []string{} + add := func(name string, data []byte) error { + entry, err := archive.Create(name) if err != nil { - return "", err + return err } - if _, err := entry.Write(log.data); err != nil { + if _, err := entry.Write(data); err != nil { + return err + } + files = append(files, name) + return nil + } + for _, log := range logs { + if err := add(log.name, log.data); err != nil { return "", err } } - metadata := struct { - Component string `json:"component"` - Revision string `json:"revision"` - GOOS string `json:"goos"` - GOARCH string `json:"goarch"` - GoVersion string `json:"goVersion"` - CollectedAt time.Time `json:"collectedAt"` - UptimeSeconds float64 `json:"uptimeSeconds"` - }{r.component, r.revision, runtime.GOOS, runtime.GOARCH, runtime.Version(), now.UTC(), now.Sub(r.started).Seconds()} for _, item := range []struct { name string value any - }{{"metadata.json", metadata}, {"memory.json", memory}} { - entry, err := archive.Create(item.name) + }{{"context.json", r.context}, {"memory.json", memory}} { + data, err := json.MarshalIndent(item.value, "", " ") if err != nil { - return "", err + collectorErrors = append(collectorErrors, item.name+": "+SafeText(err.Error())) + continue } - if err := json.NewEncoder(entry).Encode(item.value); err != nil { + if err := add(item.name, data); err != nil { return "", err } } @@ -126,13 +170,43 @@ func (r *Recorder) Export() (path string, returnedErr error) { file string debug int }{{"heap", "heap.pprof", 0}, {"goroutine", "goroutines.txt", 1}} { - entry, err := archive.Create(profile.file) - if err != nil { + var data bytes.Buffer + if err := pprof.Lookup(profile.name).WriteTo(&boundedWriter{writer: &data, remaining: maxFileBytes}, profile.debug); err != nil { + collectorErrors = append(collectorErrors, profile.file+": "+SafeText(err.Error())) + continue + } + if err := add(profile.file, data.Bytes()); err != nil { return "", err } - if err := pprof.Lookup(profile.name).WriteTo(&boundedWriter{writer: entry, remaining: maxFileBytes}, profile.debug); err != nil { - return "", fmt.Errorf("write %s profile: %w", profile.name, err) - } + } + if err := add("README.txt", []byte("Screener diagnostic report / Screener 诊断报告\n\n"+ + "Local opt-in report. Review before sharing: technical identifiers, paths and network/system details may remain.\n"+ + "Credentials are filtered; raw media and process-memory contents are not collected. heap.pprof is an allocation profile.\n"+ + "metadata.json lists retained files and collector failures. Rotated logs are a bounded history, not the entire session.\n\n"+ + "本报告由本机主动导出。分享前请检查:可能含技术标识、路径和网络/系统信息。\n"+ + "凭据已过滤;不采集原始媒体或进程内存内容。heap.pprof 是分配统计。\n"+ + "metadata.json 记录文件及采集失败;轮转日志只保留有限历史。\n")); err != nil { + return "", err + } + retention := r.log.retention() + metadata := map[string]any{ + "component": r.component, "revision": r.revision, "goos": runtime.GOOS, + "goarch": runtime.GOARCH, "goVersion": runtime.Version(), "collectedAt": now.UTC(), + "startedAt": r.started.UTC(), "uptimeSeconds": now.Sub(r.started).Seconds(), + "reportId": reportID, "pid": os.Getpid(), "cpuCount": runtime.NumCPU(), + "gomaxprocs": runtime.GOMAXPROCS(0), "goroutineCount": runtime.NumGoroutine(), + "files": append(files, "metadata.json"), "collectorErrors": collectorErrors, + "partial": len(collectorErrors) > 0 || retention["earlierHistoryMayBeMissing"] == true, "logRetention": retention, + } + if build, ok := debug.ReadBuildInfo(); ok { + metadata["mainModule"], metadata["dependencies"] = build.Main, build.Deps + } + data, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return "", err + } + if err := add("metadata.json", data); err != nil { + return "", err } return file.Name(), nil } @@ -141,6 +215,7 @@ func (r *Recorder) Export() (path string, returnedErr error) { func (r *Recorder) Close() error { r.exportMu.Lock() defer r.exportMu.Unlock() + r.closed = true r.log.mu.Lock() defer r.log.mu.Unlock() if r.log.file != nil { @@ -151,11 +226,13 @@ func (r *Recorder) Close() error { } type rotatingLog struct { - mu sync.Mutex - file *os.File - path string - size int64 - err error + mu sync.Mutex + file *os.File + path string + size int64 + err error + rotations uint64 + preexisting bool } func (l *rotatingLog) Write(data []byte) (n int, err error) { @@ -197,9 +274,20 @@ func (l *rotatingLog) rotate() error { } l.file, err = os.OpenFile(l.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) l.size = 0 + if err == nil { + l.rotations++ + } return err } +func (l *rotatingLog) retention() map[string]any { + l.mu.Lock() + defer l.mu.Unlock() + return map[string]any{"fileBytesLimit": maxFileBytes, "fileCountLimit": 2, + "rotationsThisProcess": l.rotations, "preexistingHistory": l.preexisting, + "earlierHistoryMayBeMissing": l.preexisting || l.rotations > 0} +} + type logSnapshot struct { name string data []byte @@ -208,38 +296,37 @@ type logSnapshot struct { func (l *rotatingLog) snapshot() ([]logSnapshot, error) { l.mu.Lock() defer l.mu.Unlock() - if l.err != nil { - return nil, l.err - } - if l.file == nil { - return nil, os.ErrClosed - } - if err := l.file.Sync(); err != nil { - return nil, err + issues := []error{l.err} + if l.file != nil { + issues = append(issues, l.file.Sync()) } // ponytail: at most 16 MiB copied under the log lock; compression runs outside it. var logs []logSnapshot for _, path := range []string{l.path + ".1", l.path} { if err := checkLogFile(path); err != nil { - return nil, err + issues = append(issues, err) + continue } file, err := os.Open(path) if errors.Is(err, os.ErrNotExist) && path != l.path { continue } if err != nil { - return nil, err + issues = append(issues, err) + continue } data, readErr := io.ReadAll(io.LimitReader(file, maxFileBytes+1)) if err := errors.Join(readErr, file.Close()); err != nil { - return nil, err + issues = append(issues, err) + continue } if len(data) > maxFileBytes { - return nil, errors.New("diagnostics log exceeds 8 MiB") + issues = append(issues, errors.New("diagnostics log exceeds 8 MiB")) + continue } logs = append(logs, logSnapshot{filepath.Base(path), data}) } - return logs, nil + return logs, errors.Join(issues...) } func checkLogFile(path string) error { diff --git a/internal/diagnostics/recorder_test.go b/internal/diagnostics/recorder_test.go index 2b7851e..41e9707 100644 --- a/internal/diagnostics/recorder_test.go +++ b/internal/diagnostics/recorder_test.go @@ -6,12 +6,16 @@ import ( "compress/gzip" "encoding/json" "errors" + "fmt" + "go.uber.org/zap/zapcore" "io" + "log/slog" "os" "path/filepath" "runtime" "strings" "sync" + "syscall" "testing" ) @@ -52,7 +56,7 @@ func TestRecorderRotationAndExport(t *testing.T) { t.Fatal(err) } defer archive.Close() - expected := map[string]bool{"client.log": false, "client.log.1": false, "metadata.json": false, "memory.json": false, "heap.pprof": false, "goroutines.txt": false} + expected := map[string]bool{"client.log": false, "client.log.1": false, "metadata.json": false, "memory.json": false, "heap.pprof": false, "goroutines.txt": false, "context.json": false, "README.txt": false} for _, file := range archive.File { if _, ok := expected[file.Name]; !ok { t.Fatalf("unexpected archive entry: %s", file.Name) @@ -79,7 +83,7 @@ func TestRecorderRotationAndExport(t *testing.T) { if err := json.Unmarshal(data, &metadata); err != nil { t.Fatal(err) } - if metadata["revision"] != "test-revision" || metadata["component"] != "client" || metadata["goos"] != runtime.GOOS || len(metadata) != 7 { + if metadata["revision"] != "test-revision" || metadata["component"] != "client" || metadata["goos"] != runtime.GOOS || metadata["reportId"] == "" || metadata["partial"] != true { t.Fatalf("unexpected metadata: %v", metadata) } case "memory.json": @@ -114,6 +118,15 @@ func TestRecorderRotationAndExport(t *testing.T) { if err != nil || second == archivePath { t.Fatalf("export must have a unique path: %q, %v", second, err) } + _ = recorder.Close() + reopened, err := Open(directory, "client", "next-process") + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + if reopened.log.retention()["earlierHistoryMayBeMissing"] != true { + t.Fatal("restart incorrectly claimed complete prior history") + } } func TestRecorderCloseAndFailures(t *testing.T) { @@ -172,8 +185,26 @@ func TestRecorderCloseAndFailures(t *testing.T) { t.Fatal(err) } failing.Logger().Error("write fails") - if _, err := failing.Export(); !errors.Is(err, os.ErrClosed) { - t.Fatalf("export hid a log write failure: %v", err) + partial, err := failing.Export() + if err != nil { + t.Fatalf("one failed log writer discarded usable runtime evidence: %v", err) + } + archive, err := zip.OpenReader(partial) + if err != nil { + t.Fatal(err) + } + defer archive.Close() + entry, err := archive.Open("metadata.json") + if err != nil { + t.Fatal(err) + } + var metadata map[string]any + if err = json.NewDecoder(entry).Decode(&metadata); err != nil { + t.Fatal(err) + } + _ = entry.Close() + if metadata["partial"] != true || len(metadata["collectorErrors"].([]any)) == 0 { + t.Fatalf("partial export hid failed collection: %v", metadata) } if err := failing.Close(); !errors.Is(err, os.ErrClosed) { t.Fatalf("close hid a log write failure: %v", err) @@ -182,3 +213,54 @@ func TestRecorderCloseAndFailures(t *testing.T) { t.Fatalf("profile output limit: %v", err) } } + +func TestDependencyDiagnosticsPreserveCausesWithoutSecrets(t *testing.T) { + recorder, err := Open(t.TempDir(), "client", "test") + if err != nil { + t.Fatal(err) + } + defer recorder.Close() + previous := slog.Default() + slog.SetDefault(recorder.Logger()) + defer slog.SetDefault(previous) + const secret = "must-not-appear-in-report" + recorder.Logger().Error("operation", Error(fmt.Errorf("interface enumeration: %w", syscall.EAFNOSUPPORT)), + "requestId", "request-17", "nested", map[string]any{"instanceToken": secret, "state": "checking"}) + PionLoggerFactory().NewLogger("ice").Debugf("Started agent: remoteUfrag: %q, remotePwd: %q", secret, secret) + PionLoggerFactory().NewLogger("ice").Warnf("mismatch username expected(%x) actual(%x)", secret, secret) + PionLoggerFactory().NewLogger("pc").Errorf("dropping candidate with ufrag %s because it doesn't match", secret) + PionLoggerFactory().NewLogger("ice").Warnf("Failed to get TCP connections by ufrag: tcp4 127.0.0.1:1 %s", secret) + PionLoggerFactory().NewLogger("pc").Debugf("got new track: &{id:%s streamID:private-track-identity peeked:[1 2]}", secret) + writer := Writer("capture") + for _, part := range []string{"stage=device-open\nAuthorization: Bear", "er " + secret + "\n", + "-----BEGIN PRIVATE KEY-----\n", secret + "\n-----END PRIVATE KEY-----\n", + "https://example.test/r/1234#v=" + secret + "\n", "hresult=0x80070005"} { + if _, err := writer.Write([]byte(part)); err != nil { + t.Fatal(err) + } + } + _ = writer.Close() + MediaLogger("test").Warnw("media initialization", fmt.Errorf("network adapter unavailable"), "trackId", "private-track-identity", "stats", diagnosticObject{value: secret}) + data, err := os.ReadFile(recorder.LogPath()) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{secret, fmt.Sprintf("%x", secret), "private-track-identity"} { + if bytes.Contains(data, []byte(forbidden)) { + t.Fatalf("sensitive value retained: %q", forbidden) + } + } + for _, useful := range []string{"request-17", "interface enumeration", "systemCode", "device-open", "0x80070005", "network adapter unavailable", "checking", `"jitter":12`} { + if !bytes.Contains(data, []byte(useful)) { + t.Fatalf("missing diagnostic context: %q", useful) + } + } +} + +type diagnosticObject struct{ value string } + +func (object diagnosticObject) MarshalLogObject(encoder zapcore.ObjectEncoder) error { + encoder.AddInt("jitter", 12) + encoder.AddString("password", object.value) + return nil +} diff --git a/internal/media/forwarding/publication.go b/internal/media/forwarding/publication.go index f2ac8a6..c638bd6 100644 --- a/internal/media/forwarding/publication.go +++ b/internal/media/forwarding/publication.go @@ -1,14 +1,17 @@ package forwarding import ( + "context" "crypto/rand" "errors" "io" + "log/slog" "strconv" "sync" "sync/atomic" "time" + "github.com/TNTcraftHIM/Screener/internal/diagnostics" "github.com/livekit/livekit-server/pkg/sfu" "github.com/livekit/livekit-server/pkg/sfu/buffer" "github.com/livekit/livekit-server/pkg/sfu/bwe" @@ -17,7 +20,6 @@ import ( "github.com/livekit/livekit-server/pkg/sfu/pacer" "github.com/livekit/livekit-server/pkg/sfu/streamallocator" "github.com/livekit/protocol/livekit" - "github.com/livekit/protocol/logger" "github.com/pion/interceptor" "github.com/pion/rtcp" "github.com/pion/rtp" @@ -59,7 +61,7 @@ func NewPublication(options TransportOptions) (_ *Publication, err error) { _ = publication.Close() } }() - log := logger.GetDiscardLogger() + log := diagnostics.MediaLogger("publication").WithValues("connectionId", diagnostics.ID(options.ConnectionID)) publication.bandwidth = &publicationBWE{SendSideBWE: sendsidebwe.NewSendSideBWE(sendsidebwe.SendSideBWEParams{ Config: sendsidebwe.DefaultSendSideBWEConfig, Logger: log, })} @@ -101,6 +103,9 @@ func NewPublication(options TransportOptions) (_ *Publication, err error) { } factory := buffer.NewFactoryOfBufferFactory(options.Source.maxPackets, buffer.InitPacketBufferSizeAudio).CreateBufferFactory() settings := options.Settings + if slog.Default().Enabled(context.Background(), slog.LevelDebug) { + settings.LoggerFactory = diagnostics.PionLoggerFactory("connectionId", diagnostics.ID(options.ConnectionID)) + } settings.BufferFactory = func(kind packetio.BufferPacketType, ssrc uint32) io.ReadWriteCloser { if kind == packetio.RTCPBufferPacket { if reader := factory.GetRTCPReader(ssrc); reader != nil { @@ -350,9 +355,15 @@ func (publication *Publication) Close() error { return publication.closeErr } -func (*Publication) OnBindAndConnected() {} -func (*Publication) OnStatsUpdate(*livekit.AnalyticsStat) {} -func (*Publication) OnMaxSubscribedLayerChanged(int32) {} +func (*Publication) OnBindAndConnected() {} +func (publication *Publication) OnStatsUpdate(stats *livekit.AnalyticsStat) { + if slog.Default().Enabled(context.Background(), slog.LevelDebug) { + slog.Debug("media-stats", "scope", "publication", "rtcPeerId", diagnostics.ID(publication.PC.ID()), + "rtp", stats, "egress", publication.pacer.stats(), "targetBitrate", publication.bandwidth.target.Load(), + "pendingPackets", publication.pacer.pendingCount(), "packetCapacity", publication.pacer.limit) + } +} +func (*Publication) OnMaxSubscribedLayerChanged(int32) {} func (publication *Publication) OnRttUpdate(rtt uint32) { publication.bandwidth.UpdateRTT(float64(rtt) / 1000) } diff --git a/internal/media/forwarding/source.go b/internal/media/forwarding/source.go index bcd2039..3f0b1f2 100644 --- a/internal/media/forwarding/source.go +++ b/internal/media/forwarding/source.go @@ -9,12 +9,12 @@ import ( "sync/atomic" "time" + "github.com/TNTcraftHIM/Screener/internal/diagnostics" "github.com/TNTcraftHIM/Screener/internal/media/encoded" "github.com/livekit/livekit-server/pkg/sfu" "github.com/livekit/livekit-server/pkg/sfu/buffer" "github.com/livekit/mediatransportutil" "github.com/livekit/protocol/livekit" - "github.com/livekit/protocol/logger" "github.com/livekit/protocol/utils" "github.com/livekit/protocol/utils/mono" "github.com/pion/rtcp" @@ -82,7 +82,7 @@ func NewSource(options SourceOptions) (*Source, error) { ReceiverBase: sfu.NewReceiverBase(sfu.ReceiverBaseParams{ TrackID: livekit.TrackID(options.ID), StreamID: options.StreamID, Kind: webrtc.RTPCodecTypeVideo, Codec: options.Codec, - HeaderExtensions: canonicalHeaderExtensions(options.HeaderExtensions), Logger: logger.GetDiscardLogger(), + HeaderExtensions: canonicalHeaderExtensions(options.HeaderExtensions), Logger: diagnostics.MediaLogger("source").WithValues("trackId", diagnostics.ID(options.ID)), StreamTrackerManagerConfig: sfu.DefaultStreamTrackerManagerConfig, }, info, sfu.ReceiverCodecStateNormal), buffers: make([]*buffer.Buffer, len(options.Formats)), @@ -209,7 +209,7 @@ func (source *Source) BindLayer(layer int, ssrc uint32, parameters webrtc.RTPPar } } input := buffer.NewBuffer(ssrc, source.maxPackets, buffer.InitPacketBufferSizeAudio) - input.SetLogger(logger.GetDiscardLogger()) + input.SetLogger(diagnostics.MediaLogger("rtp-buffer").WithValues("trackId", diagnostics.ID(string(source.TrackID())))) if err := input.Bind(parameters, codec.RTPCodecCapability, int(source.TrackInfo().Layers[layer].Bitrate)); err != nil { _ = input.Close() return err diff --git a/internal/media/forwarding/transport.go b/internal/media/forwarding/transport.go index e78f64f..d0b1941 100644 --- a/internal/media/forwarding/transport.go +++ b/internal/media/forwarding/transport.go @@ -1,13 +1,16 @@ package forwarding import ( + "context" "errors" "io" + "log/slog" "slices" "sync" "sync/atomic" "time" + "github.com/TNTcraftHIM/Screener/internal/diagnostics" "github.com/livekit/livekit-server/pkg/sfu" "github.com/livekit/livekit-server/pkg/sfu/buffer" "github.com/livekit/livekit-server/pkg/sfu/bwe" @@ -90,6 +93,9 @@ func NewTransport(options TransportOptions) (_ *Transport, err error) { } factory := buffer.NewFactoryOfBufferFactory(options.Source.maxPackets, buffer.InitPacketBufferSizeAudio).CreateBufferFactory() settings := options.Settings + if slog.Default().Enabled(context.Background(), slog.LevelDebug) { + settings.LoggerFactory = diagnostics.PionLoggerFactory("connectionId", diagnostics.ID(options.ConnectionID)) + } settings.BufferFactory = func(kind packetio.BufferPacketType, ssrc uint32) io.ReadWriteCloser { if kind == packetio.RTCPBufferPacket { if reader := factory.GetRTCPReader(ssrc); reader != nil { @@ -122,7 +128,9 @@ func NewTransport(options TransportOptions) (_ *Transport, err error) { if err != nil { return nil, err } - log := logger.GetDiscardLogger() + log := diagnostics.MediaLogger("transport").WithValues("connectionId", diagnostics.ID(options.ConnectionID)) + log.Debugw("transport-created", "rtcPeerId", diagnostics.ID(transport.PC.ID()), "codec", codec.MimeType, + "initialBitrate", options.InitialBitrate, "packetCapacity", options.Source.maxPackets) transport.estimator = sendsidebwe.NewSendSideBWE(sendsidebwe.SendSideBWEParams{ Config: sendsidebwe.DefaultSendSideBWEConfig, Logger: log, }) @@ -370,8 +378,14 @@ func (*Transport) BWEType() bwe.BWEType func (*Transport) IsSubscribeMutable(*sfu.DownTrack) bool { return true } func (transport *Transport) OnSubscriptionChanged(*sfu.DownTrack) { transport.Output.Reconcile() } func (*Transport) OnBindAndConnected() {} -func (*Transport) OnStatsUpdate(*livekit.AnalyticsStat) {} -func (*Transport) OnMaxSubscribedLayerChanged(int32) {} +func (transport *Transport) OnStatsUpdate(stats *livekit.AnalyticsStat) { + if slog.Default().Enabled(context.Background(), slog.LevelDebug) { + slog.Debug("media-stats", "scope", "forwarding", "rtcPeerId", diagnostics.ID(transport.PC.ID()), + "rtp", stats, "egress", transport.Egress(), "targetBitrate", transport.target.Load(), + "pendingPackets", transport.pacer.pendingCount(), "packetCapacity", transport.pacer.limit) + } +} +func (*Transport) OnMaxSubscribedLayerChanged(int32) {} func (transport *Transport) OnRttUpdate(rtt uint32) { transport.mu.Lock() defer transport.mu.Unlock() @@ -603,6 +617,12 @@ func (queue *boundedPacer) stats() EgressStats { return EgressStats{Packets: queue.packets.Load(), RTPBytes: queue.bytes.Load(), DroppedPackets: queue.dropped.Load()} } +func (queue *boundedPacer) pendingCount() int { + queue.mu.Lock() + defer queue.mu.Unlock() + return len(queue.pending) +} + type pacedPacket struct { owner *boundedPacer packet *pacer.Packet diff --git a/internal/server/app/app.go b/internal/server/app/app.go index 0b406d7..1bd2424 100644 --- a/internal/server/app/app.go +++ b/internal/server/app/app.go @@ -30,6 +30,7 @@ import ( "sync/atomic" "time" + "github.com/TNTcraftHIM/Screener/internal/diagnostics" "github.com/TNTcraftHIM/Screener/internal/server/config" "github.com/TNTcraftHIM/Screener/internal/server/room" "github.com/TNTcraftHIM/Screener/internal/server/sfu" @@ -265,8 +266,9 @@ func (s *Server) start(ctx context.Context) (int, error) { _ = s.stopServing(ctx) return 0, fmt.Errorf("Screener SFU listener failed: %w", listenErr) } - s.mediaMux = ice.NewUDPMuxDefault(ice.UDPMuxParams{UDPConn: connection}) - settings := webrtc.SettingEngine{} + factory := diagnostics.PionLoggerFactory() + s.mediaMux = ice.NewUDPMuxDefault(ice.UDPMuxParams{UDPConn: connection, Logger: factory.NewLogger("sfu-udp")}) + settings := webrtc.SettingEngine{LoggerFactory: factory} settings.SetNetworkTypes([]webrtc.NetworkType{webrtc.NetworkTypeUDP4}) settings.SetICEUDPMux(s.mediaMux) settings.SetIncludeLoopbackCandidate(net.ParseIP(configuration.ListenHost).IsLoopback()) @@ -290,6 +292,7 @@ func (s *Server) start(ctx context.Context) (int, error) { // causes; report it rather than serving nothing in silence. if err := s.httpServer.Serve(listener); !errors.Is(err, http.ErrServerClosed) { s.logger.Error("Screener HTTP server stopped unexpectedly", "errorType", fmt.Sprintf("%T", err)) + s.logger.Debug("http-listener-failed", diagnostics.Error(err)) } }() port := listener.Addr().(*net.TCPAddr).Port diff --git a/native/capture/windows/adaptive_encoder.cpp b/native/capture/windows/adaptive_encoder.cpp index f848379..f8a0b0b 100644 --- a/native/capture/windows/adaptive_encoder.cpp +++ b/native/capture/windows/adaptive_encoder.cpp @@ -5,8 +5,10 @@ #include #include #include +#include #include #include +#include #include #include "api/environment/environment_factory.h" @@ -347,8 +349,8 @@ class AdaptiveEncoder::Impl final : public webrtc::VideoSourceInterface initial) - : kind_(kind), ceiling_(ceiling), device_(device), create_(std::move(create)), + Factory create, std::unique_ptr initial, int output_index) + : kind_(kind), ceiling_(ceiling), output_index_(output_index), device_(device), create_(std::move(create)), initial_(std::move(initial)), env_(webrtc::CreateEnvironment()), readback_(device, failure_), builtin_(webrtc::CreateBuiltinVideoEncoderFactory()), allocator_(webrtc::CreateBuiltinVideoBitrateAllocatorFactory()), @@ -480,7 +482,7 @@ class AdaptiveEncoder::Impl final : public webrtc::VideoSourceInterface lock(output_mutex_); return std::exchange(output_, std::nullopt); @@ -550,8 +552,57 @@ class AdaptiveEncoder::Impl final : public webrtc::VideoSourceInterfaceGetStats(); + std::osyncstream line(std::cerr); + line << "event=encoder-stats output=" << output_index_ + << " codec=" << (kind_ == OutputKind::h264 ? "h264" : "vp8") + << " ceilingWidth=" << ceiling_.width << " ceilingHeight=" << ceiling_.height + << " ceilingFps=" << ceiling_.frame_rate << " ceilingBitrate=" << ceiling_.bit_rate + << " inputFps=" << stats.input_frame_rate << " encodeFps=" << stats.encode_frame_rate + << " inputFrames=" << stats.frames << " encodedFrames=" << stats.frames_encoded + << " encodeTimeMs=" << stats.total_encode_time_ms + << " averageEncodeTimeMs=" << stats.avg_encode_time_ms + << " encodeUsagePercent=" << stats.encode_usage_percent + << " targetBitrate=" << stats.target_media_bitrate_bps + << " encodedBitrate=" << stats.media_bitrate_bps + << " droppedCapturer=" << stats.frames_dropped_by_capturer + << " droppedTimestamp=" << stats.frames_dropped_by_bad_timestamp + << " droppedQueue=" << stats.frames_dropped_by_encoder_queue + << " droppedRateLimiter=" << stats.frames_dropped_by_rate_limiter + << " droppedCongestionWindow=" << stats.frames_dropped_by_congestion_window + << " droppedEncoder=" << stats.frames_dropped_by_encoder + << " cpuResolutionLimited=" << stats.cpu_limited_resolution + << " cpuFpsLimited=" << stats.cpu_limited_framerate + << " bandwidthResolutionLimited=" << stats.bw_limited_resolution + << " bandwidthFpsLimited=" << stats.bw_limited_framerate + << " cpuAdaptations=" << stats.number_of_cpu_adapt_changes + << " qualityAdaptations=" << stats.number_of_quality_adapt_changes + << " resolutionChanges=" << stats.quality_limitation_resolution_changes + << " suspended=" << stats.suspended; + for (const auto& [ssrc, stream] : stats.substreams) { + // This adapter has one local observer stream; no network identity is exported. + (void)ssrc; + line << " outputWidth=" << stream.width << " outputHeight=" << stream.height + << " outputFrames=" << stream.frames_encoded; + if (stream.qp_sum) line << " qpSum=" << *stream.qp_sum; + } + line << '\n'; + } + const OutputKind kind_; const VideoProfile ceiling_; + const int output_index_; + const bool debug_ = [] { + wchar_t value[2]{}; + return GetEnvironmentVariableW(L"SCREENER_CAPTURE_DEBUG", value, 2) == 1 && value[0] == L'1'; + }(); + std::optional last_stats_ms_; ComPtr device_; Factory create_; std::unique_ptr initial_; @@ -576,7 +627,7 @@ class AdaptiveEncoder::Impl final : public webrtc::VideoSourceInterface initial) { + Factory create, std::unique_ptr initial, int output_index) { if ((kind != OutputKind::vp8 && kind != OutputKind::h264) || !device || (kind == OutputKind::h264 && !create) || ceiling.width == 0 || ceiling.height == 0 || (ceiling.width & 1) || (ceiling.height & 1) || @@ -584,7 +635,7 @@ AdaptiveEncoder::AdaptiveEncoder(OutputKind kind, VideoProfile ceiling, ID3D11De ceiling.frame_rate == 0 || ceiling.frame_rate > 1'000 || ceiling.bit_rate < 1'000 || ceiling.bit_rate > static_cast(std::numeric_limits::max())) Fail("adaptive-profile", "Invalid adaptive encoder profile"); - impl_ = std::make_unique(kind, ceiling, device, std::move(create), std::move(initial)); + impl_ = std::make_unique(kind, ceiling, device, std::move(create), std::move(initial), output_index); impl_->Start(); } diff --git a/native/capture/windows/adaptive_encoder.h b/native/capture/windows/adaptive_encoder.h index ca4d1f6..1bc389a 100644 --- a/native/capture/windows/adaptive_encoder.h +++ b/native/capture/windows/adaptive_encoder.h @@ -21,7 +21,8 @@ class AdaptiveEncoder final { using FrameProducer = std::function(UINT32, UINT32)>; AdaptiveEncoder(OutputKind kind, VideoProfile ceiling, ID3D11Device* device, - Factory create, std::unique_ptr initial = nullptr); + Factory create, std::unique_ptr initial = nullptr, + int output_index = -1); ~AdaptiveEncoder(); AdaptiveEncoder(const AdaptiveEncoder&) = delete; AdaptiveEncoder& operator=(const AdaptiveEncoder&) = delete; diff --git a/native/capture/windows/main.cpp b/native/capture/windows/main.cpp index d8d0b42..22d0dda 100644 --- a/native/capture/windows/main.cpp +++ b/native/capture/windows/main.cpp @@ -1354,7 +1354,7 @@ class OutputWorker final { } if (!encoder) { encoder = std::make_unique( - kind_, profile_, device_.Get(), create_, std::move(initial)); + kind_, profile_, device_.Get(), create_, std::move(initial), layer_); } auto output = encoder->Encode([&](UINT32 width, UINT32 height) { if (!converter || converted_width != width || converted_height != height) { diff --git a/scripts/browser-local-pool-page.js b/scripts/browser-local-pool-page.js new file mode 100644 index 0000000..51beff9 --- /dev/null +++ b/scripts/browser-local-pool-page.js @@ -0,0 +1,375 @@ +// Actual HostPeer/BrowserEncodingPool comparison; synthetic source and local receivers only. +import { BrowserEncodingPool as ProbePool } from '../src/client/media/browser-encoding-pool'; +import { HostPeer as ProbeHostPeer } from '../src/client/webrtc/host-peer'; +import { applyVideoCaptureProfile as probeApplyProfile } from '../src/client/media/quality'; +import { browserDebugEnabled, exportBrowserDebug } from '../src/client/lib/debug'; + +const settings = new URLSearchParams(location.search); +const mode = settings.get('mode') || 'carrier', codec = settings.get('codec') || 'VP8'; +const network = settings.get('network') === '1', automatic = settings.get('automatic') === '1'; +const high = settings.get('high') === '1', av = settings.get('av') === '1'; +const single = settings.get('single') === '1', late = settings.get('late') === '1'; +const nativeSource = settings.get('nativeSource') === '1', background = settings.get('background') === '1'; +const lifecycle = settings.get('lifecycle') === '1', shapedRate = Number(settings.get('rate') || 120000); +const relay = settings.get('relay') === '1'; +const selectedProfile = () => ({ resolution: high ? '1080p' : '480p', maxFramerate: 30, + maxBitrate: high ? 5000000 : 1700000, + degradationPreference: settings.get('preference') || (high ? 'maintain-resolution' : 'balanced'), + screenAudioQuality: 'music' }); +const productPool = mode === 'carrier' ? new ProbePool() : null; +const productPeers = [], peers = [], tracks = [], edges = [], monitors = [], audioMonitors = []; +const errors = [], sourcePulses = [], controls = [], visibility = [], productTrace = []; +const sourceTimes = new Map(), seenProductGroups = new Map(); +let rawSource, drawing, productTraceTimer, audioContext, audioTrack, audioGain, upstreamPeer; +let producing = true, sourceFrame = 0, phase = 'startup'; +const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); +const poolState = () => productPool ? { + members: [...productPool.members].map(m => ({ id: m.id, group: m.current?.producer.id, + pending: m.pending?.group.producer.id, selecting: m.pending?.selecting, budget: m.budget, + mixed: m.mixed, disabled: m.disabled, carrier: m.carrier, replacing: !!m.replacement, + output: m.output, queued: m.encoded.current?.frames.length, + waitingKey: m.encoded.current?.needKey, pendingFrames: m.encoded.pending?.queue.frames.length })), + groups: [...productPool.groups].map(g => ({ id: g.producer.id, budget: g.budget, + ready: g.ready, failed: g.failed, output: g.output })) +} : null; +window.probeProgress = () => ({ phase, sourceFrame, errors, pool: poolState(), + peers: peers.map(p => ({ connection: p.connectionState, ice: p.iceConnectionState })), + audio: audioMonitors.map(m => ({ role: m.role, attached: m.attached, pulses: m.pulses.length })) }); + +function watch(video, role) { + const canvas = document.createElement('canvas'); + canvas.width = 640; canvas.height = 360; + const context = canvas.getContext('2d', { willReadFrequently: true }), samples = []; + let stopped = false; + const next = () => video.requestVideoFrameCallback((_time, metadata) => { + if (stopped) return; + if (nativeSource) { + samples.push({ at: performance.now(), phase, id: metadata.presentedFrames, age: null, + width: video.videoWidth, height: video.videoHeight }); + } else { + try { + context.drawImage(video, 0, 0, 640, 360); + const pixels = context.getImageData(0, 0, 640, 30).data; + let id = 0; + for (let bit = 0; bit < 12; bit++) { + const index = (15 * 640 + 20 + bit * 50) * 4; + if (pixels[index] + pixels[index + 1] + pixels[index + 2] > 384) id |= 1 << bit; + } + const now = performance.now(), created = sourceTimes.get(id); + samples.push({ at: now, phase, id, age: created === undefined ? null : now - created, + width: video.videoWidth, height: video.videoHeight }); + } catch (error) { errors.push(String(error.message)); } + } + next(); + }); + next(); + monitors.push({ role, samples, stop: () => { stopped = true; } }); +} + +function watchAudio(video, role) { + // Observe element-level played audio, not the earlier receiver track or physical speakers. + const played = video.captureStream(); + const monitor = { role, pulses: [], attached: false, active: false, + stop() { this.active = false; played.getTracks().forEach(t => t.stop()); } }; + audioMonitors.push(monitor); + const attach = () => { + if (monitor.attached || !played.getAudioTracks().length) return; + monitor.attached = monitor.active = true; + const source = audioContext.createMediaStreamSource(played), analyser = audioContext.createAnalyser(); + analyser.fftSize = 512; + const silent = audioContext.createGain(); + silent.gain.value = 0; + source.connect(analyser).connect(silent).connect(audioContext.destination); + const values = new Float32Array(analyser.fftSize); + let sounded = false; + const sample = () => { + if (!monitor.active) { source.disconnect(); analyser.disconnect(); silent.disconnect(); return; } + analyser.getFloatTimeDomainData(values); + const loud = values.some(value => Math.abs(value) > .01); + if (loud && !sounded) monitor.pulses.push({ at: performance.now(), phase }); + sounded = loud; + requestAnimationFrame(sample); + }; + sample(); + }; + played.addEventListener('addtrack', attach); + attach(); +} + +async function pair(role) { + const receive = new RTCPeerConnection({ iceServers: [] }), pending = []; + peers.push(receive); + const stream = new MediaStream([rawSource, ...(audioTrack ? [audioTrack] : [])]); + let peer; + peer = new ProbeHostPeer(role, { iceServers: [] }, stream, selectedProfile(), { + sendSignal(_id, payload) { + if (payload.kind === 'candidate' && !(network && role === 'A')) { + if (receive.remoteDescription) receive.addIceCandidate(payload.candidate).catch(error => errors.push(String(error))); + else pending.push(payload.candidate); + } + return true; + }, + onUpdate(snapshot) { if (snapshot.error) errors.push(snapshot.error); } + }, { primary: codec.toLowerCase(), vp8Fallback: false }, undefined, false, role === 'U' ? null : productPool); + if (role === 'U') upstreamPeer = peer; + else productPeers.push(peer); + peers.push(peer.connection); + const video = document.createElement('video'), received = new MediaStream(); + video.autoplay = video.muted = video.playsInline = true; + video.width = 320; video.srcObject = received; + if (role !== 'U') document.body.append(video); + receive.ontrack = event => { received.addTrack(event.track); video.play().catch(() => {}); }; + if (av && role !== 'U') video.addEventListener('loadeddata', () => watchAudio(video, role), { once: true }); + if (!(network && role === 'A')) receive.onicecandidate = event => { + if (event.candidate) void peer.acceptSignal({ kind: 'candidate', + connectionId: peer.connectionId, candidate: event.candidate.toJSON() }); + }; + if (!await peer.start()) throw Error('Product HostPeer failed to start'); + const edge = { role, peer, send: peer.connection, receive, sender: peer.videoSender, pending, received }; + edges.push(edge); + if (role !== 'U') watch(video, role); + return edge; +} + +async function connect(edge) { + if (network && edge.role === 'A') { + const gathered = async pc => { + const end = performance.now() + 5000; + while (pc.iceGatheringState !== 'complete' && performance.now() < end) await wait(20); + }; + const firstPort = sdp => Number(sdp.match(/^a=candidate:\S+ 1 udp \d+ \S+ (\d+)/mi)?.[1]); + const proxySDP = (description, port) => ({ ...description, sdp: description.sdp.replace(/^a=candidate:.*\r?\n/gm, '') + .replace(/^a=mid:[^\r\n]+\r?$/gm, line => line.trimEnd() + '\r\na=candidate:proxy 1 udp 2130706431 127.0.0.1 ' + port + ' typ host\r') }); + const proxy = await fetch('/shaper').then(r => r.json()); + await gathered(edge.send); + const offer = { type: 'offer', sdp: edge.send.localDescription.sdp }; + if (!offer.sdp.includes('a=end-of-candidates')) offer.sdp += 'a=end-of-candidates\r\n'; + await edge.receive.setRemoteDescription(proxySDP(offer, proxy.ports.right)); + await edge.receive.setLocalDescription(await edge.receive.createAnswer()); + await gathered(edge.receive); + const answer = { type: 'answer', sdp: edge.receive.localDescription.sdp }; + if (!answer.sdp.includes('a=end-of-candidates')) answer.sdp += 'a=end-of-candidates\r\n'; + await fetch('/shaper?left=' + firstPort(offer.sdp) + '&right=' + firstPort(answer.sdp)); + await edge.peer.acceptSignal({ kind: 'description', connectionId: edge.peer.connectionId, + description: proxySDP(answer, proxy.ports.left) }); + } else { + await edge.receive.setRemoteDescription(edge.send.localDescription); + for (const candidate of edge.pending.splice(0)) await edge.receive.addIceCandidate(candidate); + await edge.receive.setLocalDescription(await edge.receive.createAnswer()); + await edge.peer.acceptSignal({ kind: 'description', connectionId: edge.peer.connectionId, + description: edge.receive.localDescription }); + } + const deadline = performance.now() + 6000; + while (edge.send.connectionState !== 'connected' && performance.now() < deadline) await wait(30); + if (edge.send.connectionState !== 'connected') throw Error('Connection deadline'); +} + +const selectReport = report => Array.from(report.values()) + .filter(r => ['outbound-rtp', 'inbound-rtp', 'remote-inbound-rtp', 'remote-outbound-rtp', + 'candidate-pair', 'transport', 'local-candidate', 'remote-candidate'].includes(r.type)) + .map(r => { + const fields = ['type', 'kind', 'id', 'localCandidateId', 'remoteCandidateId', 'selectedCandidatePairId', + 'port', 'nominated', 'framesEncoded', 'framesDecoded', 'framesSent', 'framesReceived', 'bytesSent', + 'bytesReceived', 'totalEncodeTime', 'totalDecodeTime', 'frameWidth', 'frameHeight', 'framesPerSecond', + 'targetBitrate', 'qualityLimitationReason', 'qpSum', 'pliCount', 'nackCount', 'retransmittedBytesSent', + 'packetsLost', 'jitter', 'jitterBufferDelay', 'jitterBufferEmittedCount', 'freezeCount', + 'totalFreezesDuration', 'reportsSent', 'remoteTimestamp', 'currentRoundTripTime', 'availableOutgoingBitrate', 'state']; + return Object.fromEntries(fields.filter(key => r[key] !== undefined).map(key => [key, r[key]])); + }); +async function statistics() { + const result = {}; + for (const edge of edges) { + const metrics = edge.peer.getSnapshot().metrics; + result[edge.role] = { send: selectReport(await edge.send.getStats()), + receive: selectReport(await edge.receive.getStats()), + details: Object.fromEntries(['frameWidth', 'frameHeight', 'framesPerSecond', 'bitrateKbps', + 'qualityLimitationReason', 'nativeEdgeQualityState', 'intervalFramesSent', 'intervalFramesEncoded'] + .map(key => [key, metrics[key]])) }; + } + if (productPool) { + for (const group of productPool.groups) if (!seenProductGroups.has(group.producer.id)) + seenProductGroups.set(group.producer.id, { name: 'G' + seenProductGroups.size, group }); + for (const entry of seenProductGroups.values()) { + const { name, group } = entry; + const report = await group.producer.report().catch(() => entry.report ?? group.report); + if (report) { + entry.report = report; + result[name] = { send: selectReport(report), receive: [], retired: !productPool.groups.has(group) }; + } + } + } + return result; +} +async function snapshot() { + return { phase, at: performance.now(), stats: await statistics(), + process: await fetch('/process').then(r => r.json()), + ...(network ? { shaper: await fetch('/shaper').then(r => r.json()) } : {}) }; +} + +async function run() { + if (av) { + audioContext = new AudioContext({ sampleRate: 48000 }); + await audioContext.resume(); + const destination = audioContext.createMediaStreamDestination(), tone = audioContext.createOscillator(); + tone.frequency.value = 1000; + audioGain = audioContext.createGain(); audioGain.gain.value = 0; + tone.connect(audioGain).connect(destination); tone.start(); + audioTrack = destination.stream.getAudioTracks()[0]; tracks.push(audioTrack); + } + const source = document.createElement('canvas'); + source.width = high ? 1920 : 640; source.height = high ? 1080 : 360; + if (!nativeSource) document.body.prepend(source); + const ctx = source.getContext('2d'); + if (high) ctx.scale(3, 3); + rawSource = nativeSource ? (await navigator.mediaDevices.getUserMedia({ + video: { width: source.width, height: source.height, frameRate: 30 }, audio: false + })).getVideoTracks()[0] : source.captureStream(0).getVideoTracks()[0]; + rawSource.contentHint = 'motion'; tracks.push(rawSource); + await probeApplyProfile(rawSource, selectedProfile()); + const main = rawSource; + const connectChildren = async () => { + for (const role of single ? ['A'] : ['A', 'B']) await pair(role); + for (const edge of edges) if (edge.role !== 'U' && (!late || edge.role !== 'B')) await connect(edge); + }; + if (!relay) await connectChildren(); + if (!nativeSource) drawing = setInterval(() => { + if (!producing) return; + const id = sourceFrame++ % 4096; + sourceTimes.set(id, performance.now()); + if (audioGain) { + audioGain.gain.setValueAtTime(id % 60 < 6 ? .1 : 0, audioContext.currentTime); + if (id % 60 === 0) sourcePulses.push({ id, at: performance.now() }); + } + ctx.fillStyle = '#172b3a'; ctx.fillRect(0, 0, 640, 360); + ctx.fillStyle = '#48c98b'; ctx.fillRect((sourceFrame * 11) % 540, 65, 100, 220); + ctx.fillStyle = '#f7b744'; ctx.font = '40px sans-serif'; ctx.fillText(String(id), 30, 335); + for (let bit = 0; bit < 12; bit++) { + ctx.fillStyle = id & (1 << bit) ? '#fff' : '#000'; ctx.fillRect(bit * 50, 0, 50, 30); + } + (relay ? main : rawSource).requestFrame(); + }, 1000 / (lifecycle ? 60 : 30)); + if (productPool) productTraceTimer = setInterval(() => productTrace.push({ at: performance.now(), phase, ...poolState() }), 500); + if (relay) { + const upstream = await pair('U'); + await connect(upstream); + rawSource = upstream.received.getVideoTracks()[0]; + audioTrack = upstream.received.getAudioTracks()[0]; + if (!rawSource || av && !audioTrack) throw Error('Relay upstream did not supply its tracks'); + rawSource.contentHint = 'motion'; + await connectChildren(); + } + if (late) { await wait(1000); await connect(edges.find(e => e.role === 'B')); } + await wait(1900); + if (productPool) { + const deadline = performance.now() + 12000; + let activated = null; + for (;;) { + const members = [...productPool.members]; + const sharing = members.length === productPeers.length && members.every(m => m.current && m.carrier && !m.replacement) && + new Set(members.map(m => m.current)).size === 1; + if (sharing) activated ??= performance.now(); + else activated = null; + if (activated !== null && monitors.every(m => { + const last = m.samples.at(-1); + return last?.width > 16 && (nativeSource ? last.at >= activated : + last.age !== null && (sourceTimes.get(last.id) ?? -1) >= activated); + })) break; + if (performance.now() >= deadline) throw Error('Product shared output did not become fresh'); + await wait(30); + } + } + const descriptions = edges.map(e => ({ role: e.role, + extensions: e.send.localDescription?.sdp.match(/^a=extmap:.*$/gm), + parameters: e.sender.getParameters().encodings })); + const snapshots = [await snapshot()]; + phase = 'healthy'; await wait(6000); snapshots.push(await snapshot()); + if (lifecycle) { + let appliedProfile = selectedProfile(); + const apply = async profile => { + const at = performance.now(); + await probeApplyProfile(rawSource, profile); + const applied = await Promise.all(productPeers.map(peer => peer.updateCaptureProfile(profile))); + controls.push({ phase, profile, elapsedMs: performance.now() - at, applied }); + if (!applied.every(Boolean)) throw Error('Product profile update failed'); + appliedProfile = profile; + }; + const observe = async (name, duration = 6000) => { + phase = name; await wait(duration); snapshots.push(await snapshot()); + if (name.startsWith('quiet-') || name.startsWith('paused-')) return; + for (const monitor of monitors) { + const last = monitor.samples.at(-1), ceiling = { '480p': 480, '720p': 720, '1080p': 1080 }[appliedProfile.resolution]; + if (last?.phase !== name || last.age === null || last.age >= 1000 || + performance.now() - last.at >= 1000 || last.height > ceiling) + throw Error(name + ': receiver did not deliver current frames within the requested ceiling'); + } + }; + const p60 = { ...selectedProfile(), resolution: '720p', maxFramerate: 60, maxBitrate: 5000000 }; + phase = 'profile-720p60'; await apply(p60); await observe(phase); + producing = false; phase = 'quiet-profile-480p30'; + await apply({ ...p60, resolution: '480p', maxFramerate: 30, maxBitrate: 3000000 }); + await observe(phase, 2500); + producing = true; await observe('resumed-480p30'); + rawSource.enabled = false; productPeers.forEach(peer => peer.setPaused(true)); + phase = 'paused-profile-720p30'; await apply({ ...p60, maxFramerate: 30 }); await observe(phase, 2000); + rawSource.enabled = true; productPeers.forEach(peer => peer.setPaused(false)); + await observe('resumed-720p30'); + const oldSource = rawSource; + rawSource = source.captureStream(0).getVideoTracks()[0]; + rawSource.contentHint = 'motion'; tracks.push(rawSource); + await probeApplyProfile(rawSource, appliedProfile); + const stream = new MediaStream([rawSource, ...(audioTrack ? [audioTrack] : [])]); + const replaced = await Promise.all(productPeers.map(peer => peer.replaceStream(stream))); + controls.push({ action: 'source-replaced', replaced }); + if (!replaced.every(Boolean)) throw Error('Product source replacement failed'); + oldSource.stop(); await observe('replaced-source'); + if (productPool && [...productPool.groups].some(group => group.source !== rawSource)) throw Error('Old source group survived replacement'); + if (productPool) { + for (const group of [...productPool.groups]) group.producer.fail(); + await observe('ordinary-after-producer-failure'); + if (productPool.groups.size || productPeers.some(peer => !peer.isConnected())) throw Error('Ordinary recovery did not retire the pool'); + } + } + if (background) { + phase = 'hidden'; await fetch('/visibility?hidden=1'); + visibility.push({ at: performance.now(), requested: 'hidden', actual: document.visibilityState }); + if (document.visibilityState !== 'hidden') throw Error('Page was not actually hidden'); + await wait(10000); snapshots.push(await snapshot()); + phase = 'visible-again'; await fetch('/visibility?hidden=0'); + visibility.push({ at: performance.now(), requested: 'visible', actual: document.visibilityState }); + if (document.visibilityState !== 'visible') throw Error('Page was not actually restored'); + await wait(6000); snapshots.push(await snapshot()); + } + if (network) { + await fetch('/shaper?rate=' + shapedRate); + phase = 'limited'; await wait(14000); snapshots.push(await snapshot()); + await fetch('/shaper?rate=0'); + // --auto extends recovery observation; adaptation always belongs to the product. + phase = 'released'; await wait(automatic ? 40000 : 10000); snapshots.push(await snapshot()); + } + return { descriptions, snapshots, browser: navigator.userAgent, sourcePulses, + audioMonitors: audioMonitors.map(({ role, pulses, attached }) => ({ role, pulses, attached })), + monitors: monitors.map(({ role, samples }) => ({ role, samples })) }; +} +const configuration = { mode, codec, product: true, network, automatic, av, high, single, late, + background, nativeSource, lifecycle, relay, shapedRate, profile: selectedProfile() }; +try { + window.probeResult = { ...configuration, ...await run(), controls, visibility, productTrace, errors }; + if (browserDebugEnabled) { + const report = JSON.parse(await exportBrowserDebug()); + window.probeResult.debug = report; + if (productPool && (!report.events.some(e => e.scope === 'encoding-pool' && e.event === 'sample') || + !report.events.some(e => e.scope === 'webrtc' && e.event === 'stats' && e.details.role === 'pool-producer'))) + throw Error('Debug report omitted the pool or producer evidence'); + } +} catch (error) { + window.probeResult = { ...configuration, error: String(error?.stack || error?.message || error), + progress: window.probeProgress(), controls, visibility, productTrace, errors }; +} finally { + clearInterval(drawing); clearInterval(productTraceTimer); + monitors.forEach(m => m.stop()); audioMonitors.forEach(m => m.stop()); + productPeers.forEach(peer => peer.dispose()); productPool?.dispose(); + upstreamPeer?.dispose(); + tracks.forEach(t => t.stop()); peers.forEach(pc => pc.close()); + await audioContext?.close(); + window.probeDone = true; +} diff --git a/scripts/browser-local-pool-probe.ts b/scripts/browser-local-pool-probe.ts new file mode 100644 index 0000000..c3e4cf8 --- /dev/null +++ b/scripts/browser-local-pool-probe.ts @@ -0,0 +1,105 @@ +import { createServer } from 'node:http'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { build } from 'esbuild'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { CdpConnection, cleanupRun, createPage, evaluate, launchChrome, reservePort, waitForSample, waitForVersion } from './browser-gate-harness'; +import { createPoolShaper } from './browser-pool-shaper.mjs'; + +const mode = process.argv[2] || 'carrier', args = process.argv.slice(3); +const flags = new Set(['--product', '--1080', '--h264', '--single', '--av', '--late', '--background', + '--native-source', '--lifecycle', '--network', '--auto', '--relay', '--debug']); +if (!['ordinary', 'carrier'].includes(mode)) throw Error('Expected ordinary|carrier'); +for (const arg of args) if (!flags.has(arg) && !arg.startsWith('--rate=') && !arg.startsWith('--preference=')) + throw Error('Unsupported option: ' + arg); +const codec = args.includes('--h264') ? 'H264' : 'VP8', network = args.includes('--network'); +const automatic = args.includes('--auto'), high = args.includes('--1080'), av = args.includes('--av'); +const single = args.includes('--single'), late = args.includes('--late'), background = args.includes('--background'); +const nativeSource = background || args.includes('--native-source'), lifecycle = args.includes('--lifecycle'); +const relay = args.includes('--relay'); +const rate = Number(args.find(arg => arg.startsWith('--rate='))?.split('=')[1] || 120000); +const preference = args.find(arg => arg.startsWith('--preference='))?.split('=')[1] || (high ? 'maintain-resolution' : 'balanced'); +if (!['balanced', 'maintain-resolution', 'maintain-framerate'].includes(preference)) throw Error('Unsupported degradation preference'); +if (!Number.isInteger(rate) || rate <= 0) throw Error('Rate must be a positive integer in bps'); +if (lifecycle && (nativeSource || network || relay)) throw Error('Lifecycle uses direct canvas capture without network shaping'); +if (single && late) throw Error('Single-viewer control cannot also late-join B'); +const root = resolve(import.meta.dirname, '..'); +const bundle = await build({ entryPoints: [join(root, 'scripts/browser-local-pool-page.js')], + bundle: true, write: false, format: 'esm', platform: 'browser', logLevel: 'error' }); +const body = Buffer.from(bundle.outputFiles[0].contents); +let cdp: CdpConnection | null = null; +let page: Awaited> | undefined; +let originalTarget: string | undefined, backgroundTarget: string | undefined; +const shaper = network ? await createPoolShaper() : null; +const server = createServer((request, response) => { + const url = new URL(request.url!, 'http://127.0.0.1'); + if (url.pathname === '/visibility' && cdp && page) { + void (async () => { + originalTarget ??= (await cdp!.call<{ targetInfo: { targetId: string } }>( + 'Target.getTargetInfo', {}, page!.sessionId, Date.now() + 3000)).targetInfo.targetId; + backgroundTarget ??= (await cdp!.call<{ targetId: string }>( + 'Target.createTarget', { url: 'about:blank', background: true }, undefined, Date.now() + 3000)).targetId; + await cdp!.call('Target.activateTarget', { targetId: url.searchParams.get('hidden') === '1' ? backgroundTarget : originalTarget }, + undefined, Date.now() + 3000); + response.end('{}'); + })().catch(() => { response.statusCode = 500; response.end('{}'); }); + return; + } + if (url.pathname === '/process' && cdp) { + void cdp.call<{ processInfo: Array<{ cpuTime: number }> }>('SystemInfo.getProcessInfo', {}, undefined, Date.now() + 3000) + .then(info => response.end(JSON.stringify({ cpuSeconds: info.processInfo.reduce((sum, p) => sum + p.cpuTime, 0) })), + () => response.end('{}')); + return; + } + if (url.pathname === '/shaper' && shaper) { + if (url.searchParams.has('left')) shaper.setTargets(Number(url.searchParams.get('left')), Number(url.searchParams.get('right'))); + if (url.searchParams.has('rate')) shaper.setRate(Number(url.searchParams.get('rate'))); + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify({ ports: shaper.ports, stats: shaper.stats() })); + return; + } + response.setHeader('Content-Type', url.pathname === '/probe.js' ? 'text/javascript' : 'text/html'); + response.end(url.pathname === '/probe.js' ? body : + 'Browser local pool'); +}); +await new Promise(r => server.listen(0, '127.0.0.1', r)); +const port = (server.address() as { port: number }).port, debugPort = await reservePort(); +const profile = await mkdtemp(join(tmpdir(), 'screener-client-media-')); +const chrome = launchChrome(process.env.CHROME_PATH || 'C:/Program Files/Google/Chrome/Application/chrome.exe', debugPort, profile, + ['--headless=new', '--no-first-run', '--autoplay-policy=no-user-gesture-required', + ...(background ? [] : ['--disable-background-timer-throttling', '--disable-renderer-backgrounding']), + ...(nativeSource ? ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'] : [])]); +chrome.stdout.resume(); chrome.stderr.resume(); +let result: Record = { mode, codec, product: true, network, automatic, high, av, single, late, + background, nativeSource, lifecycle, relay, shapedRate: rate, preference }; +try { + const version = await waitForVersion(debugPort, chrome); + cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, Date.now() + 5000); + page = await createPage(cdp, 'about:blank'); + const query = new URLSearchParams({ mode, codec, rate: String(rate), preference }); + if (process.argv.includes('--debug')) query.set('debug', '1'); + for (const [key, value] of Object.entries({ network, automatic, high, av, single, late, background, nativeSource, lifecycle, relay })) + query.set(key, value ? '1' : '0'); + await cdp.call('Page.navigate', { url: 'http://127.0.0.1:' + port + '/?' + query }, page.sessionId, Date.now() + 5000); + await waitForSample(() => evaluate(cdp!, page!, 'window.probeDone===true', Date.now() + 2000), + Boolean, automatic || lifecycle ? 110000 : network || background ? 60000 : 25000); + result = await evaluate>(cdp, page, 'window.probeResult', Date.now() + 3000); +} catch (error) { + result.error = String(error); + if (cdp && page) result.partial = await evaluate(cdp, page, 'window.probeResult || window.probeProgress?.()', Date.now() + 3000).catch(() => null); +} finally { + const clean = await cleanupRun({ cdp, native: null, chrome, + server: { close: () => new Promise((resolve, reject) => server.close(e => e ? reject(e) : resolve())) }, + profile, ports: [port, debugPort] }); + if (shaper) { result.shaper = shaper.stats(); await shaper.close(); } + result.probeSha256 = createHash('sha256').update(body).digest('hex'); + result.cleanup = clean; + await mkdir(join(root, 'build/browser-local-pool'), { recursive: true }); + const name = [mode, codec.toLowerCase(), network && 'network', automatic && 'auto', high && '1080', + single && 'single', late && 'late', background && 'background', lifecycle && 'lifecycle', relay && 'relay', Date.now()].filter(Boolean).join('-'); + const output = join(root, 'build/browser-local-pool', name + '.json'); + await writeFile(output, JSON.stringify(result, null, 2) + '\n'); + console.log(JSON.stringify({ mode, codec, network, error: result.error ?? null, output, cleanup: clean })); + if (result.error || (result.errors as string[] | undefined)?.length || Object.values(clean).some(v => !v)) process.exitCode = 1; +} diff --git a/scripts/browser-local-pool-summary.mjs b/scripts/browser-local-pool-summary.mjs new file mode 100644 index 0000000..edf212e --- /dev/null +++ b/scripts/browser-local-pool-summary.mjs @@ -0,0 +1,63 @@ +import { readFile } from 'node:fs/promises'; +for (const filename of process.argv.slice(2)) { + const result = JSON.parse(await readFile(filename, 'utf8')); + const completed = Array.isArray(result.snapshots) && !result.error && !result.errors?.length && + result.cleanup && Object.values(result.cleanup).every(Boolean); + const configuration = { file: filename, probeSha256: result.probeSha256, mode: result.mode, codec: result.codec, + product: result.product, high: result.high, single: result.single, late: result.late, network: result.network, + automatic: result.automatic, av: result.av, background: result.background, nativeSource: result.nativeSource, + lifecycle: result.lifecycle, relay: result.relay, shapedRate: result.shapedRate, profile: result.profile, + completed: !!completed, error: result.error, errors: result.errors, cleanup: result.cleanup }; + if (!completed) process.exitCode = 1; + if (!result.snapshots) { + console.log(JSON.stringify({ ...configuration, progress: result.progress, partial: result.partial })); + continue; + } + const phases = []; + for (let i = 1; i < result.snapshots.length; i++) { + const prev = result.snapshots[i - 1], next = result.snapshots[i], seconds = (next.at - prev.at) / 1000; + const row = { phase: next.phase, seconds, browserCpuSeconds: next.process && prev.process ? next.process.cpuSeconds - prev.process.cpuSeconds : null, edges: {}, viewers: {} }; + for (const role of Object.keys(next.stats)) { + const before = prev.stats[role], after = next.stats[role]; + const outbound = s => s?.send.find(r => r.type === 'outbound-rtp' && r.kind === 'video') || {}; + const inbound = s => s?.receive.find(r => r.type === 'inbound-rtp' && r.kind === 'video') || {}; + const a = outbound(before), b = outbound(after), c = inbound(before), d = inbound(after); + row.edges[role] = { encoded: (b.framesEncoded || 0) - (a.framesEncoded || 0), encodeMs: 1000 * ((b.totalEncodeTime || 0) - (a.totalEncodeTime || 0)), + decoded: (d.framesDecoded || 0) - (c.framesDecoded || 0), decodeMs: 1000 * ((d.totalDecodeTime || 0) - (c.totalDecodeTime || 0)), + sentFrames: (b.framesSent || 0) - (a.framesSent || 0), kbps: 8 * ((b.bytesSent || 0) - (a.bytesSent || 0)) / seconds / 1000, + output: [b.frameWidth, b.frameHeight], receive: [d.frameWidth, d.frameHeight], reason: b.qualityLimitationReason, target: b.targetBitrate, + retired: after.retired ?? false, details: after.details, + meanQp: b.qpSum !== undefined && b.framesEncoded > (a.framesEncoded || 0) ? (b.qpSum - (a.qpSum || 0)) / (b.framesEncoded - (a.framesEncoded || 0)) : null, + senderReports: after.receive.filter(r => r.type === 'remote-outbound-rtp').reduce((s, r) => s + (r.reportsSent || 0), 0) - (before?.receive || []).filter(r => r.type === 'remote-outbound-rtp').reduce((s, r) => s + (r.reportsSent || 0), 0), pli: (d.pliCount || 0) - (c.pliCount || 0) }; + } + for (const monitor of result.monitors) { + const samples = monitor.samples.filter(s => s.phase === next.phase), ages = samples.filter(s => s.age !== null).map(s => s.age).sort((a, b) => a - b); + let backwards = 0, duplicates = 0, maxGapMs = samples[0] ? samples[0].at - prev.at : seconds * 1000; + for (let j = 1; j < samples.length; j++) { + maxGapMs = Math.max(maxGapMs, samples[j].at - samples[j - 1].at); + if (samples[j].id < samples[j - 1].id) + backwards++; + if (samples[j].id === samples[j - 1].id) + duplicates++; + } + if (samples.length) maxGapMs = Math.max(maxGapMs, next.at - samples.at(-1).at); + row.viewers[monitor.role] = { rendered: samples.length, fresh: result.nativeSource ? null : samples.filter(s => s.age !== null && s.age < 1000).length, backwards, duplicates, maxGapMs, + ageP50: ages[Math.floor(ages.length * .5)] ?? null, ageP95: ages[Math.floor(ages.length * .95)] ?? null }; + if (result.av) { + const sound = result.audioMonitors.find(m => m.role === monitor.role); + const offsets = []; + let missed = 0; + for (const pulse of result.sourcePulses.filter(p => p.at >= prev.at && p.at < next.at - 500)) { + const video = samples.find(s => s.id >= pulse.id && s.id < pulse.id + 6); + const audio = sound?.pulses.find(p => p.at >= pulse.at && p.at < pulse.at + 1500); + if (video && audio) offsets.push(video.at - audio.at); + else missed++; + } + row.viewers[monitor.role].av = { attached: sound?.attached ?? false, videoMinusAudioMs: offsets, missed }; + } + } + phases.push(row); + } + console.log(JSON.stringify({ ...configuration, phases, controls: result.controls, visibility: result.visibility, + shaper: result.shaper ?? null })); +} diff --git a/scripts/browser-pool-shaper.mjs b/scripts/browser-pool-shaper.mjs new file mode 100644 index 0000000..a61be88 --- /dev/null +++ b/scripts/browser-pool-shaper.mjs @@ -0,0 +1,66 @@ +import { createSocket } from 'node:dgram'; +// Test-only UDP bridge. Both ICE descriptions point at it, so the recorded +// packet counters prove the tested media path crossed the shaping boundary. +export async function createPoolShaper() { + const left = createSocket('udp4'), right = createSocket('udp4'); + await Promise.all([left, right].map(s => new Promise(r => s.bind(0, '127.0.0.1', r)))); + let targetLeft = 0, targetRight = 0, rate = 0, next = 0, closed = false, timer = null; + const queue = []; + const counters = { forwardedBytes: 0, droppedBytes: 0, packets: 0, reverseBytes: 0, maxQueueBytes: 0 }; + let queuedBytes = 0; + function flush() { + timer = null; + if (closed) + return; + const now = performance.now(); + while (queue.length && queue[0].at <= now) { + const packet = queue.shift(); + queuedBytes -= packet.data.length; + right.send(packet.data, targetRight, '127.0.0.1'); + counters.forwardedBytes += packet.data.length; + counters.packets++; + } + if (queue.length) + timer = setTimeout(flush, Math.max(1, queue[0].at - performance.now())); + } + left.on('message', data => { + if (!targetRight || closed) + return; + if (!rate) { + right.send(data, targetRight, '127.0.0.1'); + counters.forwardedBytes += data.length; + counters.packets++; + return; + } + const now = performance.now(); + if (next - now > 250) { + counters.droppedBytes += data.length; + return; + } + next = Math.max(now, next) + data.length * 8 * 1000 / rate; + queue.push({ at: next, data }); + queuedBytes += data.length; + counters.maxQueueBytes = Math.max(counters.maxQueueBytes, queuedBytes); + if (!timer) + flush(); + }); + right.on('message', data => { if (targetLeft && !closed) { + left.send(data, targetLeft, '127.0.0.1'); + counters.reverseBytes += data.length; + } }); + return { + ports: { left: left.address().port, right: right.address().port }, + setTargets(a, b) { targetLeft = a; targetRight = b; }, + setRate(value) { rate = value; if (!rate) { + next = 0; + for (const item of queue) + item.at = 0; + if (timer) + clearTimeout(timer); + flush(); + } }, + stats() { return { ...counters, queuedBytes, rate }; }, + async close() { closed = true; if (timer) + clearTimeout(timer); queue.length = 0; await Promise.all([left, right].map(s => new Promise(r => s.close(r)))); } + }; +} diff --git a/scripts/client-native-host-gate.ts b/scripts/client-native-host-gate.ts index db5892d..19ff9fe 100644 --- a/scripts/client-native-host-gate.ts +++ b/scripts/client-native-host-gate.ts @@ -766,7 +766,7 @@ async function main(): Promise { stage = "host-page"; const host = await createPage( cdp, - "http://localhost:" + appPort + "/#" + hostBootstrap, + "http://localhost:" + appPort + "/?debug=1#" + hostBootstrap, CODEC_PROBE, true, ); diff --git a/src/client/components/living/Header.tsx b/src/client/components/living/Header.tsx index c2a0457..209ea61 100644 --- a/src/client/components/living/Header.tsx +++ b/src/client/components/living/Header.tsx @@ -1,11 +1,14 @@ // App header: brand mark, LED connection state, language-mode pill // (中文 / EN / 纯视觉), and theme toggle. +import { useState } from "react"; +import { browserDebugEnabled, debugError, downloadBrowserDebug } from "../../lib/debug"; import { VisGlyph } from "./primitives"; import { BrandMark } from "./BrandMark"; import { ComicTooltip } from "./ComicTooltip"; import type { ComicKind } from "./Comic"; import { useCopy, type Lang } from "../../ui/copy"; import { useTheme } from "../../ui/theme"; +import { Glyph } from "../../ui/icons"; export type LedState = "live" | "busy" | "warn" | "bad" | "off"; @@ -47,6 +50,7 @@ export function LedStrip({ export function HeaderControls() { const { lang, vis, t, setLang, setVis } = useCopy(); const { theme, toggle } = useTheme(); + const [debugExport, setDebugExport] = useState<"idle" | "busy" | "failed">("idle"); const option = (mode: Lang | "vis", label: string, tipKey: "mode.zh" | "mode.en" | "mode.vis") => { const active = mode === "vis" ? vis : !vis && lang === mode; return ( @@ -85,8 +89,33 @@ export function HeaderControls() { )} ); + const debugTitle = t(debugExport === "failed" ? "debug.exportFailed" : "debug.exportHint"); + const debugButton = ( + + ); return ( + {browserDebugEnabled && (vis ? ( + + {debugButton} + + ) : debugButton)} {option("zh", "中", "mode.zh")} {option("en", "EN", "mode.en")} diff --git a/src/client/components/living/hints/index.tsx b/src/client/components/living/hints/index.tsx index 521db10..14ea0a1 100644 --- a/src/client/components/living/hints/index.tsx +++ b/src/client/components/living/hints/index.tsx @@ -42,6 +42,7 @@ export type Set3Kind = | "hint-codec" | "hint-advanced" | "hint-details" + | "hint-debug-export" | "hint-more-metrics"; export type Set4Kind = @@ -82,6 +83,7 @@ export const HINT_KINDS: readonly HintKind[] = [ "hint-codec", "hint-advanced", "hint-details", + "hint-debug-export", "hint-more-metrics", "hint-topology", "hint-close", diff --git a/src/client/components/living/hints/set3.tsx b/src/client/components/living/hints/set3.tsx index 6004a4e..11eed63 100644 --- a/src/client/components/living/hints/set3.tsx +++ b/src/client/components/living/hints/set3.tsx @@ -401,6 +401,24 @@ ${rmBlock( ); +const hintDebugExport: HintScene = ({ theme }) => ( + <> + + + + + + + + + + + + + +); + export const SET3_SCENES: Record = { "hint-quality": hintQuality, "hint-audio-quality": hintAudioQuality, @@ -408,5 +426,6 @@ export const SET3_SCENES: Record = { "hint-codec": hintCodec, "hint-advanced": hintAdvanced, "hint-details": hintDetails, + "hint-debug-export": hintDebugExport, "hint-more-metrics": hintMoreMetrics, }; diff --git a/src/client/lib/debug-webrtc.ts b/src/client/lib/debug-webrtc.ts new file mode 100644 index 0000000..3ff3386 --- /dev/null +++ b/src/client/lib/debug-webrtc.ts @@ -0,0 +1,61 @@ +import { browserDebugEnabled, debugError, debugEvent } from "./debug"; + +const connections = new WeakMap(); +const statsTypes = new Set([ + "codec", "inbound-rtp", "outbound-rtp", "remote-inbound-rtp", "remote-outbound-rtp", + "media-source", "transport", "candidate-pair", "local-candidate", "remote-candidate", +]); + +// Observe existing connection owners and their existing statistics samples. +// These listeners add no polling or transport actions. +export function observeDebugConnection(connection: RTCPeerConnection, identity: object): void { + if (!browserDebugEnabled) return; + connections.set(connection, identity); + const transports = new Set(); + const snapshot = (event: string) => { + try { + debugEvent("webrtc", event, { ...identity, + connectionState: connection.connectionState, iceConnectionState: connection.iceConnectionState, + iceGatheringState: connection.iceGatheringState, signalingState: connection.signalingState }); + for (const endpoint of [...connection.getSenders(), ...connection.getReceivers()]) { + const transport = endpoint.transport; + if (!transport || transports.has(transport)) continue; + transports.add(transport); + const state = () => debugEvent("webrtc", "dtls-state", { ...identity, state: transport.state }); + transport.addEventListener("statechange", state); + state(); + } + } catch (error) { debugError("webrtc", "collector-failed", error, { ...identity, collector: "connection-state" }); } + }; + for (const event of ["connectionstatechange", "iceconnectionstatechange", "icegatheringstatechange", "signalingstatechange", "track"]) { + connection.addEventListener(event, () => snapshot(event)); + } + connection.addEventListener("icecandidateerror", (event) => { + debugEvent("webrtc", "ice-candidate-error", { ...identity, + errorCode: event.errorCode, errorText: event.errorText, address: event.address, port: event.port, url: event.url }); + }); + snapshot("created"); +} + +export function debugRtcStats(connection: RTCPeerConnection, report: RTCStatsReport): void { + if (!browserDebugEnabled) return; + const identity = connections.get(connection); + if (!identity) return; + const stats: object[] = []; + report.forEach((record) => { if (statsTypes.has(record.type)) stats.push(record); }); + debugEvent("webrtc", "stats", { ...identity, stats }); +} + +export function debugRtcFailure(connection: RTCPeerConnection, error: unknown): void { + if (browserDebugEnabled) debugError("webrtc", "collector-failed", error, + { ...connections.get(connection), collector: "getStats" }); +} + +export function debugTrack(track: MediaStreamTrack, details: object = {}): void { + if (!browserDebugEnabled) return; + try { + debugEvent("capture", "track", { ...details, trackId: track.id, kind: track.kind, + state: track.readyState, enabled: track.enabled, muted: track.muted, contentHint: track.contentHint, + settings: track.getSettings(), constraints: track.getConstraints() }); + } catch (error) { debugError("capture", "collector-failed", error, { collector: "track", trackId: track.id }); } +} diff --git a/src/client/lib/debug.ts b/src/client/lib/debug.ts index c3600dd..65828cc 100644 --- a/src/client/lib/debug.ts +++ b/src/client/lib/debug.ts @@ -1,153 +1,198 @@ -import { - SIGNALING_PROTOCOL, - type QualityResolution, -} from "../../shared/protocol"; - -interface DebugDetails { - role?: "host" | "viewer"; - type?: string; - state?: string; - generation?: number; - revision?: number; - code?: string | number; - count?: number; - reconnecting?: boolean; - native?: boolean; - resolution?: QualityResolution; - maxFramerate?: number; - maxBitrate?: number; - audio?: boolean; - errorName?: string; -} +import { SIGNALING_PROTOCOL } from "../../shared/protocol"; +type DebugValue = null | boolean | number | string | DebugValue[] | { [key: string]: DebugValue }; +type DebugDetails = object; interface BrowserDebugEvent { + sequence: number; at: string; + elapsedMs: number; scope: string; event: string; - details: DebugDetails; + details: { [key: string]: DebugValue }; + truncated?: boolean; } -export const browserDebugEnabled = - typeof window !== "undefined" && +export const browserDebugEnabled = typeof window !== "undefined" && new URLSearchParams(window.location.search).get("debug") === "1"; -const events: BrowserDebugEvent[] = []; -const fields = [ - "role", - "type", - "state", - "generation", - "revision", - "code", - "count", - "reconnecting", - "native", - "resolution", - "maxFramerate", - "maxBitrate", - "audio", - "errorName", -] as const satisfies readonly (keyof DebugDetails)[]; -const errorNames = new Set([ - "Error", - "TypeError", - "RangeError", - "SyntaxError", - "ReferenceError", - "EvalError", - "URIError", - "AbortError", - "NotAllowedError", - "NotFoundError", - "NotReadableError", - "NotSupportedError", - "OverconstrainedError", - "InvalidStateError", - "SecurityError", - "TimeoutError", - "OperationError", - "NetworkError", -]); +const MAX_EVENTS = 8_192; +const MAX_BYTES = 8 * 1024 * 1024; +const events: Array<{ record: BrowserDebugEvent; bytes: number }> = []; +const startedAt = new Date().toISOString(); +const started = performance.now(); +let sequence = 0, retainedBytes = 0, evictedEvents = 0, truncatedEvents = 0; +const secretKey = /(?:token|password|passwd|secret|credential|authorization|cookie|private.?key|ice.?pwd|ufrag|usernamefragment|sdp|grant)|^(?:candidate|title|displayName)$/i; -export function debugEvent( - scope: string, - event: string, - details: DebugDetails = {}, -): void { - if ( - !browserDebugEnabled || - !/^[a-z][a-z-]{0,47}$/.test(scope) || - !/^[a-z][a-z-]{0,47}$/.test(event) - ) - return; - const safe: Record = {}; - for (const key of fields) { - const value = details[key]; - if ( - (typeof value === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(value)) || - (typeof value === "number" && Number.isFinite(value)) || - typeof value === "boolean" - ) { - safe[key] = value; - } - } - const record: BrowserDebugEvent = { - at: new Date().toISOString(), - scope, - event, - details: safe, - }; - events.push(record); - if (events.length > 256) events.shift(); - console.info("[screener]", record); +function safeText(value: string): string { + return value + .replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?(?:-----END [^-]*PRIVATE KEY-----|$)/g, "[redacted private key]") + .replace(/\bv=0\r?\n[\s\S]*/g, "[redacted SDP]") + .replace(/\b(?:set-cookie|cookie|authorization)\s*:[^\r\n]*/gi, "[redacted credential header]") + .replace(/\b(?:Bearer|Basic)\s+[^\s,;"']+/gi, "[redacted authorization]") + .replace(/screener-client-v\d+\.[A-Za-z0-9_-]+/g, "[redacted Client capability]") + .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[redacted token]") + .replace(/\b(?:[a-z]*token|[a-z]*grant|password|passwd|secret|authorization|cookie|ice-pwd)["']?\s*[=:]\s*(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi, "[redacted credential]") + .replace(/(?:https?|wss?):\/\/[^\s<>"')]+/gi, (text) => { + try { + const url = new URL(text); + url.username = url.password = url.search = url.hash = ""; + return url.toString(); + } catch { return "[redacted URL]"; } + }) + .replace(/data:[^\s]+/gi, "[redacted data URL]"); } -export function debugError( - scope: string, - event: string, - error: unknown, - details: DebugDetails = {}, -): void { - if (!browserDebugEnabled) return; - let errorName = "Error"; +function sanitize(value: unknown): { value: DebugValue; truncated: boolean } { + let remaining = 32_768, nodes = 0, truncated = false; + const seen = new WeakSet(); + const visit = (input: unknown, depth: number): DebugValue => { + if (++nodes > 2_048 || depth > 6 || remaining <= 0) { + truncated = true; + return "[truncated]"; + } + if (input === null || typeof input === "boolean") return input; + if (typeof input === "number") return Number.isFinite(input) ? input : null; + if (typeof input === "string") { + const text = safeText(input); + const length = Math.min(text.length, 8_192, remaining); + remaining -= length; + if (length < text.length) truncated = true; + return text.slice(0, length); + } + if (typeof input !== "object") return `[${typeof input}]`; + if (seen.has(input)) return "[circular]"; + seen.add(input); + try { + if (Array.isArray(input)) { + if (input.length > 64) truncated = true; + return input.slice(0, 64).map((item) => visit(item, depth + 1)); + } + const output: { [key: string]: DebugValue } = {}; + const keys = Object.keys(input); + if (keys.length > 128) truncated = true; + for (const key of keys.slice(0, 128)) { + // Codec fmtp parameters are not an SDP description or credential. + if ((key !== "sdpFmtpLine" && secretKey.test(key)) || /^(?:data|payload|body|__proto__|constructor)$/i.test(key)) continue; + const name = safeText(key).slice(0, 128); + remaining -= name.length; + try { output[name] = visit((input as Record)[key], depth + 1); } + catch { output[name] = "[unavailable]"; truncated = true; } + } + return output; + } catch { truncated = true; return { unavailable: true }; } + }; + return { value: visit(value, 0), truncated }; +} + +function errorDetails(error: unknown, depth = 0): object { + if (depth > 3) return { cause: "[truncated]" }; try { - if ( - (error instanceof Error || error instanceof DOMException) && - errorNames.has(error.name) - ) - errorName = error.name; - } catch { - /* Diagnostic inspection must not throw into media work. */ - } - debugEvent(scope, event, { ...details, errorName }); + if (error instanceof Error || (typeof DOMException !== "undefined" && error instanceof DOMException)) { + return { name: error.name, message: error.message, stack: error.stack, + ...("cause" in error ? { cause: errorDetails(error.cause, depth + 1) } : {}) }; + } + return { message: typeof error === "string" ? error : "Non-Error rejection", value: error }; + } catch { return { message: "Error details unavailable" }; } +} + +export function debugEvent(scope: string, event: string, details: DebugDetails = {}): void { + if (!browserDebugEnabled || !/^[a-z][a-z-]{0,47}$/.test(scope) || !/^[a-z][a-z-]{0,47}$/.test(event)) return; + try { + const safe = sanitize(details); + const record: BrowserDebugEvent = { + sequence: ++sequence, at: new Date().toISOString(), elapsedMs: performance.now() - started, + scope, event, details: safe.value as BrowserDebugEvent["details"], + ...(safe.truncated ? { truncated: true } : {}), + }; + const bytes = new TextEncoder().encode(JSON.stringify(record)).byteLength; + events.push({ record, bytes }); + retainedBytes += bytes; + if (safe.truncated) truncatedEvents++; + while (events.length > MAX_EVENTS || retainedBytes > MAX_BYTES) { + retainedBytes -= events.shift()!.bytes; + evictedEvents++; + } + console.info("[screener]", record); + } catch { /* Diagnostics must not interrupt the operation being observed. */ } +} + +export function debugError(scope: string, event: string, error: unknown, details: DebugDetails = {}): void { + if (browserDebugEnabled) debugEvent(scope, event, { ...details, error: errorDetails(error) }); +} + +export function debugOperation(scope: string, event: string, details: DebugDetails = {}): + (outcome: string, result?: DebugDetails, error?: unknown) => void { + if (!browserDebugEnabled) return () => undefined; + const operationId = sequence + 1; + const began = performance.now(); + debugEvent(scope, `${event}-started`, { ...details, operationId }); + return (outcome, result = {}, error) => { + const completed = { ...result, operationId, outcome, durationMs: performance.now() - began }; + if (error === undefined) debugEvent(scope, `${event}-completed`, completed); + else debugError(scope, `${event}-completed`, error, completed); + }; } function snapshot(): BrowserDebugEvent[] { - return structuredClone(events); + return structuredClone(events.map(({ record }) => record)); +} + +export async function exportBrowserDebug(): Promise { + const collectorErrors: object[] = []; + let environment: object = {}; + try { + const nav = navigator as Navigator & { userAgentData?: { getHighEntropyValues(hints: string[]): Promise } }; + environment = { userAgent: nav.userAgent, platform: nav.platform, language: nav.language, + hardwareConcurrency: nav.hardwareConcurrency, visibility: document.visibilityState, + online: nav.onLine, secureContext: globalThis.isSecureContext }; + if (nav.userAgentData) { + let timer: ReturnType | undefined; + try { + const userAgentData = await Promise.race([ + nav.userAgentData.getHighEntropyValues(["fullVersionList", "platformVersion", "architecture"]), + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Browser version collection timed out")), 1_000); }), + ]); + environment = { ...environment, userAgentData }; + } finally { clearTimeout(timer); } + } + } catch (error) { collectorErrors.push({ collector: "environment", error: errorDetails(error) }); } + const history = snapshot(); + for (const record of history) { + if (record.event === "collector-failed") collectorErrors.push({ at: record.at, ...record.details }); + } + return JSON.stringify({ + capturedAt: new Date().toISOString(), startedAt, protocol: SIGNALING_PROTOCOL, + asset: new URL(import.meta.url).pathname.split("/").at(-1), + environment: sanitize(environment).value, + history: { maxEvents: MAX_EVENTS, maxBytes: MAX_BYTES, retainedBytes, + retainedEvents: history.length, evictedEvents, truncatedEvents, + firstSequence: history[0]?.sequence ?? null, lastSequence: history.at(-1)?.sequence ?? null, + firstAt: history[0]?.at ?? null, lastAt: history.at(-1)?.at ?? null }, + partial: evictedEvents > 0 || truncatedEvents > 0 || collectorErrors.length > 0, + collectorErrors: sanitize(collectorErrors).value, + events: history, + }, null, 2); +} + +export async function downloadBrowserDebug(): Promise { + const blob = new Blob([await exportBrowserDebug()], { type: "application/json" }); + const url = URL.createObjectURL(blob); + try { + const link = document.createElement("a"); + link.href = url; + link.download = `screener-browser-${new Date().toISOString().replace(/[:.]/g, "-")}.json`; + link.click(); + } finally { setTimeout(() => URL.revokeObjectURL(url), 0); } } export function installBrowserDebug(): (() => void) | undefined { if (!browserDebugEnabled) return; window.__SCREENER_DEBUG__ = { events: snapshot, - clear: () => { - events.length = 0; - }, - export: () => - JSON.stringify( - { - capturedAt: new Date().toISOString(), - protocol: SIGNALING_PROTOCOL, - asset: new URL(import.meta.url).pathname.split("/").at(-1), - events: snapshot(), - }, - null, - 2, - ), + clear: () => { evictedEvents += events.length; events.length = 0; retainedBytes = 0; }, + export: exportBrowserDebug, }; - const reportError = (event: ErrorEvent) => - debugError("window", "error", event.error); - const reportRejection = (event: PromiseRejectionEvent) => - debugError("window", "unhandled-rejection", event.reason); + const reportError = (event: ErrorEvent) => debugError("window", "error", event.error ?? event.message); + const reportRejection = (event: PromiseRejectionEvent) => debugError("window", "unhandled-rejection", event.reason); window.addEventListener("error", reportError); window.addEventListener("unhandledrejection", reportRejection); debugEvent("app", "loaded"); @@ -162,7 +207,7 @@ declare global { __SCREENER_DEBUG__?: { events: () => readonly BrowserDebugEvent[]; clear: () => void; - export: () => string; + export: () => Promise; }; } } diff --git a/src/client/lib/signaling.ts b/src/client/lib/signaling.ts index 55ba2d1..0ae4cf9 100644 --- a/src/client/lib/signaling.ts +++ b/src/client/lib/signaling.ts @@ -133,6 +133,7 @@ export class SignalingClient { return false; } this.socket.send(JSON.stringify(message)); + debugEvent("signal", "sent", { generation: this.socketGeneration, message }); return true; } @@ -264,6 +265,7 @@ export class SignalingClient { this.acceptSignalingChallengeResponse(message.sequence, generation); return; } + debugEvent("signal", "received", { generation, message }); if (message.type === "error") { debugEvent("signal", "server-error", { role: this.identity.role, code: message.code }); diff --git a/src/client/locales/en.ts b/src/client/locales/en.ts index c3456c1..fc306df 100644 --- a/src/client/locales/en.ts +++ b/src/client/locales/en.ts @@ -2,6 +2,9 @@ import type { CopyKey, TitleFrameCatalog } from "./zh"; // Community translations are data-only and type-checked against zh. export const en: Record = { + "debug.export": "Debug report", + "debug.exportHint": "Download local diagnostics; may include network addresses and device details", + "debug.exportFailed": "Could not download diagnostics; try again", "brand.home": "Screener home", "common.roomCode": "Room code", diff --git a/src/client/locales/zh.ts b/src/client/locales/zh.ts index a1ed019..4daf572 100644 --- a/src/client/locales/zh.ts +++ b/src/client/locales/zh.ts @@ -1,5 +1,8 @@ // Canonical catalog: add keys here first; every other locale must match it. export const zh = { + "debug.export": "调试报告", + "debug.exportHint": "下载本地诊断,可能包含网络地址和设备信息", + "debug.exportFailed": "诊断下载失败,请重试", "brand.home": "Screener 首页", "common.roomCode": "房间号", diff --git a/src/client/media/browser-encoding-output.ts b/src/client/media/browser-encoding-output.ts new file mode 100644 index 0000000..8562b06 --- /dev/null +++ b/src/client/media/browser-encoding-output.ts @@ -0,0 +1,222 @@ +import { debugError, debugEvent } from "../lib/debug"; + +// One codec-sized macroblock; transport timing is not a quality layer. +export const BROWSER_CARRIER_SIZE = 16; + +export function supportsBrowserEncoding(): boolean { + return typeof RTCRtpSender !== "undefined" && typeof RTCEncodedVideoFrame === "function" && + typeof HTMLCanvasElement !== "undefined" && typeof HTMLCanvasElement.prototype.captureStream === "function" && + typeof (RTCRtpSender.prototype as RTCRtpSender & { createEncodedStreams?: unknown }).createEncodedStreams === "function"; +} + +export function encodedStreams(sender: RTCRtpSender): { + readable: ReadableStream; writable: WritableStream; +} { + return (sender as RTCRtpSender & { createEncodedStreams(): { + readable: ReadableStream; writable: WritableStream; + } }).createEncodedStreams(); +} + +export interface BrowserEncodingOutputSnapshot { + frames: number; + width: number | null; + height: number | null; + lastProducerId: string | null; + timestamp: number; +} + +type Queue = { producerId: string; frames: RTCEncodedVideoFrame[]; needKey: boolean; requestKey: () => Promise }; +type Selection = { queue: Queue; onSelected: () => void }; +// Bound pending encoded data; a lost dependency chain resumes on a fresh key. +const MAX_QUEUED_FRAMES = 4; + +function copyFrame(frame: RTCEncodedVideoFrame, rtpTimestamp?: number): RTCEncodedVideoFrame { + const Frame = RTCEncodedVideoFrame as unknown as { new(frame: RTCEncodedVideoFrame, + options?: { metadata: RTCEncodedVideoFrameMetadata & { rtpTimestamp: number } }): RTCEncodedVideoFrame }; + return new Frame(frame, rtpTimestamp === undefined ? undefined : { metadata: { ...frame.getMetadata(), rtpTimestamp } }); +} + +/** One connection-lifetime encoded stream, including ordinary fallback. */ +export class BrowserEncodingOutput { + private readonly clock = document.createElement("canvas"); + private readonly clockContext: CanvasRenderingContext2D; + readonly track: CanvasCaptureMediaStreamTrack; + private clockFrame = 0; + private readonly abort = new AbortController(); + private readonly writer: WritableStreamDefaultWriter; + private current: Queue | undefined; + private pending: Selection | undefined; + private carrier: RTCEncodedVideoFrame | undefined; + private writing: Promise | undefined; + private ownKey: (() => Promise) | undefined; + private raw: false | "key" | true = false; + private paused = false; + private epoch = 0; + private readonly sample = { frames: 0, width: null as number | null, height: null as number | null, + lastProducerId: null as string | null }; + + constructor(sender: RTCRtpSender, private readonly onFailure: () => void, private readonly identity: object) { + // A CPU-resident macroblock avoids repeatedly mapping/scaling a full GPU + // decoder surface merely to provide outgoing RTP timing. + this.clock.width = this.clock.height = BROWSER_CARRIER_SIZE; + const context = this.clock.getContext("2d"); + if (!context) throw new Error("Browser carrier canvas unavailable"); + this.clockContext = context; + this.track = this.clock.captureStream(0).getVideoTracks()[0] as CanvasCaptureMediaStreamTrack; + this.track.contentHint = "motion"; + const streams = encodedStreams(sender); + this.writer = streams.writable.getWriter(); + void streams.readable.pipeTo(new WritableStream({ write: (frame) => this.receive(frame) }), + { signal: this.abort.signal }).then(() => this.fail(), (error) => this.fail(error)).finally(() => { + try { this.writer.releaseLock(); } catch { /* Owner abort retired the writer. */ } + }); + } + + select(producerId: string, requestKey: () => Promise, onSelected: () => void): void { + if (this.abort.signal.aborted) return; + this.epoch++; + this.carrier = undefined; + this.pending = { queue: { producerId, frames: [], needKey: true, requestKey }, onSelected }; + debugEvent("encoding-pool", "selection-requested", { ...this.identity, producerId }); + if (!this.paused) this.request(this.pending.queue); + } + + push(producerId: string, frame: RTCEncodedVideoFrame): void { + if (this.abort.signal.aborted || this.paused || frame.data.byteLength === 0) return; + try { + let accepted = false; + for (const queue of new Set([this.current, this.pending?.queue])) { + if (queue?.producerId !== producerId) continue; + if (frame.type === "key") { queue.frames.length = 0; queue.needKey = false; } + else if (queue.frames.length >= MAX_QUEUED_FRAMES) this.recover(queue); + if (!queue.needKey) { queue.frames.push(copyFrame(frame)); accepted = true; } + } + if (accepted) { + this.clockContext.fillStyle = "#080808"; + this.clockContext.fillRect(0, 0, this.clock.width, this.clock.height); + this.clockContext.fillStyle = "#181818"; + this.clockContext.fillRect(this.clockFrame++ % this.clock.width, 0, 1, 1); + this.track.requestFrame(); + } + void this.drain(); + } catch (error) { this.fail(error); } + } + + passthrough(requestKey: () => Promise): void { + if (this.abort.signal.aborted) return; + this.epoch++; + this.current = undefined; this.pending = undefined; this.carrier = undefined; + this.raw = "key"; + debugEvent("encoding-pool", "ordinary-requested", this.identity); + this.ownKey = requestKey; + if (!this.paused) this.requestOwnKey(); + } + + setPaused(paused: boolean): void { + if (this.abort.signal.aborted || this.paused === paused) return; + this.paused = paused; + this.track.enabled = !paused; + debugEvent("encoding-pool", "paused", { ...this.identity, paused }); + this.epoch++; + this.carrier = undefined; + if (this.raw) this.raw = "key"; + for (const queue of new Set([this.current, this.pending?.queue])) if (queue) { + queue.frames.length = 0; queue.needKey = true; + if (!paused) this.request(queue); + } + if (!paused && this.raw) this.requestOwnKey(); + } + + snapshot(): BrowserEncodingOutputSnapshot { return { ...this.sample, timestamp: performance.now() }; } + + dispose(): void { + if (this.abort.signal.aborted) return; + this.epoch++; + this.current = undefined; this.pending = undefined; this.carrier = undefined; this.ownKey = undefined; + this.abort.abort(); + this.track.stop(); + void this.writer.abort().catch(() => undefined); + } + + private async receive(frame: RTCEncodedVideoFrame): Promise { + if (this.abort.signal.aborted || this.paused || frame.data.byteLength === 0) return; + if (this.raw) { + const epoch = this.epoch; + await this.writing; + if (this.abort.signal.aborted || this.paused || this.epoch !== epoch) return; + if (this.raw && !this.pending?.queue.frames[0]) { + if (this.raw === "key" && frame.type !== "key") return; + await this.write(frame, null); + return; + } + } + // A native carrier key can be a remote PLI. Recover the selected real + // stream, without requiring its frame type to match this clock frame. + if (frame.type === "key" && this.current && this.pending?.queue.frames[0]?.type !== "key" && + this.current.frames[0]?.type !== "key") this.recover(this.current); + this.carrier = frame; + const writing = this.drain(); + if (this.raw) await writing; + } + + private drain(): Promise | undefined { + if (this.abort.signal.aborted || this.paused || this.writing || !this.carrier) return; + const selection = this.pending?.queue.frames[0]?.type === "key" ? this.pending : undefined; + if (this.raw && !selection) return; + const queue = selection?.queue ?? this.current, frame = queue?.frames.shift(); + if (!queue || !frame) return; + const own = this.carrier; + this.carrier = undefined; + try { + const copy = copyFrame(frame, own.timestamp); + this.current = queue; + this.raw = false; + return this.write(copy, queue.producerId, selection); + } catch (error) { this.fail(error); } + } + + private write(frame: RTCEncodedVideoFrame, producerId: string | null, selection?: Selection): Promise { + const epoch = this.epoch, metadata = frame.getMetadata(), key = frame.type === "key"; + const writing = this.writer.write(frame).then(() => { + if (this.abort.signal.aborted) return; + this.sample.frames++; + this.sample.width = metadata.width ?? null; this.sample.height = metadata.height ?? null; + this.sample.lastProducerId = producerId; + if (epoch !== this.epoch || this.paused) return; + if (key) this.raw = producerId === null; + if (selection && this.pending === selection) { + this.pending = undefined; + debugEvent("encoding-pool", "selected", { ...this.identity, producerId, width: metadata.width, height: metadata.height }); + selection.onSelected(); + } + }).catch((error) => this.fail(error)).finally(() => { this.writing = undefined; void this.drain(); }); + this.writing = writing; + return writing; + } + + private recover(queue: Queue): void { + if (queue.needKey) return; + debugEvent("encoding-pool", "recovery-requested", { ...this.identity, producerId: queue.producerId, queued: queue.frames.length }); + queue.frames.length = 0; queue.needKey = true; + this.request(queue); + } + + private request(queue: Queue): void { + const epoch = this.epoch; + void queue.requestKey().catch((error) => { + if (epoch === this.epoch && (this.current === queue || this.pending?.queue === queue)) this.fail(error); + }); + } + + private requestOwnKey(): void { + const epoch = this.epoch; + void this.ownKey?.().catch((error) => { if (epoch === this.epoch) this.fail(error); }); + } + + private fail(error?: unknown): void { + if (this.abort.signal.aborted) return; + debugError("encoding-pool", "output-failed", error, { ...this.identity, producerId: this.current?.producerId }); + this.dispose(); + this.onFailure(); + } +} diff --git a/src/client/media/browser-encoding-pool.ts b/src/client/media/browser-encoding-pool.ts new file mode 100644 index 0000000..aa24c57 --- /dev/null +++ b/src/client/media/browser-encoding-pool.ts @@ -0,0 +1,359 @@ +import { createOpaqueId } from "../lib/opaque-id"; +import { browserDebugEnabled, debugError, debugEvent } from "../lib/debug"; +import { EMPTY_METRICS, type ConnectionMetrics } from "../types"; +import { captureMetrics, collectConnectionMetricsFromReport, createStatsAccumulator } from "../webrtc/stats"; +import { BrowserEncodingProducer } from "./browser-encoding-producer"; +import { BROWSER_CARRIER_SIZE, type BrowserEncodingOutput } from "./browser-encoding-output"; +import { codecIdentity, fits, nativeVideoBudget, negotiatedCodec, noRegression, observeVideo, + projectEncodingMetrics, videoOutbound, type Observation } from "./browser-encoding-stats"; +import { QUALITY_RESOLUTIONS, videoQualitySettingsEqual, type QualityProfile } from "./quality"; + +export interface BrowserPooledSender { + updateProfile(profile: QualityProfile): Promise; + carrierScale(): number | undefined; + setPaused(paused: boolean): void; + metrics(transport: ConnectionMetrics): ConnectionMetrics; + dispose(): void; +} + +type Output = ReturnType; +type Group = { + producer: BrowserEncodingProducer; source: MediaStreamTrack; profile: QualityProfile; + codecKey: string; budget: number; ready: boolean; failed: boolean; + report?: RTCStatsReport; previous?: RTCOutboundRtpStreamStats; output?: Observation; + updating?: Promise; +}; +type Member = { + id: string; source: MediaStreamTrack; sender: RTCRtpSender; connection: RTCPeerConnection; + profile: QualityProfile; encoded: BrowserEncodingOutput; + configureCarrier: () => Promise; + requestKey: () => Promise; onFatal: () => void; + paused: boolean; disabled: boolean; disposed: boolean; mixed: boolean; carrier: boolean; + codec?: RTCRtpCodec; codecKey?: string; budget?: number; raw?: RTCOutboundRtpStreamStats; + encoderStats: ReturnType; + current?: Group; pending?: { group: Group; selecting?: boolean }; + output?: Output; previousOutput?: Output; replacement?: Promise; +}; + +// M152 exposes BWE through getStats, not a bandwidth callback. The paired +// weak-entry probe bounds observation lag at this cadence; it is not a retry. +const BUDGET_SAMPLE_MS = 500; + +/** One owner per captured source tree; connections retain their own RTP and transport. */ +export class BrowserEncodingPool { + private readonly members = new Set(); + private readonly groups = new Set(); + private readonly failedSources = new WeakSet(); + private timer: ReturnType | undefined; + private polling = false; + private disposed = false; + + create(source: MediaStreamTrack, sender: RTCRtpSender, connection: RTCPeerConnection, + profile: QualityProfile, encoded: BrowserEncodingOutput, configureCarrier: Member["configureCarrier"], requestKey: Member["requestKey"], + onFatal: () => void): BrowserPooledSender | null { + if (this.disposed || this.failedSources.has(source) || source.kind !== "video" || source.readyState !== "live") return null; + const member: Member = { id: createOpaqueId(), source, sender, connection, profile: { ...profile }, encoded, configureCarrier, requestKey, onFatal, + paused: !source.enabled, disabled: false, disposed: false, mixed: false, carrier: encoded.snapshot().frames === 0, + encoderStats: createStatsAccumulator() }; + this.members.add(member); + // A fresh connection has no useful raw output to preserve. Start its + // tiny clock immediately, rather than warming two full encoders first. + if (!member.carrier) encoded.passthrough(requestKey); + encoded.setPaused(member.paused); + if (!this.timer) this.timer = setInterval(() => void this.poll(), BUDGET_SAMPLE_MS); + return { + carrierScale: () => { + if (!member.carrier) return; + const width = member.sender.track?.getSettings().width ?? QUALITY_RESOLUTIONS[member.profile.resolution].width; + return Math.max(1, width / BROWSER_CARRIER_SIZE); + }, + updateProfile: async (next) => { + if (member.disposed || videoQualitySettingsEqual(member.profile, next)) return; + member.profile = { ...next }; + member.encoderStats = createStatsAccumulator(); member.previousOutput = undefined; + // HostPeer awaits this inside its sender queue. Handoffs run outside it. + }, + setPaused: (paused) => { + if (member.disposed || member.paused === paused) return; + member.paused = paused; + member.previousOutput = undefined; + member.encoded.setPaused(paused); + this.updatePauses(); + }, + metrics: (transport) => this.metrics(member, transport), + dispose: () => this.remove(member), + }; + } + + dispose(): void { + this.disposed = true; + for (const member of [...this.members]) this.remove(member); + this.prune(); + } + + private compatible(member: Member, group: Group): boolean { + return member.source === group.source && member.codecKey === group.codecKey && + videoQualitySettingsEqual(member.profile, group.profile) && !group.failed; + } + + private references(group: Group): Member[] { + return [...this.members].filter((member) => member.current === group || member.pending?.group === group); + } + + private async poll(): Promise { + if (this.polling || this.disposed) return; + this.polling = true; + try { + await Promise.allSettled([ + ...[...this.members].map(async (member) => { + if (member.disposed || member.disabled || member.connection.connectionState !== "connected") { member.budget = undefined; return; } + const report = await member.connection.getStats().catch(() => null); + if (member.disposed) return; + if (!report) { member.budget = undefined; return; } + const codec = negotiatedCodec(report); + if (!codec) { + if (member.current || member.pending) this.failSource(member.source); + else { + const outbound = videoOutbound(report); + if (outbound?.codecId && report.get(outbound.codecId)) this.failSource(member.source); + } + return; + } + const key = codecIdentity(codec); + if (member.codecKey && member.codecKey !== key && (member.current || member.pending)) { + this.failSource(member.source); return; + } + member.codec = codec; member.codecKey = key; + member.raw = videoOutbound(report); + member.budget = nativeVideoBudget(member.raw, member.profile.maxBitrate); + }), + ...[...this.groups].map(async (group) => { + if (!group.ready || group.failed) { group.output = undefined; return; } + const report = await group.producer.report().catch(() => null); + if (!this.groups.has(group)) return; + if (!report) { group.output = undefined; return; } + group.report = report; + const video = videoOutbound(report); + group.output = observeVideo(video, group.previous); + group.previous = video; + }), + ]); + if (this.disposed) return; + this.reconcile(); + for (const member of this.members) { + member.output = member.encoded.snapshot(); + if (!member.pending && !member.replacement && member.output.frames > 0 && + member.output.lastProducerId === (member.current?.producer.id ?? null)) member.mixed = false; + } + if (browserDebugEnabled) debugEvent("encoding-pool", "sample", { + members: [...this.members].map((member) => ({ memberId: member.id, trackId: member.source.id, + rtpStatsId: member.raw?.id, ssrc: member.raw?.ssrc, + producerId: member.current?.producer.id, pendingProducerId: member.pending?.group.producer.id, + budget: member.budget, carrier: member.carrier, paused: member.paused, disabled: member.disabled, output: member.output })), + groups: [...this.groups].map((group) => ({ producerId: group.producer.id, budget: group.budget, + ready: group.ready, failed: group.failed, output: group.output })), + }); + } finally { this.polling = false; } + } + + private reconcile(): void { + const changing = new Set(); + for (const group of this.groups) { + const references = this.references(group); + const profile = references[0]?.profile; + if (!profile || group.failed || videoQualitySettingsEqual(profile, group.profile) || + !references.every((member) => member.source === group.source && member.codecKey === group.codecKey && + videoQualitySettingsEqual(member.profile, profile))) continue; + changing.add(group); + if (!group.ready || group.updating) continue; + for (const member of references) if (member.current === group && member.pending && !member.pending.selecting) member.pending = undefined; + const next = { ...profile }; + const budget = Math.min(next.maxBitrate, Math.max(...references.map((member) => member.budget ?? group.budget))); + group.budget = budget; + group.updating = group.producer.update(next, budget).then(() => { + if (!this.groups.has(group)) return; + group.profile = next; + for (const member of this.members) if (member.current === group || member.pending?.group === group) { + member.encoderStats = createStatsAccumulator(); member.previousOutput = undefined; + } + }).catch(() => { + if (this.groups.has(group)) this.failSource(group.source); + }).finally(() => { group.updating = undefined; }); + } + for (const member of this.members) { + if (member.disposed || member.disabled || member.paused || member.replacement || !member.codec || member.budget === undefined) continue; + if ([member.current, member.pending?.group].some((group) => group && (group.updating || changing.has(group)))) continue; + if (member.pending) { + const pending = member.pending; + // A shared producer can outgrow a waiting child's native allocation. + // Replan that membership; never wait forever for its budget to catch up. + if (!pending.selecting && pending.group.budget > member.budget && + !fits(pending.group.output, member.budget) && this.references(pending.group).some((other) => other !== member)) { + member.pending = undefined; + continue; + } + if (!pending.selecting && member.current && this.compatible(member, member.current) && + fits(member.current.output, member.budget) && pending.group.budget < member.current.budget) { + member.pending = undefined; + continue; + } + if (!pending.selecting && !this.compatible(member, pending.group)) { + member.pending = undefined; + continue; + } + const ownedBudget = pending.group.budget <= member.budget; + // The output already waits for a real recovery frame. A rate-owned + // downgrade need not wait another statistics window while the old + // high-rate output keeps congesting the same path. + if (!pending.selecting && pending.group.ready && + (fits(pending.group.output, member.budget) || ((member.current || member.carrier) && ownedBudget))) { + const output = pending.group.output; + // A fresh carrier has delivered no picture. Its tiny encode cannot + // establish a quality baseline for the first real output. + if (member.current || member.carrier || (output && noRegression(output, member.raw?.frameWidth ?? null, + member.raw?.frameHeight ?? null, member.raw?.framesPerSecond ?? null, member.profile))) { + pending.selecting = true; + member.mixed = true; + member.encoded.select(pending.group.producer.id, () => pending.group.producer.requestKey(), () => { + if (member.disposed || member.pending !== pending) return; + member.current = pending.group; + member.pending = undefined; + member.encoderStats = createStatsAccumulator(); member.previousOutput = undefined; + member.carrier = true; + member.replacement = member.configureCarrier().then((applied) => { + if (!member.disposed && !applied) this.failSource(member.source); + }).catch(() => this.failSource(member.source)).finally(() => { member.replacement = undefined; }); + this.prune(); + }); + } + } + continue; + } + const current = member.current; + if (current && this.compatible(member, current) && + (!current.output || current.output.bitrate === 0 || current.output.fps === 0)) continue; + if (current?.output && this.compatible(member, current) && fits(current.output, member.budget)) { + // Equal outputs converge to the earlier owner. A strictly better healthy + // output is also reusable when this encoder has not recovered itself. + let earlier = true; + for (const group of this.groups) { + if (group === current) { earlier = false; continue; } + const improves = group.output && current.output && (group.output.width > current.output.width || + group.output.height > current.output.height || Math.round(group.output.fps) > Math.round(current.output.fps)); + if ((earlier || improves) && this.compatible(member, group) && fits(group.output, member.budget) && group.output?.reason === "none" && + noRegression(group.output!, current.output.width, current.output.height, current.output.fps, member.profile)) { + member.pending = { group }; break; + } + } + continue; + } + // An unshared pipeline already has the right owner: let its native + // encoder adapt to the updated budget instead of recreating it. + if (current && this.compatible(member, current) && !this.references(current).some((other) => other !== member)) continue; + const candidates = [...this.groups].filter((group) => this.compatible(member, group)); + let target = candidates.find((group) => group !== current && fits(group.output, member.budget) && + (!current || group.budget === member.budget)); + target ??= candidates.find((group) => group !== current && group.budget === member.budget); + if (!target) { + target = this.startGroup(member, member.budget); + } + member.pending = { group: target }; + } + for (const group of this.groups) { + const references = this.references(group); + const budgets = references.flatMap((member) => this.compatible(member, group) && member.budget !== undefined ? [member.budget] : []); + if (group.ready && budgets.length) { + const budget = Math.max(...budgets); + if (budget !== group.budget && !group.updating) { + group.budget = budget; + group.updating = group.producer.update(group.profile, budget).catch(() => { + if (this.groups.has(group)) this.failSource(group.source); + }) + .finally(() => { group.updating = undefined; }); + } + } + } + this.updatePauses(); + this.prune(); + } + + private startGroup(member: Member, budget: number): Group { + const profile = { ...member.profile }; + const producer = new BrowserEncodingProducer(member.source, profile, member.codec!, (frame) => { + for (const output of this.members) if (output.current === group || output.pending?.group === group) { + output.encoded.push(producer.id, frame); + } + }, + () => { group.failed = true; this.failSource(member.source); }); + const group: Group = { producer, source: member.source, profile, codecKey: member.codecKey!, budget, ready: false, failed: false }; + this.groups.add(group); + debugEvent("encoding-pool", "producer-preparing", { producerId: producer.id, trackId: member.source.id, codec: member.codecKey, budget, profile }); + void producer.start(budget).then(() => { if (this.groups.has(group)) group.ready = true; }, (error) => { + debugError("encoding-pool", "producer-start-failed", error, { producerId: producer.id }); + if (this.groups.has(group)) { group.failed = true; this.failSource(group.source); } + }); + return group; + } + + private restore(member: Member): void { + if (member.disposed) return; + member.mixed = true; + member.pending = undefined; + member.carrier = false; + member.replacement = member.configureCarrier().then((applied) => { + if (member.disposed) return; + if (!applied) { member.onFatal(); return; } + member.encoded.passthrough(member.requestKey); + member.current = undefined; + member.encoderStats = createStatsAccumulator(); member.previousOutput = undefined; + }).catch(() => { if (!member.disposed) member.onFatal(); }) + .finally(() => { member.replacement = undefined; this.prune(); }); + } + + private failSource(source: MediaStreamTrack): void { + if (this.failedSources.has(source)) return; + this.failedSources.add(source); + for (const member of this.members) if (member.source === source) { + member.disabled = true; + if (member.replacement) void member.replacement.then(() => this.restore(member)); + else this.restore(member); + } + } + + private updatePauses(): void { + for (const group of this.groups) group.producer.setPaused(!this.references(group).some((member) => !member.paused)); + } + + private metrics(member: Member, transport: ConnectionMetrics): ConnectionMetrics { + if (member.disposed || (!member.current && !member.mixed)) return transport; + const group = member.current, input = group?.producer.track, sample = member.output, previous = member.previousOutput; + member.previousOutput = sample; + const measured = !member.mixed && !member.paused && input && group?.report && !group.failed && sample?.lastProducerId === group.producer.id && + previous?.lastProducerId === group.producer.id && sample.timestamp > previous.timestamp; + const encoder = measured ? { ...collectConnectionMetricsFromReport(group.report!, "send", member.encoderStats), + ...captureMetrics(input) } : EMPTY_METRICS; + const qualityEligible = !!measured && !member.pending && !group.updating && this.compatible(member, group) && + (encoder.nativeEdgeQualityState === "degraded" || fits(group.output, member.budget)); + return projectEncodingMetrics(transport, encoder, measured ? sample : undefined, measured ? previous : undefined, qualityEligible); + } + + private remove(member: Member): void { + if (member.disposed) return; + member.disposed = true; + this.members.delete(member); + member.encoded.passthrough(member.requestKey); + member.carrier = false; + member.current = undefined; member.pending = undefined; + this.prune(); + } + + private prune(): void { + for (const group of this.groups) if (!this.references(group).length) { + this.groups.delete(group); group.producer.dispose(); + } + if (!this.members.size) { + if (this.timer) clearInterval(this.timer); + this.timer = undefined; + } + } +} diff --git a/src/client/media/browser-encoding-producer.ts b/src/client/media/browser-encoding-producer.ts new file mode 100644 index 0000000..5069ad9 --- /dev/null +++ b/src/client/media/browser-encoding-producer.ts @@ -0,0 +1,223 @@ +import { createOpaqueId } from "../lib/opaque-id"; +import { debugError, debugEvent } from "../lib/debug"; +import { debugRtcFailure, debugRtcStats, debugTrack, observeDebugConnection } from "../lib/debug-webrtc"; +import { maxEncodedVideoFrames } from "../webrtc/stats"; +import { encodedStreams } from "./browser-encoding-output"; +import { + applyVideoCaptureProfile, cloneSenderVideoTrack, configureVideoSender, + needsStartupVideoProfile, startupVideoProfile, STARTUP_VIDEO_ENCODED_FRAMES, + videoQualitySettingsEqual, type QualityProfile, +} from "./quality"; + +export class BrowserEncodingProducer { + readonly id = createOpaqueId(); + private send: RTCPeerConnection | null = null; + private receive: RTCPeerConnection | null = null; + private input: MediaStreamTrack | null = null; + private videoSender: RTCRtpSender | null = null; + private video: HTMLVideoElement | null = null; + private readonly streamAbort = new AbortController(); + private streamWriter: WritableStreamDefaultWriter | null = null; + private parameterTail = Promise.resolve(); + private disposed = false; + private paused: boolean; + private budget: number; + private startupPending: boolean; + private applied: { capture: QualityProfile; sender: QualityProfile } | null = null; + + constructor( + readonly source: MediaStreamTrack, + private desiredProfile: QualityProfile, + private readonly codec: RTCRtpCodec, + private readonly onFrame: (frame: RTCEncodedVideoFrame) => void, + private readonly onFailure: () => void, + ) { + this.desiredProfile = { ...desiredProfile }; + this.budget = desiredProfile.maxBitrate; + this.startupPending = needsStartupVideoProfile(desiredProfile); + this.paused = !source.enabled; + } + + get track(): MediaStreamTrack | null { return this.input; } + get sender(): RTCRtpSender | null { return this.videoSender; } + + async start(initialBudget = this.desiredProfile.maxBitrate): Promise { + this.checkAlive(); + if (this.send) throw new Error("Browser encoding producer already started"); + this.budget = initialBudget; + try { + if (this.source.readyState !== "live") throw new Error("Browser encoding source ended"); + const track = this.input = cloneSenderVideoTrack(this.source); + track.enabled = !this.paused; + this.source.addEventListener("ended", this.fail); + track.addEventListener("ended", this.fail); + const send = this.send = new RTCPeerConnection({ iceServers: [], encodedInsertableStreams: true } as RTCConfiguration); + const receive = this.receive = new RTCPeerConnection({ iceServers: [] }); + observeDebugConnection(send, { producerId: this.id, role: "pool-producer" }); + observeDebugConnection(receive, { producerId: this.id, role: "pool-local-receiver" }); + const transceiver = send.addTransceiver(track, { direction: "sendonly", streams: [new MediaStream([track])] }); + this.videoSender = transceiver.sender; + const streams = encodedStreams(transceiver.sender), writer = streams.writable.getWriter(); + this.streamWriter = writer; + void streams.readable.pipeTo(new WritableStream({ write: async (frame) => { + if (this.disposed) return; + // Outputs clone synchronously before the local decoder consumes the original. + this.onFrame(frame); + await writer.write(frame); + } }), { signal: this.streamAbort.signal }).then(this.fail, this.fail).finally(() => { + try { writer.releaseLock(); } catch { /* Owner abort retired the writer. */ } + }); + transceiver.setCodecPreferences([this.codec]); + const video = this.video = document.createElement("video"); + video.autoplay = true; + video.muted = true; + video.playsInline = true; + receive.ontrack = ({ track: received }) => { + if (this.disposed) return; + video.srcObject = new MediaStream([received]); + void video.play().catch(this.fail); + }; + for (const connection of [send, receive]) { + connection.onconnectionstatechange = () => { + if (connection.connectionState === "failed") this.fail(); + }; + } + const forward = (from: RTCPeerConnection, to: RTCPeerConnection) => { + const pending: RTCIceCandidate[] = []; + from.onicecandidate = ({ candidate }) => { + if (!candidate || this.disposed) return; + if (!to.remoteDescription) pending.push(candidate); + else void to.addIceCandidate(candidate).catch(this.fail); + }; + return async () => { + for (const candidate of pending.splice(0)) { + this.checkAlive(); + await to.addIceCandidate(candidate); + } + }; + }; + const flushReceive = forward(send, receive), flushSend = forward(receive, send); + await this.serialize(() => this.applyCurrent()); + await send.setLocalDescription(await send.createOffer()); + this.checkAlive(); + await receive.setRemoteDescription(send.localDescription!); + await flushReceive(); + this.checkAlive(); + await receive.setLocalDescription(await receive.createAnswer()); + this.checkAlive(); + await send.setRemoteDescription(receive.localDescription!); + await flushSend(); + this.checkAlive(); + } catch (error) { + const retired = this.disposed; + this.dispose(); + if (retired) this.checkAlive(); + throw error; + } + } + + update(profile: QualityProfile, budget: number): Promise { + this.desiredProfile = { ...profile }; + this.budget = budget; + if (!needsStartupVideoProfile(profile)) this.startupPending = false; + return this.serialize(() => this.applyCurrent()); + } + + requestKey(): Promise { + return this.serialize(async () => { + const sender = this.requireSender(); + // Chromium's supported keyframe option is not yet in TypeScript's DOM declarations. + const request = sender.setParameters as (parameters: RTCRtpSendParameters, + options: { encodingOptions: Array<{ keyFrame: boolean }> }) => Promise; + await request.call(sender, sender.getParameters(), { encodingOptions: [{ keyFrame: true }] }); + }); + } + + async report(): Promise { + this.requireSender(); + const connection = this.send!; + const report = await connection.getStats().catch((error) => { debugRtcFailure(connection, error); throw error; }); + debugRtcStats(connection, report); + if (!this.disposed && this.startupPending && + maxEncodedVideoFrames(report, this.input!.id) >= STARTUP_VIDEO_ENCODED_FRAMES) { + this.startupPending = false; + void this.serialize(() => this.applyCurrent()).catch(this.fail); + } + return report; + } + + setPaused(paused: boolean): void { + this.paused = paused; + if (this.input) this.input.enabled = !paused; + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + debugEvent("encoding-pool", "producer-retired", { producerId: this.id }); + this.source.removeEventListener("ended", this.fail); + this.input?.removeEventListener("ended", this.fail); + this.input?.stop(); + this.streamAbort.abort(); + void this.streamWriter?.abort().catch(() => undefined); + this.streamWriter = null; + for (const connection of [this.send, this.receive]) { + if (!connection) continue; + connection.onicecandidate = connection.onconnectionstatechange = connection.ontrack = null; + connection.close(); + } + if (this.video) { this.video.pause(); this.video.srcObject = null; } + this.input = this.videoSender = this.video = this.send = this.receive = null; + } + + private readonly fail = (error?: unknown): void => { + if (this.disposed) return; + debugError("encoding-pool", "producer-failed", error, { producerId: this.id }); + this.dispose(); + this.onFailure(); + }; + + private checkAlive(): void { + if (this.disposed) throw new DOMException("Browser encoding producer was retired", "AbortError"); + } + + private requireSender(): RTCRtpSender { + this.checkAlive(); + if (!this.videoSender) throw new Error("Browser encoding producer has not started"); + return this.videoSender; + } + + private async applyCurrent(): Promise { + const sender = this.requireSender(), track = this.input!; + const profile = this.desiredProfile; + const previous = this.applied; + const effective = this.startupPending ? startupVideoProfile(profile) : profile; + const parameters = { ...effective, maxBitrate: Math.min(profile.maxBitrate, this.budget) }; + const captureChanged = !previous || !videoQualitySettingsEqual(previous.capture, profile); + const senderChanged = !previous || !videoQualitySettingsEqual(previous.sender, parameters); + if (!captureChanged && !senderChanged) return; + try { + if (captureChanged) await applyVideoCaptureProfile(track, profile); + this.checkAlive(); + if (senderChanged) await configureVideoSender(sender, parameters); + this.checkAlive(); + this.applied = { capture: profile, sender: parameters }; + debugEvent("encoding-pool", "producer-configured", { producerId: this.id, profile, parameters }); + if (captureChanged) debugTrack(track, { producerId: this.id }); + } catch (error) { + debugError("encoding-pool", "producer-settings-failed", error, { producerId: this.id, requested: profile, parameters }); + if (captureChanged && previous && !this.disposed) { + await applyVideoCaptureProfile(track, previous.capture).catch(this.fail); + } + throw error; + } + } + + private serialize(operation: () => Promise): Promise { + return this.parameterTail = this.parameterTail.catch(() => undefined).then(async () => { + this.checkAlive(); + await operation(); + this.checkAlive(); + }); + } +} diff --git a/src/client/media/browser-encoding-stats.ts b/src/client/media/browser-encoding-stats.ts new file mode 100644 index 0000000..72267bc --- /dev/null +++ b/src/client/media/browser-encoding-stats.ts @@ -0,0 +1,74 @@ +import { EMPTY_METRICS, type ConnectionMetrics } from "../types"; +import type { BrowserEncodingOutput } from "./browser-encoding-output"; +import type { QualityProfile } from "./quality"; + +export type Observation = { width: number; height: number; fps: number; bitrate: number; reason: string | null }; +type Output = ReturnType; + +const normalizeFmtp = (value = "") => value.split(";").map((part) => part.trim().toLowerCase()).filter(Boolean).sort().join(";"); +export const codecIdentity = (codec: RTCRtpCodec) => `${codec.mimeType.toLowerCase()}/${codec.clockRate}/${normalizeFmtp(codec.sdpFmtpLine)}`; + +export function videoOutbound(report: RTCStatsReport): RTCOutboundRtpStreamStats | undefined { + const videos: RTCOutboundRtpStreamStats[] = []; + report.forEach((row) => { + if (row.type === "outbound-rtp" && row.kind === "video" && row.active !== false) videos.push(row); + }); + return videos.length === 1 ? videos[0] : undefined; +} + +export function negotiatedCodec(report: RTCStatsReport): RTCRtpCodec | undefined { + const outbound = videoOutbound(report), codec = outbound?.codecId ? report.get(outbound.codecId) : undefined; + if (!codec || !["video/vp8", "video/h264"].includes(codec.mimeType?.toLowerCase())) return; + if (codec.mimeType.toLowerCase() === "video/h264" && !normalizeFmtp(codec.sdpFmtpLine).split(";").includes("packetization-mode=1")) return; + return RTCRtpSender.getCapabilities("video")?.codecs.find((candidate) => codecIdentity(candidate) === codecIdentity(codec)); +} + +export function nativeVideoBudget(outbound: RTCOutboundRtpStreamStats | undefined, ceiling: number): number | undefined { + // The carrier's native payload allocation accounts for audio, repair and + // post-transform overhead. Do not build a second RTP-overhead estimator. + const target = outbound?.targetBitrate; + return target !== undefined && Number.isFinite(target) && target >= 0 + ? Math.floor(Math.min(ceiling, target)) : undefined; +} + +export function observeVideo(video: RTCOutboundRtpStreamStats | undefined, + previous: RTCOutboundRtpStreamStats | undefined): Observation | undefined { + if (!video || !previous || video.id !== previous.id || video.timestamp <= previous.timestamp) return; + const seconds = (video.timestamp - previous.timestamp) / 1_000; + return { width: video.frameWidth ?? 0, height: video.frameHeight ?? 0, + fps: Math.max(0, (video.framesEncoded ?? 0) - (previous.framesEncoded ?? 0)) / seconds, + bitrate: Math.max(0, (video.bytesSent ?? 0) - (previous.bytesSent ?? 0)) * 8 / seconds, + reason: video.qualityLimitationReason ?? null }; +} + +export function fits(output: Observation | undefined, budget: number | undefined): boolean { + return !!output && budget !== undefined && output.bitrate > 0 && output.bitrate <= budget && output.fps > 0; +} + +export function noRegression(output: Observation, width: number | null, height: number | null, + fps: number | null, profile: QualityProfile): boolean { + return width !== null && height !== null && fps !== null && output.width >= width && output.height >= height && + Math.round(output.fps) >= Math.round(Math.min(fps, profile.maxFramerate)); +} + +/** The caller proves membership and owns both sampling histories. */ +export function projectEncodingMetrics(transport: ConnectionMetrics, encoder: ConnectionMetrics, + sample: Output | undefined, previous: Output | undefined, qualityEligible: boolean): ConnectionMetrics { + const window = sample && previous ? sample.timestamp - previous.timestamp : null; + const frames = sample && previous ? Math.max(0, sample.frames - previous.frames) : null; + const width = sample?.width ?? null, height = sample?.height ?? null; + const quality = qualityEligible && frames !== null && frames > 0 ? encoder : EMPTY_METRICS; + return { ...transport, + sampleTimestampMs: quality.sampleTimestampMs, sampleWindowMs: quality.sampleWindowMs, + captureWidth: encoder.captureWidth, captureHeight: encoder.captureHeight, + captureFramesPerSecond: encoder.captureFramesPerSecond, mediaSourceFramesPerSecond: encoder.mediaSourceFramesPerSecond, + frameWidth: width, frameHeight: height, resolution: width && height ? `${width}x${height}` : null, + framesPerSecond: frames !== null && window ? frames * 1_000 / window : null, + rtpStatsId: quality.rtpStatsId, rtpSsrc: quality.rtpSsrc, rtpMid: quality.rtpMid, rtpRid: quality.rtpRid, + trackIdentifier: quality.trackIdentifier, scalabilityMode: encoder.scalabilityMode, + encoderImplementation: encoder.encoderImplementation, powerEfficientEncoder: encoder.powerEfficientEncoder, + intervalFramesEncoded: null, intervalEncodeTimeMs: null, intervalFramesSent: frames, + intervalEncodeMs: encoder.intervalEncodeMs, qualityLimitationReason: quality.qualityLimitationReason, + nativeEdgeQualityState: quality.nativeEdgeQualityState, + }; +} diff --git a/src/client/media/host-provisional-child.ts b/src/client/media/host-provisional-child.ts index 094b10a..864247b 100644 --- a/src/client/media/host-provisional-child.ts +++ b/src/client/media/host-provisional-child.ts @@ -9,6 +9,7 @@ import { HostPeer, type HostMediaPeer } from "../webrtc/host-peer"; import type { BrowserVideoCodecPreference } from "../webrtc/video-codec"; import type { PeerSnapshot } from "../types"; import type { QualityProfile } from "./quality"; +import type { BrowserEncodingPool } from "./browser-encoding-pool"; interface HostProvisionalInput { revision: number; @@ -24,6 +25,7 @@ interface HostProvisionalPrepareInput extends HostProvisionalInput { profile: QualityProfile; videoCodec: BrowserVideoCodecPreference; natPredictionEnabled: boolean; + videoPool?: BrowserEncodingPool; } interface HostProvisionalChildEvents { @@ -139,6 +141,7 @@ export class HostProvisionalChild { input.videoCodec, candidate.connectionId, input.natPredictionEnabled, + candidate.qualityProbe ? null : input.videoPool ?? null, ); this.signalingPeer = peer; this.prepared = { diff --git a/src/client/media/quality.ts b/src/client/media/quality.ts index 0a06227..d8c8441 100644 --- a/src/client/media/quality.ts +++ b/src/client/media/quality.ts @@ -8,7 +8,8 @@ import { } from "../../shared/protocol"; import { joinItems, say, type CopyKey } from "../ui/copy"; import { displayMediaOptions } from "./audio-capture"; -import { debugError, debugEvent } from "../lib/debug"; +import { browserDebugEnabled, debugOperation } from "../lib/debug"; +import { debugTrack } from "../lib/debug-webrtc"; export type { DegradationPreference, @@ -190,25 +191,30 @@ export async function captureDisplay( throw new Error(say("host.capture.unavailable")); } - debugEvent("capture", "requested", { resolution: profile.resolution, maxFramerate: profile.maxFramerate }); + const complete = debugOperation("capture", "display", { requested: profile }); let stream: MediaStream; try { stream = await navigator.mediaDevices.getDisplayMedia(displayMediaOptions(captureConstraints(profile))); } catch (error) { - debugError("capture", "failed", error); + complete("failed", {}, error); throw error; } const videoTrack = stream.getVideoTracks()[0]; if (!videoTrack) { stream.getTracks().forEach((track) => track.stop()); + complete("failed", { reason: "no-video-track" }); throw new Error(say("host.capture.noSource")); } videoTrack.contentHint = "motion"; for (const audioTrack of stream.getAudioTracks()) { audioTrack.contentHint = "music"; } - debugEvent("capture", "started", { audio: stream.getAudioTracks().length > 0 }); + complete("applied", { audio: stream.getAudioTracks().length > 0, trackId: videoTrack.id }); + if (browserDebugEnabled) for (const track of stream.getTracks()) { + debugTrack(track, { event: "started" }); + for (const event of ["mute", "unmute", "ended"]) track.addEventListener(event, () => debugTrack(track, { event })); + } return stream; } @@ -239,12 +245,13 @@ export async function applyVideoCaptureProfile( if (!videoTrackOwnsCaptureConstraints(track)) { return; } - debugEvent("quality", "capture-requested", { resolution: profile.resolution, maxFramerate: profile.maxFramerate }); + const complete = debugOperation("quality", "capture", { requested: profile, trackId: track.id }); try { await track.applyConstraints(captureConstraints(profile)); - debugEvent("quality", "capture-applied", { resolution: profile.resolution, maxFramerate: profile.maxFramerate }); + complete("applied", { trackId: track.id }); + debugTrack(track, { event: "profile-applied" }); } catch (error) { - debugError("quality", "capture-failed", error); + complete("failed", { trackId: track.id }, error); throw error; } } @@ -264,6 +271,7 @@ export function setMediaPaused(stream: MediaStream, paused: boolean): boolean { } for (const track of stream.getTracks()) { track.enabled = !paused; + debugTrack(track, { event: "pause", paused }); } return true; } @@ -345,6 +353,7 @@ function sameParameter( export async function configureVideoSender( sender: RTCRtpSender, profile: QualityProfile, + scaleResolutionDownBy?: number, ): Promise { const parameters = sender.getParameters(); if (parameters.encodings.length === 0) { @@ -356,7 +365,7 @@ export async function configureVideoSender( parameters.encodings[encodingIndex]!.maxFramerate = profile.maxFramerate; if (parameters.encodings.length === 1) { parameters.encodings[encodingIndex]!.scaleResolutionDownBy = - requestedScaleResolutionDownBy(sender, profile); + scaleResolutionDownBy ?? requestedScaleResolutionDownBy(sender, profile); } parameters.degradationPreference = profile.degradationPreference; diff --git a/src/client/native/client.ts b/src/client/native/client.ts index 16ddd54..da913c2 100644 --- a/src/client/native/client.ts +++ b/src/client/native/client.ts @@ -1,5 +1,5 @@ import type { z } from "zod"; -import { debugEvent } from "../lib/debug"; +import { debugError, debugEvent } from "../lib/debug"; import type { IceConfig, @@ -552,29 +552,31 @@ export class NativeClient { return Promise.reject(new Error("Screener Client is unavailable")); } const id = createOpaqueId(); + const began = performance.now(); + const details = { type, requestId: id }; return new Promise((resolveRequest, rejectRequest) => { const timer = timeoutMs === null ? null : window.setTimeout(() => { this.pending.delete(id); - debugEvent("native", "request-timeout", { type }); + debugEvent("native", "request-timeout", { ...details, durationMs: performance.now() - began }); rejectRequest(new Error("Screener Client request timed out")); }, timeoutMs); this.pending.set(id, { schema, resolve: (value) => { - debugEvent("native", "response", { type }); + debugEvent("native", "response", { ...details, durationMs: performance.now() - began, applied: value }); resolveRequest(value as T); }, reject: (error) => { - debugEvent("native", "request-failed", { type }); + debugError("native", "request-failed", error, { ...details, durationMs: performance.now() - began }); rejectRequest(error); }, timer, }); try { - debugEvent("native", "request", { type }); + debugEvent("native", "request", { ...details, requested: fields, timeoutMs }); this.socket.send( JSON.stringify({ version: NATIVE_CLIENT_PROTOCOL, @@ -583,10 +585,10 @@ export class NativeClient { ...fields, }), ); - } catch { + } catch (error) { if (timer !== null) window.clearTimeout(timer); this.pending.delete(id); - debugEvent("native", "request-failed", { type }); + debugError("native", "request-failed", error, { ...details, durationMs: performance.now() - began }); rejectRequest(new Error("Screener Client request failed")); } }); @@ -597,6 +599,7 @@ export class NativeClient { try { value = JSON.parse(String(raw)); } catch { + debugEvent("native", "protocol-failed", { stage: "json-invalid" }); this.failConnection(); return; } @@ -606,26 +609,26 @@ export class NativeClient { if (!pending) return; this.pending.delete(id); if (pending.timer !== null) window.clearTimeout(pending.timer); - if (requestFailedResponseSchema.safeParse(value).success) { - pending.reject(new Error("Screener Client request failed")); + const failure = requestFailedResponseSchema.safeParse(value); + if (failure.success) { + pending.reject(new Error("Screener Client request failed", { cause: { code: failure.data.code } })); return; } const parsed = pending.schema.safeParse(value); if (parsed.success) { pending.resolve(parsed.data); } else { - pending.reject(new Error("Screener Client response is invalid")); + pending.reject(new Error("Screener Client response is invalid", { cause: parsed.error })); } return; } const event = nativeEventSchema.safeParse(value); if (!event.success) { + debugError("native", "protocol-failed", event.error, { stage: "event-invalid" }); this.failConnection(); return; } - if (event.data.type === "capture-state" || event.data.type === "edge-state" || event.data.type === "publication-state") { - debugEvent("native", "state", { type: event.data.type, state: event.data.state }); - } else if (event.data.type === "share-ended") debugEvent("native", "share-ended"); + debugEvent("native", "event", event.data); for (const listener of this.listeners) { listener(event.data); } diff --git a/src/client/native/media-bridge.ts b/src/client/native/media-bridge.ts index 7fb9d52..34045a0 100644 --- a/src/client/native/media-bridge.ts +++ b/src/client/native/media-bridge.ts @@ -1,6 +1,7 @@ import type { SignalPayload } from "../../shared/protocol"; import { createOpaqueId } from "../lib/opaque-id"; import { browserDebugEnabled, debugError, debugEvent } from "../lib/debug"; +import { observeDebugConnection } from "../lib/debug-webrtc"; import type { ConnectionMetrics } from "../types"; import { collectConnectionMetrics, @@ -48,7 +49,9 @@ export class NativeMediaBridge { private readonly onFailed: () => void, private readonly expectedAudio = false, private readonly sourceConnectionId?: string, - ) {} + ) { + observeDebugConnection(this.peer, { connectionId: this.connectionId, sourceConnectionId, shareId, role: "native-preview" }); + } collectMetrics(accumulator: StatsAccumulator): Promise { return collectConnectionMetrics(this.peer, "receive", accumulator); diff --git a/src/client/pages/HostPage.tsx b/src/client/pages/HostPage.tsx index 9db659f..614382a 100644 --- a/src/client/pages/HostPage.tsx +++ b/src/client/pages/HostPage.tsx @@ -78,6 +78,7 @@ import { type HostCreationProfile, } from "../lib/creation-profile"; import { createOpaqueId } from "../lib/opaque-id"; +import { debugEvent, debugOperation } from "../lib/debug"; import { defaultHostDisplayName, readDisplayName, @@ -124,6 +125,7 @@ import { type ScreenAudioQuality, } from "../media/quality"; import { HostSfuRoute } from "../media/host-sfu-route"; +import { BrowserEncodingPool } from "../media/browser-encoding-pool"; import { HostProvisionalChild, } from "../media/host-provisional-child"; @@ -565,6 +567,7 @@ export function HostPage({ }, [hasCustomDisplayName, lang, vis]); const iceConfigRef = useRef(null); const peersRef = useRef(new Map()); + const browserVideoPoolRef = useRef(null); const hostProvisionalChildRef = useRef(null); const activeHostChildPeerIdsRef = useRef([]); const endpointMediaCopyCapacityRef = useRef(MAX_ENDPOINT_MEDIA_CHILDREN); @@ -708,6 +711,8 @@ export function HostPage({ peersRef.current.clear(); hostProvisionalChildRef.current?.discard(); hostProvisionalChildRef.current = null; + browserVideoPoolRef.current?.dispose(); + browserVideoPoolRef.current = null; activeHostChildPeerIdsRef.current = []; endpointMediaCopyCapacityRef.current = MAX_ENDPOINT_MEDIA_CHILDREN; hostPeerIdRef.current = null; @@ -888,6 +893,8 @@ export function HostPage({ peersRef.current.clear(); hostProvisionalChildRef.current?.discard(); hostProvisionalChildRef.current = null; + browserVideoPoolRef.current?.dispose(); + browserVideoPoolRef.current = null; activeHostChildPeerIdsRef.current = []; endpointMediaCopyCapacityRef.current = MAX_ENDPOINT_MEDIA_CHILDREN; hostPeerIdRef.current = null; @@ -1603,6 +1610,7 @@ export function HostPage({ return; } if (qualityChangeRef.current) { + debugEvent("quality", "queued", { requested: nextProfile, generation: activeGenerationRef.current }); pendingQualityChangeRef.current = nextProfile; return; } @@ -1623,6 +1631,10 @@ export function HostPage({ return; } const token = {}; + const complete = debugOperation("quality", "update", { generation, + requested: nextProfile, previous: previousProfile, native: nativeModeRef.current }); + let outcome = "superseded"; + let failure: unknown; qualityChangeRef.current = token; setChangingQuality(true); setNotice(null); @@ -1666,6 +1678,8 @@ export function HostPage({ } commitQuality(appliedProfile); + debugEvent("quality", "committed", { generation, applied: appliedProfile }); + outcome = "applied"; if (nativeUpdate) { setDetails(nativeCaptureDetails(appliedProfile, activeStream)); } else if (captureChanged) { @@ -1711,6 +1725,8 @@ export function HostPage({ streamRef.current === activeStream ) { const failed = results.filter((updated) => !updated).length; + if (failed > 0 || !sfuUpdated) outcome = "partial"; + debugEvent("quality", "sender-results", { generation, failed, sfuUpdated, senderCount: results.length }); const sfuWarning = activeSfuRoute && hostSfuRouteRef.current === activeSfuRoute ? syncHostSfuQualityWarning(activeSfuRoute, generation) @@ -1730,6 +1746,8 @@ export function HostPage({ setNotice(connectionWarning ?? (sfuWarning ? null : successNotice)); } } catch (error) { + failure = error; + outcome = "failed"; if ( isCurrentGeneration(generation) && qualityChangeRef.current === token && @@ -1742,6 +1760,8 @@ export function HostPage({ setNoticeError(error, "quality"); } } finally { + complete(outcome, { generation, + ...(isCurrentGeneration(generation) ? { applied: qualitySettingsRef.current } : {}) }, failure); if (qualityChangeRef.current === token) { qualityChangeRef.current = null; const pending = pendingQualityChangeRef.current; @@ -1987,6 +2007,7 @@ export function HostPage({ profile: qualitySettingsRef.current, videoCodec: videoCodecRef.current, natPredictionEnabled: routePolicyRef.current.natPrediction, + videoPool: nativeClient && nativeShareGeneration ? undefined : browserVideoPool(), }); } @@ -2013,6 +2034,10 @@ export function HostPage({ } } + function browserVideoPool(): BrowserEncodingPool { + return browserVideoPoolRef.current ??= new BrowserEncodingPool(); + } + async function startPeer( peerId: string, generation: number, @@ -2086,6 +2111,7 @@ export function HostPage({ videoCodecRef.current, undefined, routePolicyRef.current.natPrediction, + browserVideoPool(), ); peersRef.current.set(peerId, peer); let started: boolean; diff --git a/src/client/sfu/peer.ts b/src/client/sfu/peer.ts index e5fdd9d..64556b6 100644 --- a/src/client/sfu/peer.ts +++ b/src/client/sfu/peer.ts @@ -1,3 +1,4 @@ +import { observeDebugConnection } from "../lib/debug-webrtc"; import type { ServerMessage, SfuMedia, @@ -32,6 +33,8 @@ export class SfuPeer { private config: SfuConnectionConfig, private readonly events: SfuPeerEvents, ) { + observeDebugConnection(this.pc, { connectionId: config.connectionId, + publicationGeneration: config.publicationGeneration, role: "sfu" }); this.pc.onicecandidate = ({ candidate }) => { if (this.closed || !candidate || candidate.protocol === "tcp") return; const value = { ...candidate.toJSON(), candidate: candidate.candidate }; diff --git a/src/client/sfu/publisher.ts b/src/client/sfu/publisher.ts index 5812726..9051fcc 100644 --- a/src/client/sfu/publisher.ts +++ b/src/client/sfu/publisher.ts @@ -1,5 +1,6 @@ import type { SfuMedia, SfuSignalMessage } from "../../shared/protocol"; import { say } from "../ui/copy"; +import { debugRtcFailure, debugRtcStats, debugTrack } from "../lib/debug-webrtc"; import { audioSenderParameterWarning, applyVideoCaptureProfile, @@ -509,6 +510,8 @@ export class SfuPublisher { this.statsInFlight = stats; try { const report = await peer.pc.getStats(); + debugRtcStats(peer.pc, report); + debugTrack(video, { event: "sfu-sample" }); if (this.peer !== peer || this.video !== video || this.stats !== stats) return; const native = collectNativeSenderQualityFromReport( @@ -552,8 +555,8 @@ export class SfuPublisher { this.startupPending = false; if (this.profile) void this.updateProfile(this.profile); } - } catch { - /* Diagnostics do not own media lifetime. */ + } catch (error) { + debugRtcFailure(peer.pc, error); } finally { if (this.statsInFlight === stats) this.statsInFlight = null; } diff --git a/src/client/sfu/subscriber.ts b/src/client/sfu/subscriber.ts index 7a868d3..a31a3fa 100644 --- a/src/client/sfu/subscriber.ts +++ b/src/client/sfu/subscriber.ts @@ -1,5 +1,6 @@ import type { SfuSignalMessage } from "../../shared/protocol"; import type { ConnectionMetrics } from "../types"; +import { debugRtcFailure, debugRtcStats } from "../lib/debug-webrtc"; import { collectConnectionMetricsFromReport, createStatsAccumulator, @@ -234,6 +235,7 @@ export class SfuSubscriber { this.statsInFlight = stats; try { const report = await peer.pc.getStats(); + debugRtcStats(peer.pc, report); if (this.peer !== peer || this.video !== video || this.stats !== stats) return; const metrics = collectConnectionMetricsFromReport( @@ -243,7 +245,8 @@ export class SfuSubscriber { ); this.events.onDecodedFrameSample?.(metrics.intervalFramesDecoded); this.events.onStats?.(metrics); - } catch { + } catch (error) { + debugRtcFailure(peer.pc, error); if (this.peer === peer && this.stats === stats) this.events.onDecodedFrameSample?.(null); } finally { diff --git a/src/client/webrtc/host-peer.ts b/src/client/webrtc/host-peer.ts index 9b563ea..5dafe28 100644 --- a/src/client/webrtc/host-peer.ts +++ b/src/client/webrtc/host-peer.ts @@ -5,6 +5,10 @@ import { type SignalPayload, } from "../../shared/protocol"; import { createOpaqueId } from "../lib/opaque-id"; +import type { BrowserEncodingPool, BrowserPooledSender } from "../media/browser-encoding-pool"; +import { BrowserEncodingOutput, encodedStreams, supportsBrowserEncoding } from "../media/browser-encoding-output"; +import { debugError, debugEvent } from "../lib/debug"; +import { debugTrack, observeDebugConnection } from "../lib/debug-webrtc"; import { audioSenderParameterWarning, applyVideoCaptureProfile, @@ -84,6 +88,9 @@ export class HostPeer { private replacementVideoTrack: MediaStreamTrack | null = null; private replacementAudioTrack: MediaStreamTrack | null = null; private videoSender: RTCRtpSender | null = null; + private pooledVideo: BrowserPooledSender | null = null; + private encodedOutput: BrowserEncodingOutput | null = null; + private readonly encodedStreamsEnabled: boolean; private audioTransceiver: RTCRtpTransceiver | null = null; private audioSender: RTCRtpSender | null = null; private statsTimer: number | null = null; @@ -120,6 +127,7 @@ export class HostPeer { VP8_ONLY_VIDEO_CODEC, connectionId = createOpaqueId(), natPredictionEnabled = false, + private readonly videoPool: BrowserEncodingPool | null = null, ) { this.connectionId = connectionId; this.natPredictionEnabled = natPredictionEnabled; @@ -129,13 +137,16 @@ export class HostPeer { ? cloneSenderVideoTrack(sourceVideoTrack) : null; this.startupVideoProfilePending = needsStartupVideoProfile(desiredProfile); + this.encodedStreamsEnabled = !!videoPool && supportsBrowserEncoding(); this.connection = new RTCPeerConnection({ + ...(this.encodedStreamsEnabled ? { encodedInsertableStreams: true } : {}), iceServers: iceServersWithNatPrediction( iceConfig.iceServers, this.natPredictionEnabled, iceConfig.natPredictionStunUrls, ), }); + observeDebugConnection(this.connection, { connectionId, peerId, role: "send" }); this.localIceCandidates = new NatPredictionCandidateEmitter( this.natPredictionEnabled, (candidate) => this.sendIceCandidate(candidate), @@ -175,11 +186,24 @@ export class HostPeer { return false; } this.videoSender = videoTransceiver.sender; + if (this.encodedStreamsEnabled) { + this.encodedOutput = new BrowserEncodingOutput(this.videoSender, () => { + if (!this.disposed) { this.dispose(); this.emit(); } + }, { connectionId: this.connectionId, peerId: this.peerId }); + } + this.attachVideoPool(this.stream.getVideoTracks()[0]!); this.audioTransceiver = this.connection.addTransceiver(audioTrack ?? "audio", { direction: audioTrack ? "sendonly" : "inactive", streams: [this.stream], }); this.audioSender = this.audioTransceiver.sender; + if (this.encodedStreamsEnabled) { + // The connection flag also owns audio, including a later source with audio. + const audio = encodedStreams(this.audioSender); + void audio.readable.pipeTo(audio.writable).catch(() => { + if (!this.disposed) { this.dispose(); this.emit(); } + }); + } await this.enqueueSenderMutation(async () => { if (this.disposed || !this.videoSender || !this.audioSender) { return false; @@ -265,7 +289,10 @@ export class HostPeer { this.senderVideoTrack = nextVideoTrack; this.stream = nextStream; retainedNextVideoTrack = true; - previousVideoTrack.stop(); + this.pooledVideo?.dispose(); + this.pooledVideo = null; + this.attachVideoPool(nextSourceVideoTrack); + if (previousVideoTrack !== this.encodedOutput?.track) previousVideoTrack.stop(); this.startupVideoProfilePending = needsStartupVideoProfile( this.desiredProfile, ); @@ -305,6 +332,7 @@ export class HostPeer { setPaused(paused: boolean): void { this.paused = paused; + this.pooledVideo?.setPaused(paused); this.applyPausedState(this.senderVideoTrack, this.audioSender?.track ?? null); this.applyPausedState(this.replacementVideoTrack, this.replacementAudioTrack); } @@ -472,6 +500,10 @@ export class HostPeer { this.statsTimer = null; } this.connection.close(); + this.pooledVideo?.dispose(); + this.pooledVideo = null; + this.encodedOutput?.dispose(); + this.encodedOutput = null; this.localIceCandidates.discard(); this.senderVideoTrack?.stop(); this.senderVideoTrack = null; @@ -512,6 +544,52 @@ export class HostPeer { this.connection.addEventListener("iceconnectionstatechange", () => this.emit()); } + private attachVideoPool(source: MediaStreamTrack): void { + const sender = this.videoSender; + if (!sender || !this.videoPool || !this.encodedOutput) return; + let binding: BrowserPooledSender | null = null; + const owns = () => !this.disposed && this.pooledVideo === binding && + this.videoSender === sender && this.stream.getVideoTracks()[0] === source; + const requestKey = () => this.enqueueSenderMutation(async () => { + if (!owns()) return; + const request = sender.setParameters as (parameters: RTCRtpSendParameters, + options: { encodingOptions: Array<{ keyFrame: boolean }> }) => Promise; + await request.call(sender, sender.getParameters(), { encodingOptions: [{ keyFrame: true }] }); + }); + binding = this.videoPool.create(source, sender, this.connection, this.desiredProfile, this.encodedOutput, + () => this.enqueueSenderMutation(async () => { + if (!owns()) return false; + const previous = this.senderVideoTrack!; + const next = binding?.carrierScale() === undefined ? cloneSenderVideoTrack(source) : this.encodedOutput!.track; + try { + this.applyPausedState(next, null); + await applyVideoCaptureProfile(next, this.desiredProfile); + if (!owns()) { if (next !== this.encodedOutput?.track) next.stop(); return false; } + if (next !== previous) await sender.replaceTrack(next); + await configureVideoSender(sender, + this.startupVideoProfilePending ? startupVideoProfile(this.desiredProfile) : this.desiredProfile, + binding?.carrierScale()); + if (!owns()) throw new DOMException("Retired Browser pool binding", "AbortError"); + this.senderVideoTrack = next; + if (previous !== next && previous !== this.encodedOutput?.track) previous.stop(); + this.statsAccumulator = createStatsAccumulator(); + return true; + } catch (error) { + if (!this.disposed && sender.track !== previous) { + try { await sender.replaceTrack(previous); } catch { this.dispose(); } + } + if (next !== previous && next !== this.encodedOutput?.track) next.stop(); + debugError("encoding-pool", "carrier-attachment-failed", error, { connectionId: this.connectionId }); + return false; + } + }), + requestKey, + () => { if (owns()) { this.dispose(); this.emit(); } }); + this.pooledVideo = binding; + if (!binding) this.encodedOutput.passthrough(requestKey); + binding?.setPaused(this.paused); + } + private sendIceCandidate(candidate: SignalCandidate | null): void { this.events.sendSignal(this.peerId, { kind: "candidate", @@ -678,6 +756,7 @@ export class HostPeer { }, ); const capture = captureMetrics(captureTrack); + debugTrack(captureTrack, { connectionId: this.connectionId, event: "sample" }); const metrics = { ...(await metricsPromise), ...capture }; if ( this.disposed || @@ -699,10 +778,11 @@ export class HostPeer { this.startupVideoProfilePending = false; void this.updateProfile(this.desiredProfile); } - this.updateLimitationWarning(metrics.qualityLimitationReason); + const deliveredMetrics = this.pooledVideo?.metrics(metrics) ?? metrics; + this.updateLimitationWarning(deliveredMetrics.qualityLimitationReason); this.snapshot = { ...this.snapshot, - metrics, + metrics: deliveredMetrics, ...this.qualityWarningSnapshot(), }; this.emit(); @@ -713,7 +793,8 @@ export class HostPeer { } } - private setError(_error: unknown, fallback: string): void { + private setError(error: unknown, fallback: string): void { + debugError("webrtc", "sender-failed", error, { connectionId: this.connectionId, reason: fallback }); this.snapshot = { ...this.snapshot, error: fallback }; this.emit(); } @@ -743,10 +824,11 @@ export class HostPeer { if (mutation.video) { try { - senderParameters = await configureVideoSender(sender, profile); + senderParameters = await configureVideoSender(sender, profile, this.pooledVideo?.carrierScale()); videoWarning = senderParameterWarning(senderParameters); - } catch { + } catch (error) { videoSucceeded = false; + debugError("webrtc", "sender-parameters-failed", error, { connectionId: this.connectionId, profileRevision, requested: profile }); videoWarning = say("host.err.applySender"); } } @@ -759,6 +841,11 @@ export class HostPeer { return false; } + if (mutation.video && videoSucceeded && this.pooledVideo) { + try { await this.pooledVideo.updateProfile(this.desiredProfile); } + catch { videoSucceeded = false; videoWarning = say("host.err.applySender"); } + } + if (mutation.audio && audioSender) { if (audioSender.track) { try { @@ -767,8 +854,9 @@ export class HostPeer { profile.screenAudioQuality, ); audioWarning = audioSenderParameterWarning(audioSenderParameters); - } catch { + } catch (error) { audioSucceeded = false; + debugError("webrtc", "audio-parameters-failed", error, { connectionId: this.connectionId, profileRevision }); audioWarning = say("host.err.applyAudioSender"); } } else { @@ -787,6 +875,8 @@ export class HostPeer { } this.videoSenderWarning = videoWarning; this.audioSenderWarning = audioWarning; + debugEvent("webrtc", "sender-parameters", { connectionId: this.connectionId, profileRevision, + requested: profile, videoSucceeded, audioSucceeded, senderParameters, audioSenderParameters }); if (mutation.video && videoSucceeded) { this.appliedVideoProfile = profile; } diff --git a/src/client/webrtc/stats.ts b/src/client/webrtc/stats.ts index 389c5c0..ffd93fa 100644 --- a/src/client/webrtc/stats.ts +++ b/src/client/webrtc/stats.ts @@ -5,6 +5,7 @@ import { import { packetLossPercentFromDeltas } from "../../shared/packet-loss"; import { deriveVideoCodecEvidence } from "../../shared/video-codec-evidence"; import { isPredictedCandidateFoundation } from "../../shared/nat-candidate"; +import { debugRtcFailure, debugRtcStats } from "../lib/debug-webrtc"; type StatsRecord = Record & { id: string; @@ -641,12 +642,14 @@ export async function collectConnectionMetrics( previous: StatsAccumulator, selector: StatsMediaSelector | null = null, ): Promise { - return collectConnectionMetricsFromReport( - await connection.getStats(), - direction, - previous, - selector, - ); + try { + const report = await connection.getStats(); + debugRtcStats(connection, report); + return collectConnectionMetricsFromReport(report, direction, previous, selector); + } catch (error) { + debugRtcFailure(connection, error); + throw error; + } } export function collectConnectionMetricsFromReport( diff --git a/src/client/webrtc/viewer-peer.ts b/src/client/webrtc/viewer-peer.ts index ca4e925..f549ebd 100644 --- a/src/client/webrtc/viewer-peer.ts +++ b/src/client/webrtc/viewer-peer.ts @@ -1,5 +1,7 @@ import { say } from "../ui/copy"; import type { IceConfig, SignalPayload } from "../../shared/protocol"; +import { debugError } from "../lib/debug"; +import { observeDebugConnection } from "../lib/debug-webrtc"; import { EMPTY_METRICS, type PeerSnapshot, @@ -255,6 +257,7 @@ export class ViewerPeer implements ViewerMediaPeer { this.currentIceConfig.natPredictionStunUrls, ), }); + observeDebugConnection(connection, { connectionId, parentPeerId, role: "receive" }); const localIceCandidates = new NatPredictionCandidateEmitter( this.natPredictionEnabled, (candidate) => { @@ -641,6 +644,7 @@ export class ViewerPeer implements ViewerMediaPeer { } private setError(error: unknown, fallback: string): void { + debugError("webrtc", "receiver-failed", error, { connectionId: this.connectionId, reason: fallback }); if (!this.snapshot) { return; } diff --git a/src/client/webrtc/viewer-relay.ts b/src/client/webrtc/viewer-relay.ts index ed39d5c..b1598be 100644 --- a/src/client/webrtc/viewer-relay.ts +++ b/src/client/webrtc/viewer-relay.ts @@ -4,6 +4,7 @@ import type { SignalPayload, } from "../../shared/protocol"; import type { QualityProfile } from "../media/quality"; +import { BrowserEncodingPool } from "../media/browser-encoding-pool"; import type { PeerSnapshot } from "../types"; import { HostPeer, @@ -51,6 +52,7 @@ export class ViewerRelay { private childPeerIds: string[] = []; private stream: MediaStream | null = null; private readonly peers = new Map(); + private readonly videoPool = new BrowserEncodingPool(); private readonly snapshots = new Map(); private preparedChild: PreparedChild | null = null; private preparedRevision: number | null = null; @@ -360,6 +362,7 @@ export class ViewerRelay { this.discardPreparedChild(); this.childPeerIds = []; this.disposePeers(); + this.videoPool.dispose(); } private startPreparedChild( @@ -587,6 +590,7 @@ export class ViewerRelay { this.videoCodec, connectionId, this.natPredictionEnabled, + candidate?.qualityProbe ? null : this.videoPool, ); return peer; } diff --git a/tests/browser-debug.test.ts b/tests/browser-debug.test.ts index 982b628..2de4deb 100644 --- a/tests/browser-debug.test.ts +++ b/tests/browser-debug.test.ts @@ -1,88 +1,167 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -function browser(search: string) { +function browser(search = "?debug=1") { const page = Object.assign(new EventTarget(), { - location: new URL( - `https://private.example/r/1234${search}#v=private-grant`, - ), + location: new URL(`https://private.example/r/1234${search}#v=private-grant`), + setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout, }) as unknown as Window; vi.stubGlobal("window", page); + vi.stubGlobal("document", { visibilityState: "visible" }); + vi.stubGlobal("navigator", { userAgent: "test-browser", language: "en", onLine: true }); + vi.spyOn(console, "info").mockImplementation(() => undefined); return page; } afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.resetModules(); }); describe("opt-in Browser diagnostics", () => { - it("is quiet without the explicit page flag", async () => { + it("does no diagnostic collection without the explicit flag", async () => { const page = browser(""); - const logged = vi - .spyOn(console, "info") - .mockImplementation(() => undefined); const debug = await import("../src/client/lib/debug"); expect(debug.installBrowserDebug()).toBeUndefined(); debug.debugEvent("capture", "requested", { resolution: "1080p" }); debug.debugError("capture", "failed", new Error("private detail")); - expect(logged).not.toHaveBeenCalled(); + expect(console.info).not.toHaveBeenCalled(); expect(page.__SCREENER_DEBUG__).toBeUndefined(); }); - it("keeps a bounded detached snapshot and excludes unapproved data and raw errors", async () => { - const page = browser("?debug=1&hostToken=private-token"); - vi.spyOn(console, "info").mockImplementation(() => undefined); + it("retains error causes, stacks and technical context while removing credentials and media", async () => { + const page = browser(); const debug = await import("../src/client/lib/debug"); const cleanup = debug.installBrowserDebug(); - const captureError = new DOMException( - "candidate:1 1 UDP 1 192.0.2.1 1234 typ host", - "NotReadableError", - ); - debug.debugError("capture", "failed", captureError, { - resolution: "1080p", - maxFramerate: 30, - ...{ - hostToken: "private-token", - sdp: "private-sdp", - candidate: "private-candidate", - inviteUrl: "private-grant", - }, + const error = new Error('Encoding failed: {"password":"secret-password"}', { + cause: new Error("fetch https://user:secret-auth@example.test/api?token=secret-query#secret-fragment failed; Bearer secret-bearer"), }); - const report = page.__SCREENER_DEBUG__!.export(); - expect(report).toContain("NotReadableError"); - for (const secret of [ - "private-token", - "private-sdp", - "private-candidate", - "private-grant", - "192.0.2.1", - "private.example", - "/r/1234", - ]) + debug.debugError("capture", "failed", error, { + connectionId: "connection-123", address: "192.0.2.1", requested: { width: 1920, fps: 60 }, + hostToken: "secret-host", nested: { icePwd: "secret-ice", cookie: "secret-cookie", sdp: "secret-sdp", remoteSdp: "secret-remote-sdp" }, + data: "secret-pixels", payload: "secret-audio", + viewerGrant: "secret-grant", passwordHash: "secret-hash", + transportError: "screener-client-v9.secret-capability Basic secret-basic\nCookie: session=secret-session; refresh=secret-refresh", + }); + const report = await page.__SCREENER_DEBUG__!.export(); + for (const secret of ["secret-password", "secret-auth", "secret-query", "secret-fragment", "secret-bearer", + "secret-host", "secret-ice", "secret-cookie", "secret-sdp", "secret-remote-sdp", "secret-pixels", "secret-audio", "private-grant", + "secret-grant", "secret-hash", "secret-capability", "secret-basic", "secret-session", "secret-refresh"]) expect(report).not.toContain(secret); - const forgedError = new Error("private-message"); - forgedError.name = "private-token"; - debug.debugError("capture", "failed", forgedError); - expect(page.__SCREENER_DEBUG__!.events().at(-1)!.details.errorName).toBe( - "Error", - ); - for (let revision = 0; revision < 300; revision++) - debug.debugEvent("signal", "route", { revision }); - const events = page.__SCREENER_DEBUG__!.events(); - expect(events).toHaveLength(256); - expect(events[0]!.details.revision).toBe(44); - events[0]!.details.revision = -1; - expect(page.__SCREENER_DEBUG__!.events()[0]!.details.revision).toBe(44); - debug.debugEvent("signal", "route", { - state: "x".repeat(1000), - revision: Number.NaN, - }); - expect(page.__SCREENER_DEBUG__!.events().at(-1)!.details).toEqual({}); - page.__SCREENER_DEBUG__!.clear(); - expect(page.__SCREENER_DEBUG__!.events()).toEqual([]); + for (const useful of ["Encoding failed", "fetch", "stack", "cause", "connection-123", "192.0.2.1", "1920"]) + expect(report).toContain(useful); cleanup?.(); - page.dispatchEvent(new Event("error")); - expect(page.__SCREENER_DEBUG__!.events()).toEqual([]); + }); + + it("bounds history and reports eviction, truncation, retained range and collector failure", async () => { + const page = browser(); + const debug = await import("../src/client/lib/debug"); + debug.installBrowserDebug(); + for (let revision = 0; revision < 8_300; revision++) debug.debugEvent("signal", "route", { revision }); + const saved = page.__SCREENER_DEBUG__!.events(); + expect(saved).toHaveLength(8_192); + saved[0]!.details.revision = -1; + expect(page.__SCREENER_DEBUG__!.events()[0]!.details.revision).toBe(108); + // Exercise the byte limit separately from the event-count limit. + for (let index = 0; index < 1_100; index++) debug.debugEvent("capture", "state", { message: "界".repeat(9_000) }); + debug.debugError("webrtc", "collector-failed", new Error("stats unavailable"), { collector: "getStats", connectionId: "edge" }); + const report = JSON.parse(await debug.exportBrowserDebug()); + expect(report.history.retainedBytes).toBeLessThanOrEqual(8 * 1024 * 1024); + expect(report.history.retainedEvents).toBeLessThan(1_100); + expect(report.history.evictedEvents).toBeGreaterThan(108); + expect(report.history.truncatedEvents).toBe(1_100); + expect(report.history.firstSequence).toBe(report.events[0].sequence); + expect(report.history.lastAt).toBe(report.events.at(-1).at); + expect(report.partial).toBe(true); + expect(report.collectorErrors).toContainEqual(expect.objectContaining({ collector: "getStats", connectionId: "edge" })); + }); + + it("returns retained evidence when optional Browser metadata never completes", async () => { + browser(); + vi.useFakeTimers(); + vi.stubGlobal("navigator", { userAgent: "known-browser", userAgentData: { + getHighEntropyValues: () => new Promise(() => undefined), + } }); + const debug = await import("../src/client/lib/debug"); + debug.debugEvent("capture", "active", { width: 1280 }); + const exporting = debug.exportBrowserDebug(); + await vi.advanceTimersByTimeAsync(1_000); + const report = JSON.parse(await exporting); + expect(report.environment.userAgent).toBe("known-browser"); + expect(report.events).toHaveLength(1); + expect(report.partial).toBe(true); + expect(report.collectorErrors[0].collector).toBe("environment"); + expect(vi.getTimerCount()).toBe(0); + }); + + it("correlates existing RTC samples and DTLS events without adding statistics polls", async () => { + const page = browser(); + vi.useFakeTimers(); + const debug = await import("../src/client/lib/debug"); + const rtc = await import("../src/client/lib/debug-webrtc"); + debug.installBrowserDebug(); + const transport = Object.assign(new EventTarget(), { state: "connecting" }); + const connection = Object.assign(new EventTarget(), { + connectionState: "connecting", iceConnectionState: "checking", iceGatheringState: "complete", signalingState: "stable", + getSenders: () => [{ transport }], getReceivers: () => [], getStats: vi.fn(), + }); + const peer = connection as unknown as RTCPeerConnection; + rtc.observeDebugConnection(peer, { connectionId: "local-edge", role: "send" }); + transport.state = "connected"; + transport.dispatchEvent(new Event("statechange")); + rtc.debugRtcStats(peer, new Map([ + ["video", { id: "video", type: "outbound-rtp", framesEncoded: 60, totalEncodeTime: 0.3 }], + ["codec", { id: "codec", type: "codec", mimeType: "video/H264", sdpFmtpLine: "packetization-mode=1;profile-level-id=42e01f" }], + ["private", { id: "private", type: "certificate", base64Certificate: "private-certificate" }], + ]) as unknown as RTCStatsReport); + await vi.advanceTimersByTimeAsync(5_000); + expect(connection.getStats).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + const history = page.__SCREENER_DEBUG__!.events(); + expect(history.filter((event) => event.scope === "webrtc").every((event) => event.details.connectionId === "local-edge")).toBe(true); + expect(history).toContainEqual(expect.objectContaining({ event: "dtls-state", details: expect.objectContaining({ state: "connected" }) })); + const report = await debug.exportBrowserDebug(); + expect(report).toContain("framesEncoded"); + expect(report).toContain("packetization-mode=1;profile-level-id=42e01f"); + expect(report).not.toContain("private-certificate"); + }); + + it("correlates actual Native request and response using their existing wire identity", async () => { + const page = browser(); + const token = "x".repeat(43); + class Socket extends EventTarget { + static OPEN = 1; static CLOSING = 2; + readyState = 1; protocol = `screener-client-v9.${token}`; + constructor() { super(); queueMicrotask(() => this.dispatchEvent(new Event("open"))); } + close() { this.readyState = 3; } + send(raw: string) { + const request = JSON.parse(raw); + queueMicrotask(() => this.dispatchEvent(Object.assign(new Event("message"), { + data: JSON.stringify({ version: 9, id: request.id, + type: request.type === "hello" ? "ready" : "share-updated", + ...(request.shareId ? { shareId: request.shareId } : {}) }), + }))); + } + } + vi.stubGlobal("WebSocket", Socket); + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ + protocol: 9, service: "screener-client", port: 39721, instanceToken: token, + nativeMedia: { video: true, processAudio: false, systemAudio: true, hardwareH264: true, softwareVP8: true }, + }), { status: 200 }))); + const debug = await import("../src/client/lib/debug"); + debug.installBrowserDebug(); + const { NativeClient } = await import("../src/client/native/client"); + const { DEFAULT_QUALITY_SETTINGS } = await import("../src/shared/protocol"); + const client = await NativeClient.connect(); + expect(client).not.toBeNull(); + try { await client!.updateShare("share_123456", DEFAULT_QUALITY_SETTINGS); } + finally { client!.close(); } + const history = page.__SCREENER_DEBUG__!.events().filter((event) => event.details.type === "update-share"); + expect(history.map((event) => event.event)).toEqual(["request", "response"]); + expect(history[1]!.details.requestId).toBe(history[0]!.details.requestId); + expect(history[1]!.details.durationMs).toBeGreaterThanOrEqual(0); + expect(history[0]!.details.requested).toMatchObject({ profile: DEFAULT_QUALITY_SETTINGS }); + expect(await debug.exportBrowserDebug()).not.toContain(token); }); }); diff --git a/tests/host-quality-ownership.test.ts b/tests/host-quality-ownership.test.ts index c941f7d..e905e8d 100644 --- a/tests/host-quality-ownership.test.ts +++ b/tests/host-quality-ownership.test.ts @@ -8,6 +8,7 @@ import { type QualitySettings, } from "../src/client/media/quality"; import { NativeSenderPeer } from "../src/client/native/native-sender-peer"; +import { debugEvent, debugOperation } from "../src/client/lib/debug"; // Exercise the actual page owners without mounting capture hardware or a Browser. const source = ts.createSourceFile("HostPage.tsx", readFileSync( @@ -50,6 +51,7 @@ function fixture() { stopReceive: vi.fn(async () => undefined), stopShare: vi.fn(async () => undefined) }; const route = { updateProfile: vi.fn(async () => true), resyncAuthoritative: vi.fn(async (): Promise => undefined) }; const state = { + debugEvent, debugOperation, phase: "live", qualitySettingsRef: ref(original), advancedQualityRef: ref(original), qualityChangeRef: ref(null), pendingQualityChangeRef: ref(null), activeGenerationRef: ref(1), streamRef: ref(stream), sourceSwitchRef: ref(null),