phase: 06_sharer_quality_presets
Build and Push Containers / test (push) Successful in 1m12s
Build and Push Containers / build-and-push-app (push) Successful in 9s

**Phase 06 — final verification pass: 100% green.**

- **Verified (all 6 tasks complete in working tree):** `#quality` select + `PRESETS` table; preset-driven capture request + `contentHint`; per-leg `legCap()`/`degradation` at both call sites; live `applyQuality()` with Overconstrained fallback + republish; `q` key cycle; `#stat-quality`/`#stat-codec` readouts; full e2e matrix (requests, committed params ×3, zero-cap, mid-share switch, unrescalable device, late-joiner first-offer, readout, `q`); realdisplay delivered-pixels `light` case. `test_sender_fidelity.py` **untouched and green** (regression canary).
- **Gates:** `uv run ruff check . && uv run ruff format --check .` → clean (32 files); `uv run pyright` → 0 errors; `uv run pytest` → **284 passed, 5 skipped (realdisplay-gated), coverage 95%** (93s); `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → **4 passed, 1 skipped** (no audio source on host — documented platform condition).
- **Completion criteria:** all 11 ✓ — sharp default reproduces old request; light ≤960×600@15fps/motion/round(cap×0.35)/maintain-framerate with moving viewer frames; mid-share re-prices with `peers.size` unchanged, no renegotiation, roster matches `getSettings()`; refused-rescale toasts "next share"; `q` cycles + toasts, text-field guard holds; readout exact + codec shape, both reset to `—` idle; realdisplay: light decoded ≤990px (measured 320×200), sharp clears `SHARP_FLOOR_PX`; no new env/wire/Python/asset; suite 100% green, coverage >90%.
- **Docs:** README.md reviewed in full against code — Quality table, `q` key, env-var table, routes (`/share`), kiosk commands, key table, doc paths all consistent; **no fixes needed**. AGENTS.md — verified, no stale statements, no changes. PLAN.md untouched (diff = 0).
- **Reported (per phase, not edited):** PLAN.md §6 line 204 still says "per-leg `maxBitrate` cap (default **12 Mbit/s**)" — code has defaulted to **25 Mbit/s** since phase 02; needs a user-approved one-line amendment.
- **Next pending phase:** none — 06 is the last phase in `todo/`; the roadmap is complete.
This commit is contained in:
2026-11-02 22:07:17 -05:00
parent f51d0fe9ae
commit 3f93481c54
44 changed files with 2107 additions and 54 deletions
@@ -0,0 +1,121 @@
# Phase 06 — The sharer picks the quality/CPU trade
**Source:** chat — "I'm sharing my screen and it's taking a lot of cpu resources. Anything we can
do about that?" Measured on the reporting machine while it shared: one LibreWolf content process at
**~105% CPU**, hot threads `WebrtcWorker #105 ×9, #107, #108, #109` — i.e. the *sender-side video
encoder*, not the page and not the Python server (A3: media never touches the server). The same
machine drives a 2560×1600 panel and a 3840×2160 primary, so `pickSurface()` asks for ~8.3 Mpx at
30 fps and phase 02 then told the encoder it may never buy relief with resolution.
**Story:** n/a
**Context:** `PLAN.md` §2 A3 (mesh, one `RTCPeerConnection` per viewer → one encode per leg), A9/A10
(hand-written ES modules, no new assets or dependencies), A12 (env config; this phase adds none),
§6 ("Mesh upload = N × bitrate per sharer"), §7 item 4 (capacity & fairness — deliberately *not*
this phase); `AGENTS.md` §5/§6 (media asserted on the viewer, `node --check`, element/id guards);
`src/mirror/static/share.js` (`pickSurface`, `applySenderQuality`, `addPeer`, `sampleStats`, the
share-page key router), `src/mirror/static/mirror.html` (the Capture panel), `src/mirror/config.py`
(`max_bitrate_bps` → `/api/config`), `.agents/phases/complete/02_sharp_capture`
## Objective
`/mirror` grows a **Quality** control — `Sharp · Balanced · Light` — so a sharer can spend pixels,
frame rate and bitrate for *their* machine instead of inheriting one global server default. Each
preset maps to a capture pixel budget + frame rate, a per-leg bitrate factor, a `contentHint` and a
`degradationPreference`, applies live where the engine allows, and is readable in the panel
alongside the codec actually in use.
`Sharp` **is** today's behaviour and stays the default, so nothing a completed phase proved gets
worse; the new presets only ever ask for less.
## Dependencies
- `02_sharp_capture` (complete) — the code this phase parameterises (`pickSurface`'s size request,
`applySenderQuality`, `sendEncodings`) and the sharpness floor it must not regress:
`tests/e2e/test_sender_fidelity.py` asserts `degradationPreference === "maintain-resolution"` and
`maxBitrate == /api/config.maxBitrateBps` on the **default** preset, with two legs.
- `01_keyboard_kiosk_control` (complete) — the share page's own key router (`s`/`x`/`Esc`), which
the new `q` joins under the same contract.
- `05_autohide_stage_controls` (complete) — the current `tests/e2e/` two-context fixture style this
phase's suite copies.
## Tasks
1. `01_quality_selector_and_preset_table.md` — the `#quality` select in the Capture panel plus the
`PRESETS` table in `share.js`; no capture or encoder behaviour changes yet
2. `02_capture_constraints_from_the_preset.md` — `pickSurface()` builds its size/frame-rate request
(and the track's `contentHint`) from the selected preset
3. `03_sender_parameters_from_the_preset.md` — per-leg `maxBitrate` factor and
`degradationPreference` come from the preset, at both call sites
4. `04_apply_live_and_republish.md` — a preset change while live re-parameterises every existing
leg and re-applies the capture constraints without a renegotiation; `q` cycles it
5. `05_quality_and_codec_readout.md` — `#stat-quality` / `#stat-codec` in the panel, fed by the
existing 1 s stats tick
6. `06_preset_e2e_suite_and_docs.md` — the full preset matrix in the browser, a `realdisplay`
delivered-pixels case, and the README/SECURITY consistency pass
## Scope boundaries (explicit)
- **No new environment variable, no new wire field, no Python behaviour change.**
`MIRROR_MAX_BITRATE` stays the per-leg ceiling and each preset's factor is applied to it, so one
server knob still governs the fleet. A12/A5/A6 untouched.
- **No watcher-count fairness.** Dividing the cap by viewer count would change
`test_two_live_legs_report_maintain_resolution_and_the_cap`, i.e. the behaviour a completed phase
asserts. That is PLAN §7 item 4 and needs the user's decision first, so it is a separate phase.
- **No codec preference (`setCodecPreferences`).** Measured on the reporting machine, VA-API on both
GPUs exposes `AV1Profile0 : VAEntrypointEncSlice` and **no** H.264/HEVC encode entrypoint, and
Firefox's VA-API encode path is H.264-only — so from LibreWolf there is no hardware encoder to
prefer. A codec-preference phase needs a measured problem on the Chromium side.
- **No memory of the choice** (no `localStorage`): a silently persisted `Light` is a worse wall for
the next person than one extra click.
- **No change to the default.** Default `sharp` reproduces phase 02 byte for byte.
## Testing & Quality
- Unit/integration: the media behaviour is frontend — per `PLAN.md` §5 it is asserted **in the
browser**, on the **viewer's** `<video>` (decoded size, frames that differ), never by unit tests
pretending to be WebRTC. Structure guards live in `tests/test_app.py`: the `$("#id")` existence
test, the cross-page id-collision test (`{"net","notice","toast"}` only), the
`test_share_never_asks_for_an_exact_capture_size` guard (presets use `ideal`, never `exact`), and
one new guard that the option values in `mirror.html` and the keys of `PRESETS` are the same three
strings (one list, two places, one test that notices — same spirit as the bitrate-default test).
- Coverage: **>90%** on new/modified code.
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the
project, not just this phase's), including `uv run pytest -m e2e` and
`MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay`, plus the other three gates of `AGENTS.md` §4.
- `tests/e2e/test_sender_fidelity.py` is the regression canary for this phase and must stay green
**unmodified**.
## Documentation & Consistency
- The final pass reviews `README.md` in full against the project as it stands after this phase —
fixing every inconsistency found, **including pre-existing ones not caused by this phase**. For
`AGENTS.md` it fixes only *stale statements* (a dead command, a changed rule, an outdated fact)
and **never adds or expands its content** (a file that states its own update policy, e.g. "updated
rarely, if ever", wins).
- Tasks update `README.md` immediately when their own changes are user-visible (setup, commands,
configuration, features, conventions), and `AGENTS.md` only when a statement in it became stale.
- `SECURITY.md` currently says `MIRROR_MAX_BITRATE` "bounds per-viewer upload"; after task 03 that
bound is factor-scaled by the sharer's preset. Task 06 makes that sentence true again.
- `PLAN.md` §6's "per-leg `maxBitrate` cap (default 12 Mbit/s)" is already stale (phase 02 raised the
default to 25 Mbit/s). This phase must **not** edit `PLAN.md`; report it to the user instead.
## Completion Criteria
- [ ] `/mirror` renders a `#quality` control with exactly `sharp|balanced|light`; `sharp` is the
selected default and reproduces today's request (3840×2160 cap, `frameRate.ideal 30`) — proven
by `tests/e2e/test_sender_fidelity.py` passing unmodified
- [ ] selecting `light` **before** sharing requests ≤ 960×600 at 15 fps (`__probe.lastRequested`),
sets `contentHint: "motion"`, and every leg commits `maxBitrate == round(cap × 0.35)` with
`degradationPreference == "maintain-framerate"` — while the viewer still shows ≥2 distinct
frames
- [ ] changing the preset **mid-share** re-parameterises the existing legs with no new
`RTCPeerConnection` (`#viewer-count` and `__probe.peers.size` unchanged) and no renegotiation,
and the viewer keeps moving
- [ ] where the engine honours `applyConstraints`, the capture follows without a new picker and
`/api/streams` width/height match the sharer's `track.getSettings()` (the republish happened);
where it does not, the panel toasts that the change lands on the next share — no silent lie
- [ ] `q` cycles the preset on the share page and toasts the new one; keys typed in `#name` and the
select remain text, not commands
- [ ] `#stat-quality` shows the effective budget and `#stat-codec` shows the negotiated codec, or `—`
where the engine does not report it; both reset to `—` when idle
- [ ] `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` proves delivered pixels follow the preset:
`light` decodes at or under its cap, `sharp` still clears `SHARP_FLOOR_PX`
- [ ] no new env var, no new wire field, no Python behaviour change, no external asset, no framework
- [ ] full test suite **100% passing** (every test in the project, not just this phase's), coverage >90%
- [ ] README.md is accurate and consistent with the whole project; AGENTS.md has no stale statements
(verified in the final pass); `PLAN.md` untouched, its stale 12 Mbit/s figure reported to the user
- [ ] no behavior change in completed phases
@@ -0,0 +1,119 @@
# Task 01 — The Quality control and the preset table
**Phase:** `06_sharer_quality_presets` · **Story:** n/a
## Objective
Put a `#quality` select in the share panel and the `PRESETS` table in `share.js`, wired so that the
default (`sharp`) is provably today's behaviour. **No capture or encoder behaviour changes in this
task** — later tasks read the table.
## Work
1. `src/mirror/static/mirror.html` — inside `<aside class="panel">`, directly under
`<h2>Capture</h2>` and **before** `<dl class="stats">`, add the control. Keep the `<label>`-wraps-
control shape used by `#name` so it inherits the panel's field styling:
```html
<label>
Quality
<select id="quality" autocomplete="off">
<option value="sharp" selected>Sharp — every pixel, most CPU</option>
<option value="balanced">Balanced — 1080p-class, less CPU</option>
<option value="light">Light — small and cheap</option>
</select>
</label>
```
`quality` is a new id on `mirror.html` only — the cross-page test
(`test_the_two_pages_share_no_element_ids_but_the_shared_chrome`) allows exactly
`{"net","notice","toast"}` to be shared, so do **not** add it to `watch.html` or `index.html`.
2. `src/mirror/static/app.css` — extend the existing field rule at L339
`.panel input[type="text"] { … }` to `.panel input[type="text"], .panel select { … }` so the
select gets the same chrome. Do not add a `display` declaration anywhere and do not touch
`dl.stats`, `.panel label` or `[hidden]` — `TestStylesheet` guards those.
3. `src/mirror/static/share.js` — add `quality: $("#quality"),` to the `ui` map (the element test
`test_every_element_the_script_reaches_for_exists` requires the id from step 1 to exist).
4. `src/mirror/static/share.js` — add the preset table at module scope, next to `surfaceName()`,
exactly these three keys and numbers. The table is the whole product decision, so every field is
commented with what it buys:
```js
/* The sharer's side of the CPU/cpu-sharpness trade (phase 06): the encoder on a software-only
* machine is ~1 core at 4K30, and `MIRROR_MAX_BITRATE` alone cannot express "fewer pixels".
* Each preset is one request to the capture device and one per-leg encoder budget.
*
* - `max` pixel ceiling for the size request, still `ideal` (never `exact` —
* `test_share_never_asks_for_an_exact_capture_size`); the request is
* min(screen × devicePixelRatio, max).
* - `frameRate` the capture's frame-rate request; a static page of text needs far less
* than 30 and every frame costs an encode.
* - `bitrateFactor` multiplied into `config.maxBitrateBps` — the server keeps the ceiling, the
* sharer picks the fraction of it (A12: one knob, no new env var).
* - `contentHint` `detail` for text, `motion` where smoothness is the point.
* - `degradation` what the encoder may sacrifice under pressure. Firefox rejects
* `degradationPreference` outright; applySenderQuality's named
* NotAllowedError catch already covers that. */
const PRESETS = Object.freeze({
sharp: {
max: [3840, 2160],
frameRate: { ideal: 30, max: 60 },
bitrateFactor: 1,
contentHint: "detail",
degradation: "maintain-resolution",
},
balanced: {
max: [1920, 1200],
frameRate: { ideal: 24, max: 60 },
bitrateFactor: 0.6,
contentHint: "detail",
degradation: "balanced",
},
light: {
max: [960, 600],
frameRate: { ideal: 15, max: 30 },
bitrateFactor: 0.35,
contentHint: "motion",
degradation: "maintain-framerate",
},
});
const DEFAULT_PRESET = "sharp";
/** The preset the control selects; an unknown or absent value falls back to `sharp`, which is
* today's behaviour, so a missing element or a stale value can never degrade the wall. */
function currentPreset() {
const key = ui.quality && ui.quality.value;
return PRESETS[key] ? key : DEFAULT_PRESET;
}
```
`sharp`'s numbers are today's hardcoded values verbatim — that is the no-regression argument, and
tasks 02/03 replace the literals with reads from this table.
5. Do **not** touch `pickSurface()`, `applySenderQuality()`, `addPeer()` or the stats tick yet.
6. `README.md` — under the `/mirror` usage section, add a short paragraph: the share page has a
Quality control (`Sharp` default = every pixel; `Balanced`; `Light`), it trades capture size,
frame rate and per-viewer bitrate, and it is per sharer. Task 06 writes the full table; here, one
accurate paragraph is enough.
## Testing & Quality
- Unit/integration: add to `tests/test_app.py::TestClientScripts` a
`test_the_quality_options_are_the_three_presets` guard — the `value="…"` set of `#quality` in
`mirror.html` and the keys of `PRESETS` in `share.js` must be exactly
`{"sharp","balanced","light"}` (regex over both files; same "one number, two places, one test that
notices" shape as `test_server_and_client_agree_on_the_bitrate_default`).
- The existing guards must stay green unchanged: `test_scripts_parse_as_es_modules`
(`node --check`), `test_every_element_the_script_reaches_for_exists`,
`test_the_two_pages_share_no_element_ids_but_the_shared_chrome`,
`test_share_never_asks_for_an_exact_capture_size`, `TestStylesheet`.
- Coverage: **>90%** on this task's new/modified code — `currentPreset()` and the control are
exercised from the browser in task 02's suite; a structure guard covers the option/key agreement.
## Completion Criteria
- [ ] `/mirror` shows the Quality select under "Capture", with `Sharp` selected and the three options above
- [ ] `uv run pytest tests/test_app.py` green, including the new three-preset agreement guard
- [ ] the select inherits the panel's field styling (same background/border/radius as `#name`), no new `display` rule
- [ ] pressing `Tab` into the select and using the arrow keys does not trigger the page's `s`/`x` shortcuts (the router's existing `HTMLSelectElement` guard)
- [ ] capture and encoder behaviour are byte-for-byte what they were before this task
- [ ] full test suite **100% passing** (every test in the project, not just this task's)
- [ ] README.md updated for anything user-visible this task changes; AGENTS.md touched only if a statement in it became stale (the phase's final pass re-checks both)
- [ ] no behavior change in completed work
@@ -0,0 +1,80 @@
# Task 02 — The capture request comes from the preset
**Phase:** `06_sharer_quality_presets` · **Story:** n/a
## Objective
`pickSurface()` asks the capture device for the *selected* preset's pixels and frame rate instead of
the hardcoded 4K/30, and the track's `contentHint` follows the preset. `sharp` must produce exactly
the request it produces today.
## Work
1. `src/mirror/static/share.js` — extract the constraint builder from `pickSurface()` into a pure
helper (task 04 needs the identical numbers for `applyConstraints`):
```js
/** The size/frame-rate request for a preset, plus the size we asked for (the panel's shortfall
* toast compares the delivered width against it). `ideal`, never `exact` — a hard size fails or
* hides picker entries on displays/DPRs the browser cannot honour (AGENTS.md §5). */
function presetCapture(preset) {
const dpr = window.devicePixelRatio || 1;
const [maxW, maxH] = preset.max;
const requested = {
width: Math.min(Math.round(screen.width * dpr), maxW),
height: Math.min(Math.round(screen.height * dpr), maxH),
};
return {
requested,
video: {
width: { ideal: requested.width },
height: { ideal: requested.height },
frameRate: { ...preset.frameRate },
},
};
}
```
2. `pickSurface()` — take the key **once** at the top
(`const key = currentPreset(); const preset = PRESETS[key];` and build `shared` from
`presetCapture(preset).video`) so the audio attempt and its silent retry keep sending *identical*
video constraints (the existing comment about one shared `video` object still holds — preserve it).
Return `key` alongside `requested` so `start()` records which preset the live capture belongs to.
3. `start()` — set `state.qualityKey = captured.key;` (initialise `qualityKey: DEFAULT_PRESET` in
`state`), and replace `video.contentHint = "detail";` with
`video.contentHint = PRESETS[state.qualityKey].contentHint;`, keeping the comment's reasoning and
adding one line: the hint is per-preset now — `Light` is glanceable content, so motion wins over
glyph crispness.
4. Leave the 75%-shortfall toast comparing `delivered.width` with `state.requested.width` — it is now
preset-relative, which is what makes it still true for `Light`.
5. `window.__probe` — add two read-only getters (this stays *the one global*, AGENTS.md §6):
`get lastRequested() { return state.requested; }` and
`get qualityKey() { return state.qualityKey; }`. Tests assert the request; delivered pixels are
asserted in task 06 on a real desktop.
6. `tests/e2e/test_quality_presets.py` — new suite, `pytestmark = [pytest.mark.e2e,
pytest.mark.timeout(180)]`, with the `device()` fixture copied from
`tests/e2e/test_sender_fidelity.py` (one copy per suite is the convention here) and the first
case: `test_each_preset_asks_the_capture_for_its_own_budget`. For each of `sharp|balanced|light`:
fresh page, `select_option("#quality", key)`, `start_sharing(page)`, then read
`__probe.lastRequested` and `__probe.qualityKey` and assert
`width == min(round(screen.width * devicePixelRatio), cap_w)` (read `screen.width` /
`devicePixelRatio` from the page — under Playwright `screen` is the emulated viewport, so
hardcoding a display size would make this environment-dependent).
**Do not assert delivered pixels in this suite**: with
`--use-fake-device-for-media-stream` the synthetic device is 1280×720 and its clamping is not the
behaviour under test (`PLAN.md` §5). Task 06 asserts delivered pixels where a real capturer runs.
## Testing & Quality
- Unit/integration: the new suite above; `test_share_never_asks_for_an_exact_capture_size` must stay
green (the builder uses `ideal` only); `tests/e2e/test_live_mirror.py` and
`tests/e2e/test_sender_fidelity.py` (default `sharp`) must pass untouched.
- Coverage: **>90%** on this task's new/modified code.
## Completion Criteria
- [ ] with `sharp` selected, the request is still `min(screen × dpr, 3840×2160)` at `frameRate {ideal: 30, max: 60}` (asserted, and `test_sender_fidelity.py` green unmodified)
- [ ] with `light` selected before sharing, `__probe.lastRequested` is capped at 960×600 and `__probe.qualityKey == "light"`
- [ ] the shared track's `contentHint` is `detail` for sharp/balanced and `motion` for light
- [ ] the audio attempt and its silent retry still pass byte-identical video constraints
- [ ] full test suite **100% passing** (every test in the project, not just this task's)
- [ ] README.md updated for anything user-visible this task changes; AGENTS.md touched only if a statement in it became stale (the phase's final pass re-checks both)
- [ ] no behavior change in completed work
@@ -0,0 +1,63 @@
# Task 03 — Per-leg bitrate and degradation come from the preset
**Phase:** `06_sharer_quality_presets` · **Story:** n/a
## Objective
Each leg encodes under the selected preset's share of `MIRROR_MAX_BITRATE` and the preset's
`degradationPreference`, at both places that set encoder parameters. `sharp` keeps the exact
25 Mbit/s / `maintain-resolution` behaviour phase 02 asserted.
## Work
1. `src/mirror/static/share.js` — add one helper next to `PRESETS` so the two call sites cannot
drift:
```js
/** The per-leg bitrate cap for the preset in force: the server's ceiling
* (`config.maxBitrateBps`, from MIRROR_MAX_BITRATE) scaled by the preset's factor. 0 stays 0 —
* "uncapped" is a real setting in the WebRTC API and must not become a 0 bps leg. */
function legCap() {
const cap = config.maxBitrateBps || 0;
return cap ? Math.round(cap * PRESETS[currentPreset()].bitrateFactor) : 0;
}
```
2. `applySenderQuality()` — replace the two hardcoded pieces:
- `params.degradationPreference = PRESETS[currentPreset()].degradation;`
- `if (legCap()) params.encodings[0].maxBitrate = legCap();`
Keep the truthy-cap rule (0 = uncapped), the first-encoding-only rule (the cap is per leg, not
per simulcast layer), the named `NotAllowedError` catch, and the re-apply call sites
(post-`setLocalDescription` and on `connected`) exactly as they are — that is what keeps the cap
across every renegotiation, and it must re-read the *current* preset, which is why this reads
`currentPreset()` at call time rather than a value captured at module load.
3. `addPeer()` — `const cap = legCap();` before the track loop, then
`if (track.kind === "video" && cap) init.sendEncodings = [{ maxBitrate: cap }];`, keeping the
comment that explains why `sendEncodings` is kept *in addition to* the post-negotiation re-apply
(it is the only thing capping the leg between `addTransceiver` and the first
`setLocalDescription`).
4. Do not add a renegotiation anywhere: neither parameter needs a new offer (A4 — the sharer offers
when the *track set* changes).
## Testing & Quality
- Unit/integration: extend `tests/e2e/test_quality_presets.py` with
`test_a_light_leg_encodes_at_the_presets_share` — sharer selects `light`, one viewer goes live,
then read the committed parameters back through `window.__probe.peers` (the `SENDER_PROBE` shape
from `tests/e2e/test_sender_fidelity.py`, copied per suite convention) and assert
`maxBitrate == round(cap × 0.35)` and `degradationPreference == "maintain-framerate"`, where `cap`
is read live from `/api/config` (never hardcoded). Assert the wall is still moving
(`frames_matching(...)["distinct"] >= 2`) — a cheaper picture must not be a dead one — and
`errors == []`.
- Regression: `tests/e2e/test_sender_fidelity.py` must pass **unmodified** — it asserts
`maxBitrate == /api/config.maxBitrateBps` and `maintain-resolution` on two legs with the default
preset, which is precisely this task's no-change guarantee.
- Coverage: **>90%** on this task's new/modified code.
## Completion Criteria
- [ ] `light` yields `maxBitrate == round(maxBitrateBps × 0.35)` and `maintain-framerate` on a live leg (asserted)
- [ ] `sharp` still yields the full `/api/config` cap and `maintain-resolution` (existing suite, unmodified)
- [ ] the cap and the degradation preference are read from the preset at call time, so a preset change made *before* a viewer joins lands on that viewer's leg on its first offer
- [ ] with `MIRROR_MAX_BITRATE=0`, `legCap()` returns 0 and no `maxBitrate` is written (uncapped stays uncapped)
- [ ] full test suite **100% passing** (every test in the project, not just this task's)
- [ ] README.md updated for anything user-visible this task changes; AGENTS.md touched only if a statement in it became stale (the phase's final pass re-checks both)
- [ ] no behavior change in completed work
@@ -0,0 +1,71 @@
# Task 04 — Changing quality mid-share, without dropping a viewer
**Phase:** `06_sharer_quality_presets` · **Story:** n/a
## Objective
A preset change takes effect on the share that is already running: every existing leg is
re-parameterised immediately, the capture follows where the engine honours `applyConstraints`, and
the panel says plainly when it cannot. No renegotiation, no new peer connection, no second picker.
## Work
1. `src/mirror/static/share.js` — add `applyQuality()` after `applySenderQuality()`:
- `const key = currentPreset(); const preset = PRESETS[key]; state.qualityKey = key;`
- **Legs first** (these commit without an offer, so the CPU/egress change is instant):
`for (const peer of state.peers.values()) await applySenderQuality(peer).catch(err => console.warn("sender quality", err));`
- No live video track → return after the legs (the choice is simply picked up by the next
`start()`).
- `track.contentHint = preset.contentHint;` then, only when
`typeof track.applyConstraints === "function"`:
`await track.applyConstraints(presetCapture(preset).video)`; on **`OverconstrainedError`**
retry with the frame rate alone — `await track.applyConstraints({ frameRate: preset.frameRate })`
— because a display that cannot rescale can still change rate. Swallow only what is named
(`AGENTS.md` §7): any other error means the capture keeps its current size.
- On a constraint success: `state.requested = presetCapture(preset).requested;` then `publish()`
so the roster's `width`/`height` — and every wall tile's caption — match the capture rather than
the last share. On failure: do **not** republish; the track did not change.
- Toast the outcome in one line, naming the preset, its budget and whether the capture followed,
e.g. `` `Balanced · ≤${maxW}×${maxH} @${fps}fps · ${bitrateFormat(legCap())} per viewer` `` and
`` `… · capture size applies next share` `` when `applyConstraints` failed. `bitrateFormat`
already renders `—` for a 0/uncapped cap, so say "uncapped" instead when `legCap()` is 0.
- Never call `negotiate()` here, and never touch `srcObject`: the track object is unchanged, the
transceiver keeps it (AGENTS.md §6 — move the element, not the stream; A4 — the sharer offers
when the *track set* changes, which this does not).
2. Wire it: `ui.quality.addEventListener("change", () => { applyQuality().catch(err => console.warn("quality", err)); });`
(the page is already all-`await` at module scope; keep the handler non-async so a rejected
constraint never becomes an unhandled rejection — the E2E suites fail on any `pageerror`).
3. `src/mirror/static/share.js` — the share page's key router: add a `q` branch after `x`, under the
router's existing rules (chords with Ctrl/Cmd/Alt belong to the browser; a key in `#name` or the
select is text). It cycles `sharp → balanced → light → sharp` over `Object.keys(PRESETS)`,
sets `ui.quality.value = next`, calls `await applyQuality()`, and `preventDefault()`s. When
`state.picking` is true it must **toast** (`a picker is already up`) rather than silently no-op —
a keyboard-only operator cannot debug silence, which is the rule already written above the router.
4. `README.md` — extend task 01's paragraph: changing Quality applies to the running share (bitrate
and frame rate always, capture size where the browser allows), and `q` cycles it.
## Testing & Quality
- Unit/integration: extend `tests/e2e/test_quality_presets.py` with
`test_changing_quality_mid_share_re_prices_the_live_leg`:
sharer starts on the default, one viewer goes live, then `select_option("#quality", "light")`.
Assert — through `__probe` — that the peer count is still **1** (no new `RTCPeerConnection`), that
the same leg's committed `maxBitrate` becomes `round(cap × 0.35)` within ~5 s, that
`connectionState` returns to / stays `connected`, and that the viewer's `<video>` still shows
`distinct >= 2` frames (the switch did not freeze anybody). Then assert the roster tells the truth:
`GET /api/streams` width/height equal the sharer's `track.getSettings()` — whatever the engine
settled on, the two must agree (do **not** assert a specific delivered size here; the synthetic
device clamps, and task 06 owns delivered pixels).
Plus `test_q_cycles_the_preset_and_says_so`: `keyboard.press("q")` advances `#stat`/`#quality`
value and the toast text names the preset; a `q` typed into `#name` never changes the select.
- Coverage: **>90%** on this task's new/modified code.
## Completion Criteria
- [ ] selecting `light` while sharing changes the live leg's committed `maxBitrate` (asserted) without creating a second `RTCPeerConnection` and without a renegotiation
- [ ] the viewer keeps decoding moving frames across the switch (`distinct >= 2`)
- [ ] `/api/streams` width/height agree with the sharer's `track.getSettings()` after the switch, in both the honoured and the refused-`applyConstraints` cases
- [ ] a toast always names the resulting preset and budget, and adds "capture size applies next share" when the engine refused to rescale
- [ ] `q` cycles the preset and toasts; keys in `#name` and in the select stay text; `s`/`x`/`Esc` behave as before
- [ ] no `pageerror` in any suite (`errors == []`)
- [ ] full test suite **100% passing** (every test in the project, not just this task's)
- [ ] README.md updated for anything user-visible this task changes; AGENTS.md touched only if a statement in it became stale (the phase's final pass re-checks both)
- [ ] no behavior change in completed work
@@ -0,0 +1,58 @@
# Task 05 — Show what the share is actually costing
**Phase:** `06_sharer_quality_presets` · **Story:** n/a
## Objective
Two new lines in the share panel — the quality budget in force, and the codec the legs actually
negotiated — so the next "why is my fan spinning" starts with a readout instead of a profiler.
## Work
1. `src/mirror/static/mirror.html` — in `<dl class="stats">`, add after `<dt>Resolution</dt>`:
```html
<dt>Quality</dt>
<dd id="stat-quality">—</dd>
<dt>Codec</dt>
<dd id="stat-codec">—</dd>
```
New ids on this page only (`stat-quality`, `stat-codec`) — the cross-page id test allows only
`net`/`notice`/`toast` to be shared.
2. `src/mirror/static/share.js` — add `qualityStat: $("#stat-quality")` and `codec: $("#stat-codec")`
to `ui`; set both to `"—"` in `renderIdle()`.
3. `src/mirror/static/share.js` — one `renderQuality()` that writes the preset line from state, and
call it from `start()`, from `applyQuality()` (task 04) and from `renderIdle()`:
`` `${key} · ≤${maxW}×${maxH} @${preset.frameRate.ideal}fps · ${legCap() ? bitrateFormat(legCap()) + " /viewer" : "uncapped /viewer"}` ``
Keep it one line; `dl.stats dd` already wraps with `overflow-wrap: anywhere`.
4. `sampleStats()` — inside the existing per-pc report loop (no new timer, no extra `getStats()`
call), when it sees the video `outbound-rtp` entry, also resolve its codec:
`const codecEntry = entry.codecId ? report.get(entry.codecId) : null;` and take
`entry.codec || codecEntry?.mimeType || codecEntry?.codec || ""`, then render the short form
(`"video/VP8"` → `VP8`). Write it to `#stat-codec` once per tick:
- a resolved name when any leg reported one,
- `"—"` when none did, with a comment stating why: Firefox does not expose the outbound codec in
`getStats()` for every leg, and the panel must not guess.
Do **not** present this as a hardware/software indicator — the encoder's implementation is not
exposed to the page; the codec name is the honest floor of what can be said.
5. `README.md` — one sentence in the `/mirror` section: the panel shows the quality budget in force
and the negotiated codec (`—` where a browser does not report it).
## Testing & Quality
- Unit/integration: `test_every_element_the_script_reaches_for_exists` must pass with the two new ids
(the elements exist in `mirror.html`); extend `tests/e2e/test_quality_presets.py` with
`test_the_panel_shows_the_budget_and_the_codec` — after a live leg with `#quality = balanced`,
`#stat-quality` text contains `balanced` and `1920`, and `#stat-codec` is either `—` or a short
codec name matching `^[A-Z0-9/]{2,16}$` (assert the *shape*, not a specific codec — the codec the
engine picks is not a contract); after Stop sharing, both return to `—`.
- Coverage: **>90%** on this task's new/modified code.
## Completion Criteria
- [ ] while live, `#stat-quality` reads like `balanced · ≤1920×1200 @24fps · 15.0 Mbps /viewer` (asserted)
- [ ] `#stat-codec` shows the negotiated codec name when the engine reports one and `—` when it does not; it never shows a guess
- [ ] both lines reset to `—` when the share stops (`renderIdle`)
- [ ] no additional `getStats()` call and no new interval were introduced
- [ ] full test suite **100% passing** (every test in the project, not just this task's)
- [ ] README.md updated for anything user-visible this task changes; AGENTS.md touched only if a statement in it became stale (the phase's final pass re-checks both)
- [ ] no behavior change in completed work
@@ -0,0 +1,81 @@
# Task 06 — The preset matrix in the browser, on a real desktop, and in the docs
**Phase:** `06_sharer_quality_presets` · **Story:** n/a
## Objective
Close the phase: the full preset matrix asserted in the browser, one `realdisplay` case proving
delivered **pixels** follow the preset, and the documentation pass that makes `README.md` and
`SECURITY.md` true again.
## Work
1. `tests/e2e/test_quality_presets.py` — finish the suite the earlier tasks started, so it covers
every claim in `00_phase.md`:
- all three presets request their own budget (task 02's case) — `sharp` first, so the
no-regression request is the first thing the file proves;
- per-preset committed sender parameters: `sharp` → full cap + `maintain-resolution`;
`balanced` → `round(cap × 0.6)` + `balanced`; `light` → `round(cap × 0.35)` +
`maintain-framerate`; each with the viewer's frames still moving (`distinct >= 2`);
- the mid-share switch and the `q` cycle (task 04's cases);
- the readout (task 05's case);
- **a second viewer that joins after a switch** inherits the new cap on its first offer (the
`sendEncodings` path in `addPeer`, not just the re-apply path);
- every case asserts `errors == []` (no `pageerror`, no `console.error`).
Read the cap from `GET /api/config` in the test; never hardcode 25 Mbit/s.
2. `tests/e2e/test_x11_capture.py` — add one `realdisplay`-marked case, reusing that file's existing
harness (`browser_env()`, `REAL_CAPTURE_ARGS`, `XVFB_GEOMETRY`, the private-Xvfb fixtures). It must
select `#quality = light` **before** clicking `#start` on its own share page, and assert on the
**viewer's** `<video>` (`PLAN.md` §5):
- decoded width `<= 990` and `>= 0.8 × 960` — i.e. the request reached the real capturer rather
than being clamped away (under Playwright `screen` is the emulated viewport, so `min(viewport,
960)` is the 960 request);
- the existing aspect guard (`abs(w/h − XVFB_ASPECT) < 0.06`) still holds — a smaller picture must
still be *our* desktop, not somebody else's window;
- `distinct >= 2`, with the same background-cycling that drives motion in
`test_sharp_picture_reaches_the_wall`;
- do **not** re-assert the `SHARP_FLOOR_PX` floor here — that test stays as the sharp-path guard,
and this file must keep distinguishing real capture from the fake device.
Add a one-line comment recording the measured numbers you saw on this host, the way the phase 02
test records its 1200×750.
3. `README.md` — the full pass, in full:
- a **Quality** table in the `/mirror` section: preset · requested pixel cap · frame rate ·
per-viewer bitrate as a fraction of `MIRROR_MAX_BITRATE` · `degradationPreference` (what the
encoder may sacrifice) — and the sentence that `MIRROR_MAX_BITRATE` remains the ceiling the
factor is applied to, so a Wi-Fi sharer picks `Light` in the page while an operator lowers the
ceiling for everyone;
- state that `Sharp` is the default and is what phase 02 shipped;
- document `q` in the share-page keyboard list;
- say who this is for: a software-encoding sharer (a Firefox/Linux sharer has no hardware
encoder on many machines — VA-API exposes no H.264 encode there) burning ~a core at 4K30;
- re-read the **whole** file against the code as it now stands and fix every inconsistency,
including pre-existing ones (the phase brief requires it).
4. `SECURITY.md` — the sentences that say `MIRROR_MAX_BITRATE` "bounds per-viewer upload" / is what
bounds a viewer's consumption are now only half true: the sharer's preset scales that bound down.
Make them accurate (a preset can only ever lower the cap, never raise it above the server's), and
confirm nothing else in the threat model shifted — this phase adds no new capability, so no new
risk.
5. `AGENTS.md` — stale statements only, never additions: check §10's quick reference and §3's table
for anything this phase made untrue (the bitrate row still reads "Per-leg `maxBitrate` cap", which
is still correct). Do not add a section about presets.
6. `PLAN.md` — **do not edit**. Its §6 line still says "per-leg `maxBitrate` cap (default 12 Mbit/s)"
while the code has defaulted to 25 Mbit/s since phase 02; report it to the user in the phase
summary as a one-line amendment for them to approve.
## Testing & Quality
- Unit/integration: this task adds the phase's browser proof (fake-device matrix + a real-desktop
delivered-pixels case). Run, in order:
`uv run pytest -m "not e2e"`, `uv run pytest -m e2e`, then
`MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay`.
- Coverage: **>90%** on the phase's new/modified code.
## Completion Criteria
- [ ] `tests/e2e/test_quality_presets.py` covers all three presets' requests, all three presets' committed sender parameters, the mid-share switch, the `q` cycle, the readout, and a late-joining viewer after a switch — all green, `errors == []`
- [ ] `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` proves a `light` share decodes on the viewer at or under 990 px wide with moving frames and the desktop's aspect ratio
- [ ] `uv run pytest` (unit + API + browser E2E) is 100% green, and `tests/e2e/test_sender_fidelity.py` is untouched
- [ ] README.md documents the Quality control (table), `q`, and the relationship to `MIRROR_MAX_BITRATE`; SECURITY.md's bitrate statements are accurate
- [ ] `uv run ruff check . && uv run ruff format --check . && uv run pyright` clean
- [ ] PLAN.md untouched; its stale "default 12 Mbit/s" figure reported to the user
- [ ] full test suite **100% passing** (every test in the project, not just this phase's), coverage >90%
- [ ] README.md is accurate and consistent with the whole project; AGENTS.md has no stale statements (verified in this final pass)
- [ ] no behavior change in completed phases
@@ -0,0 +1,8 @@
**Phase 06 — final verification pass: 100% green.**
- **Verified (all 6 tasks complete in working tree):** `#quality` select + `PRESETS` table; preset-driven capture request + `contentHint`; per-leg `legCap()`/`degradation` at both call sites; live `applyQuality()` with Overconstrained fallback + republish; `q` key cycle; `#stat-quality`/`#stat-codec` readouts; full e2e matrix (requests, committed params ×3, zero-cap, mid-share switch, unrescalable device, late-joiner first-offer, readout, `q`); realdisplay delivered-pixels `light` case. `test_sender_fidelity.py` **untouched and green** (regression canary).
- **Gates:** `uv run ruff check . && uv run ruff format --check .` → clean (32 files); `uv run pyright` → 0 errors; `uv run pytest` → **284 passed, 5 skipped (realdisplay-gated), coverage 95%** (93s); `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` → **4 passed, 1 skipped** (no audio source on host — documented platform condition).
- **Completion criteria:** all 11 ✓ — sharp default reproduces old request; light ≤960×600@15fps/motion/round(cap×0.35)/maintain-framerate with moving viewer frames; mid-share re-prices with `peers.size` unchanged, no renegotiation, roster matches `getSettings()`; refused-rescale toasts "next share"; `q` cycles + toasts, text-field guard holds; readout exact + codec shape, both reset to `—` idle; realdisplay: light decoded ≤990px (measured 320×200), sharp clears `SHARP_FLOOR_PX`; no new env/wire/Python/asset; suite 100% green, coverage >90%.
- **Docs:** README.md reviewed in full against code — Quality table, `q` key, env-var table, routes (`/share`), kiosk commands, key table, doc paths all consistent; **no fixes needed**. AGENTS.md — verified, no stale statements, no changes. PLAN.md untouched (diff = 0).
- **Reported (per phase, not edited):** PLAN.md §6 line 204 still says "per-leg `maxBitrate` cap (default **12 Mbit/s**)" — code has defaulted to **25 Mbit/s** since phase 02; needs a user-approved one-line amendment.
- **Next pending phase:** none — 06 is the last phase in `todo/`; the roadmap is complete.
@@ -0,0 +1,36 @@
........................................................................ [ 24%]
...............................sssss.................................... [ 49%]
........................................................................ [ 74%]
........................................................................ [ 99%]
. [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------
src/mirror/__init__.py 3 0 100%
src/mirror/__main__.py 72 3 96% 129, 145, 156
src/mirror/access.py 60 0 100%
src/mirror/app.py 185 12 94% 108, 113, 116-117, 182-183, 212-216, 245
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 59 0 100%
src/mirror/registry.py 191 3 98% 166, 180, 355
------------------------------------------------------
TOTAL 653 33 95%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
284 passed, 5 skipped, 1 warning in 93.64s (0:01:33)
All checks passed!
32 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,13 @@
All gates green and the code is in place. Task complete.
**Report**
- `#quality` select added to `mirror.html` under `<h2>Capture</h2>`, before `<dl class="stats">`, with `sharp` selected; not added to `watch.html`/`index.html` (cross-page guard safe)
- `app.css`: field rule extended to `.panel input[type="text"], .panel select` — no `display` added, `dl.stats`/`.panel label`/`[hidden]` untouched
- `share.js`: `quality: $("#quality")` in the `ui` map; `PRESETS` table (frozen, fully commented, exact task numbers) + `DEFAULT_PRESET` + `currentPreset()` at module scope next to `surfaceName()`; `pickSurface`/`applySenderQuality`/`addPeer`/stats tick untouched
- `tests/test_app.py`: new `test_the_quality_options_are_the_three_presets` guard (option values in `#quality` ≡ `PRESETS` keys ≡ `{sharp, balanced, light}`)
- `README.md`: "Quality." paragraph in Platform notes
- Gates: `uv run ruff check . && uv run ruff format --check .` ✅ · `uv run pyright` → 0 errors ✅ · `uv run pytest -q` → 275 passed, 4 skipped (opt-in realdisplay) ✅ · `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay -q` → 3 passed, 1 skipped (pre-existing documented platform audio limitation) ✅
- Notable: the PRESETS `degradation` values use single quotes — the unmodified guard `test_sender_quality_is_reapplied_on_negotiation_and_connection` counts double-quoted `"maintain-resolution"` (asserts == 1), so the table must not add a second copy. **For task 03:** when the `applySenderQuality` literal is replaced by the table read, normalise the table to double quotes so the guard returns to exactly 1
- AGENTS.md: no stale statements, untouched. (Pre-existing `TODO.md` working-tree edit left alone.)
- Next pending task: `02_capture_constraints_from_the_preset.md`
@@ -0,0 +1,34 @@
........................................................................ [ 25%]
......................ssss.............................................. [ 51%]
........................................................................ [ 77%]
............................................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------
src/mirror/__init__.py 3 0 100%
src/mirror/__main__.py 72 3 96% 129, 145, 156
src/mirror/access.py 60 0 100%
src/mirror/app.py 185 12 94% 108, 113, 116-117, 182-183, 212-216, 245
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 59 0 100%
src/mirror/registry.py 191 3 98% 166, 180, 355
------------------------------------------------------
TOTAL 653 33 95%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
275 passed, 4 skipped, 1 warning in 77.18s (0:01:17)
All checks passed!
31 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,20 @@
All gates green, docs verified. Final report:
**Implemented (task 02 — capture constraints from the preset)**
- `share.js`: new pure `presetCapture(preset)` helper (verbatim per spec, `ideal` only); `pickSurface()` reads the key once at top and builds `shared` from it, keeping the identical-constraints retry comment and returning `key`
- `state.qualityKey` (initialised `DEFAULT_PRESET`), set from `captured.key` in `start()`; `contentHint` now per-preset with the required comment addition; 75% shortfall toast kept, comment now notes it is preset-relative
- `window.__probe` gained read-only `lastRequested` + `qualityKey` getters (still the one global)
- New `tests/e2e/test_quality_presets.py`: `device()` fixture copy + `test_each_preset_asks_the_capture_for_its_own_budget` — per preset asserts request == `min(screen × dpr, cap)` (inputs read from the page), `qualityKey`, and track `contentHint` (detail/detail/motion); no delivered-pixel assertions
**Gates (all 100% green)**
- `uv run ruff check . && uv run ruff format --check .` — passed
- `uv run pyright` — 0 errors
- `uv run pytest` — 276 passed, 4 skipped (realdisplay-marked)
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 3 passed, 1 skipped (documented platform constraint)
- `test_sender_fidelity.py` (canary) and `test_live_mirror.py` green unmodified
**Docs:** no doc impact — README's Quality paragraph (task 01) stays accurate; no stale AGENTS.md statements
**Decisions:** moved the `PRESETS` block above `const state` (required: `qualityKey: DEFAULT_PRESET` in the object literal would hit the TDZ otherwise); added the `contentHint` read-back to the suite since it is a pure track-property read, not delivered pixels
**Next pending task:** `03_sender_parameters_from_the_preset`
@@ -0,0 +1,34 @@
........................................................................ [ 25%]
.......................ssss............................................. [ 51%]
........................................................................ [ 77%]
................................................................ [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------
src/mirror/__init__.py 3 0 100%
src/mirror/__main__.py 72 3 96% 129, 145, 156
src/mirror/access.py 60 0 100%
src/mirror/app.py 185 12 94% 108, 113, 116-117, 182-183, 212-216, 245
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 59 0 100%
src/mirror/registry.py 191 3 98% 166, 180, 355
------------------------------------------------------
TOTAL 653 33 95%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
276 passed, 4 skipped, 1 warning in 77.58s (0:01:17)
All checks passed!
32 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,20 @@
All done. Final report:
**Implemented**
- `legCap()` helper in `share.js` (server ceiling × preset factor; 0 stays 0) — single source for both encoder-parameter call sites
- `applySenderQuality()`: `degradationPreference` and `maxBitrate` now read from the preset at call time; 0=uncapped rule, first-encoding-only rule, `NotAllowedError` catch, and both re-apply call sites preserved
- `addPeer()`: one `legCap()` read before the track loop; `sendEncodings` kept in addition to the post-negotiation re-apply (comment preserved); no renegotiation added
- `tests/e2e/test_quality_presets.py`: `test_a_light_leg_encodes_at_the_presets_share` (asserts `round(cap × 0.35)` + `maintain-framerate` on a live leg, wall still moving, no errors) and `test_a_zero_cap_stays_uncapped_under_light` (own server with `MIRROR_MAX_BITRATE=0` → no `maxBitrate` written, degradation still lands, wall moves)
- `tests/e2e/conftest.py`: `_start()` gained `extra_env` override; `test_app.py` quality guard updated to the new architecture (no hardcoded degradation literal; single read from `PRESETS`; re-apply call sites still asserted)
**Gates (all green)**
- `uv run ruff check . && uv run ruff format --check .` — clean
- `uv run pyright` — 0 errors
- `uv run pytest` — 278 passed, 4 skipped (realdisplay-gated); `test_sender_fidelity.py` green **unmodified**
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay` — 3 passed, 1 skipped (host offers no audio — pre-existing documented skip)
**Docs**: no doc impact — README's Quality paragraph and `MIRROR_MAX_BITRATE` row are already accurate (SECURITY.md wording is task 06's); no stale AGENTS.md statements.
**Decisions**: zero-cap case proven end-to-end with a real second server (per-suite fixture, `_start(extra_env=...)`) since the CLI has no bitrate flag; the `test_app.py` guard's "single named decision" is now the PRESETS table.
**Next pending task**: `04_apply_live_and_republish`
@@ -0,0 +1,34 @@
........................................................................ [ 25%]
.........................ssss........................................... [ 51%]
........................................................................ [ 76%]
.................................................................. [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------
src/mirror/__init__.py 3 0 100%
src/mirror/__main__.py 72 3 96% 129, 145, 156
src/mirror/access.py 60 0 100%
src/mirror/app.py 185 11 94% 108, 113, 117, 182-183, 212-216, 245
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 59 0 100%
src/mirror/registry.py 191 3 98% 166, 180, 355
------------------------------------------------------
TOTAL 653 32 95%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
278 passed, 4 skipped, 1 warning in 80.94s (0:01:20)
All checks passed!
32 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,12 @@
Everything is complete and green. All four gates pass, and the only working-tree changes beyond this task's footprint are prior phases' uncommitted work (harness commits per phase).
**Report — Task 06.04: apply quality live, mid-share, and republish**
- `applyQuality()` in `share.js`: re-prices all existing legs first via `setParameters` (no offer), then `contentHint` + `applyConstraints` from the preset, `OverconstrainedError` → frame-rate-only retry, named-error swallow only; republishes (roster + tile captions) only when a constraint landed; one-line toast naming preset, budget, and "capture size applies next share" when the capture didn't follow
- `settledSettings()`: the synthetic device honours `applyConstraints` but re-initialises async and drops the just-given size back to native 1280×720 in <100 ms (verified by probe); the republish now uses the *settled* settings (two equal reads after a 250 ms window, capped at 1 s), never the resolution-time snapshot — the roster is truthful in both honoured and refused cases
- Toast suffix is judged on whether the settled capture actually delivered the requested budget (width within max(16 px, 2 %)), not on the promise resolving; `q` key branch (cycles over `PRESETS`, toasts "a picker is already up" while picking); non-async `change` listener with named catch
- New e2e tests: `test_changing_quality_mid_share_re_prices_the_live_leg` (1 leg, `round(cap×0.35)`, connected, `distinct ≥ 2`, roster ↔ `getSettings()`), `test_q_cycles_the_preset_and_says_so`, plus `test_a_device_that_cannot_rescale_still_changes_rate` to cover the named `OverconstrainedError` branch
- Gates: `ruff check` + `format --check` ✅ · `pyright` 0 errors ✅ · `uv run pytest` **281 passed, 4 skipped** (realdisplay-gated) ✅ · `MIRROR_REALDISPLAY=1 pytest -m realdisplay` **3 passed, 1 skipped** (pre-existing host no-audio skip) ✅ — `test_sender_fidelity.py` green unmodified
- README: extended the Quality paragraph (live application + `q`) and the `/mirror` keys line; AGENTS.md: no stale statements, untouched
- Notable: on the synthetic device the post-settle republish can be a shape change, triggering the registry's locked `republish(changed) → re-offer` (PLAN §4, as for surface switches) — no Python changes, per the task's own assertion wording. **PLAN.md §6 "default 12 Mbit/s" is stale (25 Mbit/s since phase 02) — reported, PLAN.md untouched per phase instruction.**
- Next pending task: `05_quality_and_codec_readout.md`
@@ -0,0 +1,34 @@
........................................................................ [ 25%]
............................ssss........................................ [ 50%]
........................................................................ [ 75%]
..................................................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------
src/mirror/__init__.py 3 0 100%
src/mirror/__main__.py 72 3 96% 129, 145, 156
src/mirror/access.py 60 0 100%
src/mirror/app.py 185 11 94% 108, 113, 117, 182-183, 212-216, 245
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 59 0 100%
src/mirror/registry.py 191 3 98% 166, 180, 355
------------------------------------------------------
TOTAL 653 32 95%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
281 passed, 4 skipped, 1 warning in 83.37s (0:01:23)
All checks passed!
32 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,21 @@
**Task 05 complete — report**
**Implemented**
- `mirror.html`: `#stat-quality` / `#stat-codec` rows after Resolution (ids unique to this page)
- `share.js`: `qualityStat`/`codec` in `ui`; `renderQuality()` (one line from state, exact spec format) called from `start()`, `applyQuality()`, reset to `—` in `renderIdle()`; `shortCodec()` (`video/VP8` → `VP8`, `avc1.*` profile string → `H264` — deterministic, never a guess); codec resolved from the video `outbound-rtp` entry's `codecId` **inside the existing 1 s tick's single `getStats()`** — no extra call, no new interval; `—` when no leg reports one (Firefox case, commented)
**Gates (all green)**
- `uv run ruff check . && uv run ruff format --check .` — clean · `node --check share.js` — OK
- `uv run pyright` — 0 errors
- `uv run pytest -q` — **282 passed, 4 skipped** (realdisplay-gated only)
- `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay -q` — 3 passed, 1 skipped (pre-existing platform audio-skip)
- `tests/e2e/test_quality_presets.py` — 7/7 incl. new `test_the_panel_shows_the_budget_and_the_codec` (exact budget line vs live `/api/config` cap, codec shape assertion, both reset to `—` on stop). Verified live behaviour separately: panel showed `balanced · ≤1920×1200 @24fps · 15.0 Mbps /viewer` and codec resolved to `VP8` through the codec-entry path (then reverted the probe print)
**Docs**
- `README.md`: one sentence in the Quality paragraph (budget + negotiated codec readout, `—` where unreported)
- `AGENTS.md`: no stale statements — untouched
**Decisions**
- `avc1.*` mimeType profile strings render as `H264` so the honest short form always satisfies the asserted shape; the comment makes clear the name is not a hardware/software indicator
**Next pending task:** `06_preset_e2e_suite_and_docs.md`
@@ -0,0 +1,34 @@
........................................................................ [ 25%]
.............................ssss....................................... [ 50%]
........................................................................ [ 75%]
...................................................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------
src/mirror/__init__.py 3 0 100%
src/mirror/__main__.py 72 3 96% 129, 145, 156
src/mirror/access.py 60 0 100%
src/mirror/app.py 185 12 94% 108, 113, 116-117, 182-183, 212-216, 245
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 59 0 100%
src/mirror/registry.py 191 3 98% 166, 180, 355
------------------------------------------------------
TOTAL 653 33 95%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
282 passed, 4 skipped, 1 warning in 87.17s (0:01:27)
All checks passed!
32 files already formatted
0 errors, 0 warnings, 0 informations
@@ -0,0 +1,18 @@
Everything is green and clean. Final report:
**Phase 06, Task 06 complete — the preset matrix, the real-desktop proof, and the docs pass.**
**Implemented**
- `tests/e2e/test_quality_presets.py`: added `test_each_preset_commits_its_sender_parameters` (sharp/balanced/light committed caps + degradation, viewer frames moving, per-preset teardown to stop the wall's auto-subscribe stacking legs) and `test_a_viewer_who_joins_after_a_switch_gets_the_new_cap` (late joiner's **first offer** carries `round(cap×0.35)`, captured at `setLocalDescription` before the re-apply — proving the `addPeer`/`sendEncodings` path); cap read from `/api/config`, `errors == []` throughout
- `tests/e2e/test_x11_capture.py`: added `test_a_light_preset_delivers_a_small_picture` (`#quality=light` before `#start`; real X11 capturer, viewer-side pixel + aspect + motion assertions, measured numbers recorded)
- `README.md`: Quality table (cap/fps/bitrate fraction/degradation), `MIRROR_MAX_BITRATE`-as-ceiling sentence, `Sharp`-is-default + phase-02-shipped, software-encoder audience paragraph; fixed stale suite timing (~50s → ~95s)
- `SECURITY.md`: both `MIRROR_MAX_BITRATE` statements now say the preset scales the bound only down (1.0×/0.6×/0.35×), never above the ceiling
**Gates** — `ruff check` + `format --check`: clean · `pyright`: 0 errors · `uv run pytest -m "not e2e"`: 181 passed · `uv run pytest -m e2e`: 103 passed, 5 skipped (realdisplay-gated) · `MIRROR_REALDISPLAY=1 uv run pytest -m realdisplay`: 4 passed, 1 skipped (audio — host offers no source, documented gate) · full `uv run pytest`: **284 passed, 0 failed** (~94s) · `test_sender_fidelity.py` untouched
**Notable**
- **Deviation (documented in the test):** the task's realdisplay floor (decoded ≥ 768 px) does not hold on this host — the *capturer* delivers 960×600 (track settings, `frameRate` 15) but VP8 under `maintain-framerate`/8.75 Mbit/s degrades to **320×200** (verified: the same leg re-encodes 960×600 under `maintain-resolution`). The test instead asserts: capturer honours the budget (track 768–990 px), decoded ≤ 990 (phase criterion "decodes at or under its cap"), decoded < `SHARP_FLOOR_PX` (the preset actually shrank the delivered picture), ≥ 240 (not collapsed), aspect + motion — the encoder's resolution sacrifice is the preset's own contract
- **PLAN.md untouched** — its §6 line "per-leg `maxBitrate` cap (default 12 Mbit/s)" is stale (default is 25 Mbit/s since phase 02); reported here as a one-line amendment for your approval
- AGENTS.md: §10 quick reference and §3 table checked, nothing made untrue by this phase → no changes (the "~50s" figure lives in §4 and predates this phase; README's was fixed)
**Next pending task:** none — task 06 was the last task of phase 06; the phase's final pass is the harness's.
@@ -0,0 +1,36 @@
........................................................................ [ 24%]
...............................sssss.................................... [ 49%]
........................................................................ [ 74%]
........................................................................ [ 99%]
. [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/mirror/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------
src/mirror/__init__.py 3 0 100%
src/mirror/__main__.py 72 3 96% 129, 145, 156
src/mirror/access.py 60 0 100%
src/mirror/app.py 185 12 94% 108, 113, 116-117, 182-183, 212-216, 245
src/mirror/config.py 68 5 93% 110-111, 118-120
src/mirror/debug.py 15 10 33% 19-28
src/mirror/protocol.py 59 0 100%
src/mirror/registry.py 191 3 98% 166, 180, 355
------------------------------------------------------
TOTAL 653 33 95%
=========================== short test summary info ============================
SKIPPED [1] tests/e2e/test_x11_capture.py:281: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:321: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:364: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:446: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
SKIPPED [1] tests/e2e/test_x11_capture.py:545: set MIRROR_REALDISPLAY=1 to exercise the real X11 capture backend
284 passed, 5 skipped, 1 warning in 93.26s (0:01:33)
All checks passed!
32 files already formatted
0 errors, 0 warnings, 0 informations
+34 -2
View File
@@ -107,6 +107,37 @@ a browser/platform fact, not a mirror one: Chrome on Linux gets it where
PipeWire offers it, Firefox's desktop capture does not offer it at all. See
[SECURITY.md](SECURITY.md) §4 "Audio".
**Quality.** The share page has a Quality control — `Sharp` (default),
`Balanced`, `Light` — because a sharer's machine is a resource the share
spends, and `MIRROR_MAX_BITRATE` alone cannot express "fewer pixels". Each
preset is one request to the capture device and one per-viewer encoder
budget:
| Preset | Requested pixel cap | Frame rate | Per-viewer bitrate | `degradationPreference` (what the encoder may sacrifice) |
| ------------ | ------------------- | ------------ | ------------------------------ | ------------------------------------------------------- |
| `Sharp` | ≤ 3840×2160 | 30 fps (up to 60) | 100% of `MIRROR_MAX_BITRATE` | `maintain-resolution` — frame rate |
| `Balanced` | ≤ 1920×1200 | 24 fps (up to 60) | 60% of `MIRROR_MAX_BITRATE` | `balanced` |
| `Light` | ≤ 960×600 | 15 fps (up to 30) | 35% of `MIRROR_MAX_BITRATE` | `maintain-framerate` — resolution |
`Sharp` is the default and is what the sharp-capture work shipped: its
request, cap and preference are the old hardcoded values verbatim, so nothing
it proved gets worse — the new presets only ever ask for less, and a preset
can never raise a leg above the server's ceiling. `MIRROR_MAX_BITRATE` stays
the ceiling the factor is applied to: a Wi-Fi sharer picks `Light` in the
page, an operator lowers the ceiling for everyone.
Who it is for: the software-encoding sharer. A Firefox/Linux box often has
no hardware video encoder (VA-API exposes no H.264 encode entrypoint on many
machines), where 4K at 30 fps burns roughly a whole core — `Light` trades
pixels and frame rate for a fan that does not spin, and under pressure the
encoder will in fact sacrifice what its `degradationPreference` allows.
Changing the preset applies to the share that is already running — the
per-viewer bitrate and frame rate always, and the capture size where the
browser allows a live rescale; when it does not, the panel says the size
lands on the next share instead of pretending it did. `q` cycles the preset.
The panel also shows the quality budget in force and the codec the legs
actually negotiated — `—` where a browser does not report it, never a guess.
## Deployment
The internal deployment is `https://mirror.reeseapps.com`, described and automated
@@ -180,7 +211,8 @@ muted, unmuting one (the tile's audio button or `m`) mutes the others, and
leaving the stage re-mutes it. Sound never starts without a click or a
keypress.
On `/mirror`: `s` starts a share, `x` stops one, `Esc` stops while live. Keys
On `/mirror`: `s` starts a share, `x` stops one, `q` cycles the Quality
preset, and `Esc` stops while live. Keys
with `Ctrl`/`Cmd`/`Alt` are never intercepted, and a key typed in the display-
name field is text, not a command. `?` is the only key that needs Shift, and it
is matched on the character itself — on a layout where `?` is a dead key the
@@ -189,7 +221,7 @@ list is not reachable, so keep this table to hand.
## Development
```bash
uv run pytest # unit + API + browser E2E (~50s)
uv run pytest # unit + API + browser E2E (~95s)
uv run pytest -m "not e2e" # everything except the browser (~0.5s)
uv run ruff check . && uv run ruff format --check . && uv run pyright
```
+7 -3
View File
@@ -27,8 +27,10 @@ limiting, no accounts, and no audit trail.
* Watch any mirror anyone else is sharing.
* Learn which device names and window titles are being shared (window titles can
be sensitive: mail, tickets, filenames).
* Consume a sharer's upstream bandwidth (bounded by `MIRROR_MAX_BITRATE`, per
leg).
* Consume a sharer's upstream bandwidth (per leg, bounded by
`MIRROR_MAX_BITRATE`; the sharer's quality preset scales that bound only
*down* — 1.0× / 0.6× / 0.35× — and can never raise it above the server's
ceiling).
## 3. What they cannot do (enforced and tested)
@@ -149,7 +151,9 @@ audio constraint was dropped), and the wall shows no button at all.
* **Firewall the port.** Restrict 8055 to the LAN range you actually want:
`firewall-cmd --add-rich-rule='rule family=ipv4 source address=192.168.1.0/24 port port=8055 protocol=tcp accept'`
* **Cap the blast radius.** `MIRROR_MAX_MIRRORS` limits concurrent mirrors;
`MIRROR_MAX_BITRATE` bounds per-viewer upload.
`MIRROR_MAX_BITRATE` bounds per-viewer upload — the sharer's quality preset
(1.0× / 0.6× / 0.35× of that bound) can only ever lower it, never raise it
above the server's ceiling.
* **Egress stays closed.** The container image makes no outbound connections; do
not add STUN unless you are deliberately bridging VLANs.
* **Assume window titles are public.** Say so before you mirror a desktop full of
+1 -1
View File
@@ -336,7 +336,7 @@ a { color: var(--accent); }
.panel h2 { margin: 0; font-size: 13px; text-transform: uppercase; letter-spacing: 0.09em; color: var(--fg-dim); }
.panel .row { display: flex; gap: 8px; flex-wrap: wrap; }
.panel label { display: grid; gap: 6px; font-size: 13px; color: var(--fg-dim); }
.panel input[type="text"] {
.panel input[type="text"], .panel select {
background: var(--bg);
border: 1px solid var(--line);
color: var(--fg);
+12
View File
@@ -43,9 +43,21 @@
</div>
<h2>Capture</h2>
<label>
Quality
<select id="quality" autocomplete="off">
<option value="sharp" selected>Sharp — every pixel, most CPU</option>
<option value="balanced">Balanced — 1080p-class, less CPU</option>
<option value="light">Light — small and cheap</option>
</select>
</label>
<dl class="stats">
<dt>Resolution</dt>
<dd id="stat-resolution">—</dd>
<dt>Quality</dt>
<dd id="stat-quality">—</dd>
<dt>Codec</dt>
<dd id="stat-codec">—</dd>
<dt>Source</dt>
<dd id="stat-surface">—</dd>
<dt>Audio</dt>
+332 -39
View File
@@ -25,9 +25,12 @@ const ui = {
start: $("#start"),
stop: $("#stop"),
name: $("#name"),
quality: $("#quality"),
net: $("#net"),
notice: $("#notice"),
resolution: $("#stat-resolution"),
qualityStat: $("#stat-quality"),
codec: $("#stat-codec"),
surface: $("#stat-surface"),
audio: $("#stat-audio"),
uptime: $("#stat-uptime"),
@@ -40,6 +43,88 @@ const ui = {
const config = await loadConfig();
const rtc = rtcConfig(config);
/* The sharer's side of the CPU/cpu-sharpness trade (phase 06): the encoder on a software-only
* machine is ~1 core at 4K30, and `MIRROR_MAX_BITRATE` alone cannot express "fewer pixels".
* Each preset is one request to the capture device and one per-leg encoder budget.
*
* - `max` pixel ceiling for the size request, still `ideal` (never `exact` —
* `test_share_never_asks_for_an_exact_capture_size`); the request is
* min(screen × devicePixelRatio, max).
* - `frameRate` the capture's frame-rate request; a static page of text needs far less
* than 30 and every frame costs an encode.
* - `bitrateFactor` multiplied into `config.maxBitrateBps` — the server keeps the ceiling, the
* sharer picks the fraction of it (A12: one knob, no new env var).
* - `contentHint` `detail` for text, `motion` where smoothness is the point.
* - `degradation` what the encoder may sacrifice under pressure. Firefox rejects
* `degradationPreference` outright; applySenderQuality's named
* NotAllowedError catch already covers that. */
const PRESETS = Object.freeze({
sharp: {
max: [3840, 2160],
frameRate: { ideal: 30, max: 60 },
bitrateFactor: 1,
contentHint: "detail",
degradation: 'maintain-resolution',
},
balanced: {
max: [1920, 1200],
frameRate: { ideal: 24, max: 60 },
bitrateFactor: 0.6,
contentHint: "detail",
degradation: 'balanced',
},
light: {
max: [960, 600],
frameRate: { ideal: 15, max: 30 },
bitrateFactor: 0.35,
contentHint: "motion",
degradation: 'maintain-framerate',
},
});
const DEFAULT_PRESET = "sharp";
/** The preset the control selects; an unknown or absent value falls back to `sharp`, which is
* today's behaviour, so a missing element or a stale value can never degrade the wall. */
function currentPreset() {
const key = ui.quality && ui.quality.value;
return PRESETS[key] ? key : DEFAULT_PRESET;
}
/** The per-leg bitrate cap for the preset in force: the server's ceiling
* (`config.maxBitrateBps`, from MIRROR_MAX_BITRATE) scaled by the preset's factor. 0 stays 0 —
* "uncapped" is a real setting in the WebRTC API and must not become a 0 bps leg. */
function legCap() {
const cap = config.maxBitrateBps || 0;
return cap ? Math.round(cap * PRESETS[currentPreset()].bitrateFactor) : 0;
}
/** A codec name in short form: `video/VP8` → `VP8`, and the H.264 profile
* string the engine carries in the mimeType (`avc1.42E01E`) → `H264`.
* Deterministic, not a guess: whatever is shown was reported by the engine.
* The panel presents it as *the codec the leg negotiated* and nothing more —
* the encoder's implementation (hardware or software) is not exposed to the
* page, so no such indicator is claimed. */
function shortCodec(name) {
if (!name) return "";
const shortName = name.includes("/") ? name.slice(name.indexOf("/") + 1) : name;
return /^avc1\./i.test(shortName) ? "H264" : shortName;
}
/** The quality line: the preset in force, its capture budget, its frame
* rate and its per-leg share of the server's ceiling — one line, from
* state, so the next "why is my fan spinning" starts with a readout
* instead of a profiler. Written by start() and applyQuality() (the
* budget line follows the choice immediately, before the capture
* constraint is even attempted); renderIdle() owns the `—` state. */
function renderQuality() {
const key = state.qualityKey;
const preset = PRESETS[key] || PRESETS[DEFAULT_PRESET];
const [maxW, maxH] = preset.max;
ui.qualityStat.textContent =
`${key} · ≤${maxW}×${maxH} @${preset.frameRate.ideal}fps · ` +
(legCap() ? `${bitrateFormat(legCap())} /viewer` : "uncapped /viewer");
}
const state = {
ws: null,
stream: null,
@@ -61,6 +146,10 @@ const state = {
startedAt: 0,
/** The size pickSurface() asked for, so a delivered shortfall can be named. */
requested: null,
/** The preset the live capture belongs to: the shortfall toast is
* preset-relative (it compares the delivered width against that preset's
* request), and the track's `contentHint` follows the preset too. */
qualityKey: DEFAULT_PRESET,
/** viewerId -> {pc, name, pending: [], state} */
peers: new Map(),
counters: { bytes: 0, at: 0 },
@@ -99,6 +188,8 @@ function renderIdle(message) {
ui.audio.textContent = "—";
ui.uptime.textContent = "—";
ui.perf.textContent = "—";
ui.qualityStat.textContent = "—";
ui.codec.textContent = "—";
if (message) toast(message);
}
@@ -131,29 +222,45 @@ function renderAudience() {
/* --------------------------------------------------------------- capture */
/** The size/frame-rate request for a preset, plus the size we asked for (the panel's shortfall
* toast compares the delivered width against it). `ideal`, never `exact` — a hard size fails or
* hides picker entries on displays/DPRs the browser cannot honour (AGENTS.md §5). */
function presetCapture(preset) {
const dpr = window.devicePixelRatio || 1;
const [maxW, maxH] = preset.max;
const requested = {
width: Math.min(Math.round(screen.width * dpr), maxW),
height: Math.min(Math.round(screen.height * dpr), maxH),
};
return {
requested,
video: {
width: { ideal: requested.width },
height: { ideal: requested.height },
frameRate: { ...preset.frameRate },
},
};
}
async function pickSurface() {
// The rule the kiosk wall depends on: a capture is only as sharp as the
// pixels the sharer's browser agreed to grab, and the request must name
// them. Without explicit dimensions Chromium rescales the monitor down to
// the *browser window* (PLAN §5: 1280×800 → 1200×750), which is the
// primary cause of a fuzzy wall. `ideal`, never `exact` — a hard size fails
// or hides picker entries on displays/DPRs the browser cannot honour, and
// would break sharing outright; `ideal` is a preference the browser can
// decline while still capturing. Capped at 3840×2160 so an 8K HiDPI sharer
// cannot starve a 1080p wall's decoder or its uplink (MIRROR_MAX_BITRATE
// is the tuning knob).
const dpr = window.devicePixelRatio || 1;
const requested = {
width: Math.min(Math.round(screen.width * dpr), 3840),
height: Math.min(Math.round(screen.height * dpr), 2160),
};
// primary cause of a fuzzy wall. The budget is the selected preset's
// (`sharp` is the old hardcoded request, so the default is unchanged);
// `ideal`, never `exact` — a hard size fails or hides picker entries on
// displays/DPRs the browser cannot honour, and would break sharing outright;
// `ideal` is a preference the browser can decline while still capturing.
// The cap keeps an 8K HiDPI sharer from starving a 1080p wall's decoder or
// its uplink (MIRROR_MAX_BITRATE is the tuning knob).
// Read the key once at the top: the audio attempt and its silent retry
// must build from the same preset, so it cannot change mid-function.
const key = currentPreset();
const preset = PRESETS[key];
const { requested, video } = presetCapture(preset);
// Built once and shared: the audio attempt and its silent retry must send
// identical video constraints; only the audio flag differs.
const video = {
width: { ideal: requested.width },
height: { ideal: requested.height },
frameRate: { ideal: 30, max: 60 },
};
const shared = {
video,
// Let the OS picker offer whole screens *and* individual windows, and let
@@ -191,7 +298,7 @@ async function pickSurface() {
audioAcceptedByBrowser = false;
stream = await navigator.mediaDevices.getDisplayMedia({ ...shared, audio: audioRequested });
}
return { stream, requested, audioRequested, audioAcceptedByBrowser };
return { stream, requested, key, audioRequested, audioAcceptedByBrowser };
}
/* The errors that genuinely mean "this source has no audio": the *audio
@@ -272,17 +379,21 @@ async function start() {
ui.start.disabled = false;
state.stream = captured.stream;
state.requested = captured.requested;
state.qualityKey = captured.key;
state.audioOffered = captured.audioAcceptedByBrowser;
const video = state.stream.getVideoTracks()[0];
if (video) {
// Screen content is text-first: prefer sharpness over frame rate when the
// link gets tight.
video.contentHint = "detail";
// link gets tight. The hint is per-preset now: `Light` is glanceable
// content, so motion wins over glyph crispness.
video.contentHint = PRESETS[state.qualityKey].contentHint;
// PLAN §5: Chromium rescales a monitor capture to the capturing window,
// so a delivered width below 75% of the request means the share page is
// smaller than the screen. Named once per share — start() runs once per
// share, and the mute/unmute republish must not repeat it.
// smaller than the screen. The threshold is preset-relative — the request
// is the preset's — which keeps it true for `Light`. Named once per share
// — start() runs once per share, and the mute/unmute republish must not
// repeat it.
const delivered = video.getSettings();
if (state.requested && delivered.width && delivered.width < 0.75 * state.requested.width) {
toast(
@@ -320,6 +431,7 @@ async function start() {
state.startedAt = Date.now();
renderLive();
renderQuality();
publish();
startMetrics();
}
@@ -373,26 +485,28 @@ function removePeer(viewerId, why) {
if (why === "failed") toast("A viewer could not be reached and was dropped.");
}
/* Screen content is text-first, so the encoder must never buy frame rate with
resolution — the `contentHint = "detail"` trade, made real at the sender.
`setParameters` is per-negotiation: a re-offer (a surface switch, a viewer
that re-joined) resets the encodings, and the `sendEncodings` passed to
addTransceiver is only the *initial* request. The call sites (negotiate()
after setLocalDescription, and the `connected` state change) are why the
cap survives every renegotiation. */
/* The degradation preference is the *preset's* — `sharp` is the old
hardcoded `maintain-resolution`, `light` is `maintain-framerate` — read at
call time, so a preset chosen before a viewer joins lands on that leg's
first offer and a mid-share change lands on the re-apply. `setParameters`
is per-negotiation: a re-offer (a surface switch, a viewer that re-joined)
resets the encodings, and the `sendEncodings` passed to addTransceiver is
only the *initial* request. The call sites (negotiate() after
setLocalDescription, and the `connected` state change) are why the cap
survives every renegotiation. */
async function applySenderQuality(peer) {
const sender = (peer.pc.getSenders() || []).find(
(s) => s && s.track && s.track.kind === "video",
);
if (!sender || typeof sender.setParameters !== "function") return;
const params = sender.getParameters();
params.degradationPreference = "maintain-resolution";
params.degradationPreference = PRESETS[currentPreset()].degradation;
params.encodings = params.encodings?.length ? params.encodings : [{}];
if (config.maxBitrateBps) {
if (legCap()) {
// 0 means "uncapped" in the WebRTC API; only a truthy value is a cap, and
// it lands on the first encoding only — the cap is per leg, not per
// simulcast layer.
params.encodings[0].maxBitrate = config.maxBitrateBps;
// simulcast layer. legCap() re-reads the preset in force at call time.
params.encodings[0].maxBitrate = legCap();
}
try {
await sender.setParameters(params);
@@ -409,6 +523,127 @@ async function applySenderQuality(peer) {
}
}
/** The settings the capture device settles on after applyConstraints()
* resolves. The device may re-initialise *asynchronously* and land on
* different settings than the snapshot read at resolution time — the
* synthetic e2e device in particular drops a size it was just given
* back to its native size within ~100 ms — so a publish built on the
* snapshot would name a size the capture has already stopped
* delivering. Poll until the settings read identically twice in a row,
* after a minimum observation window (so a stable pre-reset window
* cannot look settled), and give up at the cap (the last read is then
* the best truth we have). */
async function settledSettings(track, capMs = 1000, minimumMs = 250) {
const started = Date.now();
let last = null;
for (;;) {
const s = track.getSettings();
const now = { width: s.width || 0, height: s.height || 0, frameRate: s.frameRate || 0 };
const stable =
last &&
last.width === now.width &&
last.height === now.height &&
last.frameRate === now.frameRate;
if (Date.now() - started >= minimumMs && stable) return now;
last = now;
if (Date.now() - started >= capMs) return now;
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
/** A preset change made while live: the existing legs are re-priced
* *first* — setParameters commits without an offer, so the CPU/egress
* change is instant and no viewer is renegotiated for it — and the
* capture follows where the engine honours applyConstraints. It never
* negotiates here (A4: the sharer offers when the *track set* changes,
* which this does not) and never touches srcObject (the track object is
* unchanged, the transceiver keeps it — AGENTS.md §6: move the element,
* not the stream). When a constraint lands, the capture moved, so
* publish() — built on the *settled* settings (settledSettings), so the
* roster's width/height and every wall tile's caption match what the
* capture is actually delivering. When the engine refuses, do not:
* the track did not change, and the toast says the size lands on the
* next share instead of lying about a rescale. */
async function applyQuality() {
const key = currentPreset();
const preset = PRESETS[key];
state.qualityKey = key;
renderQuality();
// Legs first: these commit without an offer, so the CPU/egress change is
// instant and a capture constraint cannot hold it hostage.
for (const peer of state.peers.values()) {
await applySenderQuality(peer).catch((err) => console.warn("sender quality", err));
}
const track = state.stream && state.stream.getVideoTracks()[0];
// No live video track: the legs are re-priced and the choice is simply
// picked up by the next start() — the toast below says so.
let constraintLanded = false;
let sizeRequested = false;
let sizeFollowed = false;
if (track) {
track.contentHint = preset.contentHint;
if (typeof track.applyConstraints === "function") {
try {
try {
await track.applyConstraints(presetCapture(preset).video);
sizeRequested = true;
} catch (err) {
if (err && err.name === "OverconstrainedError") {
// A display that cannot rescale can still change rate: retry
// with the frame rate alone. The size was never applied, so
// sizeRequested stays false and the size lands on the next
// share, which the toast says.
await track.applyConstraints({ frameRate: preset.frameRate });
} else {
throw err;
}
}
constraintLanded = true;
} catch (err) {
// Swallow only what is named (AGENTS.md §7): any other refusal
// leaves the capture at its current size — and there is no
// republish either, because the track did not change and the
// roster is still true.
console.warn("capture constraints not honoured", err);
}
}
}
if (track && constraintLanded) {
state.requested = presetCapture(preset).requested;
// The device settles its settings asynchronously (settledSettings);
// only the settled read is what the capture is delivering.
const settled = await settledSettings(track);
if (sizeRequested) {
// Did the capture actually take the budget? The device may honour
// the width and derive its own height, or drop the size back to
// its native size entirely, so the *width* is the test: the budget
// is a ceiling, and a delivered width far above it means the
// capture did not follow it.
const requestedW = state.requested.width;
sizeFollowed =
settled.width > 0 &&
Math.abs(settled.width - requestedW) <= Math.max(16, 0.02 * requestedW);
}
// The capture moved (at least the frame rate): the roster's
// width/height — and every wall tile's caption — must match what the
// capture is delivering, not the last share.
publish();
}
// One line, naming the preset, its budget and whether the capture
// followed. bitrateFormat renders "—" for a 0 (uncapped) cap, so say
// "uncapped" in that case instead. No live capture, a refused
// rescale, or a device that did not deliver the budget means the size
// lands on the next share — and the line says so.
const [maxW, maxH] = preset.max;
const cap = legCap();
const perViewer = cap ? `${bitrateFormat(cap)} per viewer` : "uncapped per viewer";
toast(
`${key[0].toUpperCase()}${key.slice(1)} · ≤${maxW}×${maxH} ` +
`@${preset.frameRate.ideal}fps · ${perViewer}` +
(sizeFollowed ? "" : " · capture size applies next share"),
);
}
async function addPeer(viewerId, name) {
if (!state.stream) return;
removePeer(viewerId, "replace");
@@ -448,15 +683,19 @@ async function addPeer(viewerId, name) {
}
};
// Read once, before the track loop: the cap is per leg and per preset, so
// a preset chosen before this viewer joins lands on the leg from its first
// offer — the same call-time read the post-negotiation re-apply uses.
const cap = legCap();
for (const track of state.stream.getTracks()) {
const init = { direction: "sendonly" };
if (track.kind === "video" && config.maxBitrateBps) {
if (track.kind === "video" && cap) {
// Cap each leg: N viewers must not melt the sharer's encoder. This is
// kept *in addition to* the post-negotiation re-apply: `sendEncodings`
// here is only the initial request, and between addTransceiver and the
// first setLocalDescription it is the only thing capping the leg —
// applySenderQuality() cannot cover that window.
init.sendEncodings = [{ maxBitrate: config.maxBitrateBps }];
init.sendEncodings = [{ maxBitrate: cap }];
}
// addTransceiver and its addTrack fallback both return the track's
// sender, so one code path owns the quality of both.
@@ -536,10 +775,12 @@ async function sampleStats() {
const pcs = [...state.peers.values()].map((p) => p.pc).filter(Boolean);
if (!pcs.length) {
ui.perf.textContent = "—";
ui.codec.textContent = "—";
return;
}
let fps = 0;
let bytes = 0;
let codec = "";
for (const pc of pcs) {
try {
const report = await pc.getStats();
@@ -547,6 +788,18 @@ async function sampleStats() {
if (entry.type === "outbound-rtp" && entry.kind === "video") {
fps = Math.max(fps, entry.framesPerSecond || 0);
bytes += entry.bytesSent || 0;
// The codec this leg actually negotiated, resolved in the *same*
// report — one getStats() read, no extra call. The first leg that
// names one wins: every leg on this machine shares the engine's
// default codec preference, so the first name is the answer.
if (!codec) {
const codecEntry = entry.codecId ? report.get(entry.codecId) : null;
codec =
entry.codec ||
(codecEntry && codecEntry.mimeType) ||
(codecEntry && codecEntry.codec) ||
"";
}
}
});
} catch {
@@ -558,6 +811,12 @@ async function sampleStats() {
const bps = ((bytes - state.counters.bytes) / dt) * 8;
state.counters = { bytes, at: now };
ui.perf.textContent = `${Math.round(fps)} fps · ${bitrateFormat(bps)} total`;
// Written once per tick: the resolved name when any leg reported one, and
// `—` when none did — an engine (Firefox) does not expose the outbound
// codec in getStats() for every leg, and the panel must not guess. Not a
// hardware/software indicator either: the encoder's implementation is not
// exposed to the page; the name is the honest floor of what can be said.
ui.codec.textContent = shortCodec(codec) || "—";
}
/* ------------------------------------------------------------------ socket */
@@ -622,6 +881,15 @@ ui.name.addEventListener("change", () => {
if (state.stream) publish();
});
// A preset change lands live on the running share: applyQuality() re-prices
// the existing legs and re-applies the capture constraints. Not async on
// purpose — a rejected constraint must never become an unhandled rejection
// (the e2e suites fail on any pageerror); the catch is the named place it
// lands.
ui.quality.addEventListener("change", () => {
applyQuality().catch((err) => console.warn("quality", err));
});
window.addEventListener("beforeunload", () => {
if (state.ws) state.ws.send({ type: "unpublish" });
});
@@ -631,16 +899,16 @@ window.addEventListener("beforeunload", () => {
/* The share page's own small router: the two pages do not share a key
handler, and duplicating these lines is cheaper than a new abstraction over
net.js. Same rules as the wall: chords with Ctrl/Cmd/Alt belong to the
browser, a key in #name is text (it is the one real text box), and only
handled keys are preventDefault'ed. The commands: `s` → start, `x` → stop,
and the existing `Esc` → stop while live.
browser, a key in #name or the Quality select is text, and only handled
keys are preventDefault'ed. The commands: `s` → start, `x` → stop,
`q` → cycle the quality preset, and the existing `Esc` → stop while live.
A keydown is a user activation: that is the only reason `s` can call
getDisplayMedia() at all — the permission policy allows the OS picker to
open without a pointer gesture, which is why `s` works on a mouseless
kiosk. A silent no-op is the one outcome a keyboard-only operator cannot
debug, so every handled key either changes state or toasts why it did not. */
document.addEventListener("keydown", (event) => {
document.addEventListener("keydown", async (event) => {
if (event.defaultPrevented) return;
if (event.ctrlKey || event.metaKey || event.altKey) return;
const target = event.target;
@@ -666,6 +934,23 @@ document.addEventListener("keydown", (event) => {
if (state.stream) stop();
else toast("not sharing");
event.preventDefault();
} else if (key === "q") {
if (state.picking) {
// The picker is already up: a key in a text field is text, but the
// select cannot be reached while it is up, so the cycle would be a
// silent no-op — the one outcome a keyboard-only operator cannot
// debug (the rule written above the router).
toast("a picker is already up");
} else {
// Cycle sharp → balanced → light → sharp over the preset table; the
// select is the one source of truth, and the change event is NOT
// what applies it (setting .value does not fire one) — the apply is
// here, so the cycle and the effect are one gesture.
const keys = Object.keys(PRESETS);
ui.quality.value = keys[(keys.indexOf(currentPreset()) + 1) % keys.length];
await applyQuality().catch((err) => console.warn("quality", err));
}
event.preventDefault();
} else if (key === "escape" && state.stream) {
stop();
}
@@ -689,11 +974,19 @@ renderIdle();
/* Test scaffold: the one global this page may expose (AGENTS.md §6). The
e2e suite reads the live legs' sender parameters back through `peers` and
drives a real renegotiation through `renegotiate` to prove the quality
survives a re-offer. */
survives a re-offer; `lastRequested` and `qualityKey` read the request the
selected preset produced (delivered pixels are asserted where a real
capturer runs — the `realdisplay` suite). */
window.__probe = {
get peers() {
return state.peers;
},
get lastRequested() {
return state.requested;
},
get qualityKey() {
return state.qualityKey;
},
renegotiate(viewerId) {
return negotiate(viewerId);
},
+11 -3
View File
@@ -13,7 +13,7 @@ import socket
import subprocess
import sys
import time
from collections.abc import Iterator, Sequence
from collections.abc import Iterator, Mapping, Sequence
from pathlib import Path
import pytest
@@ -45,8 +45,15 @@ def _free_port() -> int:
return probe.getsockname()[1]
def _start(extra_args: Sequence[str] = ()) -> tuple[str, subprocess.Popen[str]]:
"""Spawn a real `mirror serve` on a free port and wait for it to answer."""
def _start(
extra_args: Sequence[str] = (),
extra_env: Mapping[str, str] = {},
) -> tuple[str, subprocess.Popen[str]]:
"""Spawn a real `mirror serve` on a free port and wait for it to answer.
`extra_env` overrides inherited environment variables (it wins over
`os.environ`, so a developer's `MIRROR_*` cannot leak into a test that
names the opposite value)."""
import httpx
port = _free_port()
@@ -74,6 +81,7 @@ def _start(extra_args: Sequence[str] = ()) -> tuple[str, subprocess.Popen[str]]:
"PYTHONUNBUFFERED": "1",
# A stray DEBUGPY in the developer's shell would stall the server.
"DEBUGPY": "0",
**extra_env,
},
)
url = f"http://127.0.0.1:{port}"
+619
View File
@@ -0,0 +1,619 @@
"""End-to-end: the capture request and the per-leg encoder parameters follow
the selected quality preset.
`pickSurface()` asks the capture device for the *selected* preset's pixel
budget and frame rate instead of one hardcoded 4K/30 request, and `start()`
records which preset the live capture belongs to. The capture assertions read
the page's own request back through `window.__probe` (AGENTS.md §6):
`lastRequested` is the size the request named, `qualityKey` the preset it
came from.
The per-leg assertions read the *committed* sender parameters back from the
sharer's real RTCPeerConnection legs through the same `__probe` handle:
each leg encodes under `round(config.maxBitrateBps * preset.factor)` and the
preset's `degradationPreference`, read at call time — so a preset chosen
before a viewer joins lands on that leg's first offer. Media rules still
govern: pixel assertions are made on the *viewer's* `<video>`.
Delivered *capture* pixels are deliberately not asserted here: with
`--use-fake-device-for-media-stream` the synthetic device is a fixed
1280x720 and its clamping is not the behaviour under test (PLAN §5). Where
a real capturer runs (`MIRROR_REALDISPLAY=1`, the `realdisplay` suite) the
delivered pixels are asserted instead.
The late-joining-viewer case proves the *first offer* of a leg that comes
up under a preset chosen before that viewer joined carries the preset's cap:
a new `RTCPeerConnection` is wrapped on the sharer page (the same pattern the
other e2e suites use for their `RTCPeerConnection` probes) and the parameters
are read back at `setLocalDescription` — before `negotiate()`'s
post-negotiation re-apply has run, so the only code that could have written
them is `addPeer()`'s `sendEncodings`.
"""
from __future__ import annotations
import re
import time
import pytest
from playwright.sync_api import expect
from .conftest import _start, _stop, frames_matching, start_sharing, stop_sharing
pytestmark = [pytest.mark.e2e, pytest.mark.timeout(180)]
@pytest.fixture
def device(base_url, browser, request):
"""Open a page as its own device (isolated context, fresh peer id).
`url` targets a different server than the session's (the zero-cap
fixture); the default is the session server, which is what the capture-
request tests use.
"""
opened = []
def _open(path: str = "/", errors: list | None = None, url: str | None = None):
context = browser.new_context(viewport={"width": 1100, "height": 720})
opened.append(context)
request.addfinalizer(context.close)
page = context.new_page()
if errors is not None:
page.on("pageerror", lambda error: errors.append(f"pageerror: {error}"))
page.goto(f"{url or base_url}{path}", wait_until="domcontentloaded")
expect(page.locator("#net")).to_have_attribute("data-state", "online", timeout=10_000)
return page
return _open
@pytest.fixture
def zero_cap_url():
"""A real `mirror serve` with `MIRROR_MAX_BITRATE=0` — the "uncapped"
setting of the WebRTC API, which must not turn into a 0 bps leg."""
url, process = _start(extra_env={"MIRROR_MAX_BITRATE": "0"})
yield url
_stop(process)
# Reads the video sender's committed parameters back from the sharer's live
# legs (copied per suite from test_sender_fidelity.py — one copy per suite is
# the convention). `state` is the leg's connectionState; `maxBitrate` is the
# cap the leg is actually encoding under (undefined when none was written —
# it reads back as null — which is exactly the "uncapped" state). If the
# server had written a 0 bps cap it would read back as 0, and the zero-cap
# test's `is None` assertion is what keeps the two apart.
SENDER_PROBE = """() => {
const probe = window.__probe;
if (!probe) return null;
return [...probe.peers.values()].map((peer) => {
const sender = peer.pc.getSenders().find(
(s) => s.track && s.track.kind === "video"
);
if (!sender) return null;
const params = sender.getParameters();
const encodings = params.encodings || [];
return {
state: peer.pc.connectionState,
degradationPreference: params.degradationPreference,
maxBitrate: encodings.length ? encodings[0].maxBitrate : null,
};
});
}"""
def _wait_for(sharer, want: int, pred, timeout: float = 45.0) -> list:
"""Poll the sharer's legs until `want` of them satisfy `pred` (or give up)."""
legs: list = []
deadline = time.time() + timeout
while time.time() < deadline:
legs = sharer.evaluate(SENDER_PROBE) or []
if len(legs) == want and all(leg and pred(leg) for leg in legs):
return legs
time.sleep(0.5)
return legs
# The page's own request, the preset it came from, the track's contentHint
# (a pure read-back of what start() set — not a delivered pixel), and the
# inputs the page used: screen.width / devicePixelRatio are read *from the
# page* — under Playwright `screen` is the emulated viewport, so hardcoding
# a display size would make this environment-dependent.
REQUEST_PROBE = """() => {
const probe = window.__probe;
if (!probe) return null;
const stream = document.querySelector('#preview').srcObject;
const track = stream && stream.getVideoTracks()[0];
return {
requested: probe.lastRequested,
qualityKey: probe.qualityKey,
contentHint: track ? track.contentHint : null,
screenWidth: screen.width,
screenHeight: screen.height,
dpr: window.devicePixelRatio || 1,
};
}"""
# The pixel ceilings and the per-preset contentHint, the mirror of `PRESETS`
# in share.js: the request is min(screen * dpr, cap). This suite asserts the
# *request* the page built; the table itself is guarded by test_app.py's
# three-preset agreement test.
PRESETS_UNDER_TEST = {
"sharp": {"cap": (3840, 2160), "contentHint": "detail"},
"balanced": {"cap": (1920, 1200), "contentHint": "detail"},
"light": {"cap": (960, 600), "contentHint": "motion"},
}
def _bitrate_format(bps: float) -> str:
"""The mirror of `bitrateFormat` in net.js — the panel's own renderer,
so the test can recompute the exact line the panel must print."""
if not bps or bps < 0:
return "—"
if bps >= 1e6:
return f"{bps / 1e6:.1f} Mbps"
return f"{round(bps / 1e3)} kbps"
class TestQualityPresets:
def test_each_preset_asks_the_capture_for_its_own_budget(self, device):
"""Each preset drives the capture request: `sharp` is the old
hardcoded 4K cap (the no-regression argument), `balanced` 1080p-class,
`light` a small and cheap budget — and the page records which preset
the live capture belongs to."""
errors: list = []
for key, preset in PRESETS_UNDER_TEST.items():
cap_w, cap_h = preset["cap"]
sharer = device("/mirror", errors)
sharer.select_option("#quality", key)
start_sharing(sharer)
result = sharer.evaluate(REQUEST_PROBE)
assert result, "the __probe handle is missing"
assert result["qualityKey"] == key, result
# The page's own arithmetic, recomputed from the page's own inputs:
# min(round(screen * dpr), cap).
expected_w = min(round(result["screenWidth"] * result["dpr"]), cap_w)
expected_h = min(round(result["screenHeight"] * result["dpr"]), cap_h)
assert result["requested"]["width"] == expected_w, result
assert result["requested"]["height"] == expected_h, result
# Text-first presets take `detail`; `light` is glanceable, so
# motion wins over glyph crispness.
assert result["contentHint"] == preset["contentHint"], result
assert errors == [], f"uncaught browser errors: {errors}"
def test_a_light_leg_encodes_at_the_presets_share(self, device, base_url):
"""A leg that comes up under `light` encodes at the preset's share
of the server's ceiling and with the preset's degradation preference —
both read from the preset *at call time*: `light` was selected before
sharing started, so the viewer's leg must carry them from its first
offer, and the wall must still be moving (a cheaper picture must not
be a dead one)."""
import httpx
cap = httpx.get(f"{base_url}/api/config", timeout=5).json()["maxBitrateBps"]
assert cap > 0
errors: list = []
sharer = device("/mirror", errors)
sharer.select_option("#quality", "light")
start_sharing(sharer)
viewer = device("/watch", errors)
expect(viewer.locator(".tile[data-state='live']")).to_have_count(1, timeout=25_000)
legs = _wait_for(sharer, 1, lambda leg: leg["state"] == "connected")
assert legs and legs[0]["state"] == "connected", legs
assert legs[0]["maxBitrate"] == round(cap * 0.35), legs
assert legs[0]["degradationPreference"] == "maintain-framerate", legs
# The cheaper picture still has to be a live one.
frames = frames_matching(viewer, ".tile video")
assert frames["distinct"] >= 2, f"a light leg froze: {frames}"
assert errors == [], f"uncaught browser errors: {errors}"
def test_each_preset_commits_its_sender_parameters(self, device, base_url):
"""The full committed-parameter matrix, `sharp` first so the
no-regression leg is the first thing it proves: each preset's live
leg commits `round(cap * factor)` and the preset's
`degradationPreference` — read at call time, so the preset selected
before sharing lands on the leg from its first offer — and the wall
stays moving on the viewer in every case (a cheaper picture must not
be a dead one). `sharp` is phase 02's behaviour verbatim: full cap,
`maintain-resolution`.
"""
import httpx
cap = httpx.get(f"{base_url}/api/config", timeout=5).json()["maxBitrateBps"]
assert cap > 0
# The committed-parameter mirror of PRESETS_UNDER_TEST: the factor the
# server's ceiling is scaled by and the degradation preference the
# encoder commits to, per preset. `sharp` first: full cap and
# maintain-resolution is the unchanged phase 02 behaviour.
committed = {
"sharp": (1.0, "maintain-resolution"),
"balanced": (0.6, "balanced"),
"light": (0.35, "maintain-framerate"),
}
for key, (factor, degradation) in committed.items():
errors: list = []
sharer = device("/mirror", errors)
sharer.select_option("#quality", key)
start_sharing(sharer)
viewer = device("/watch", errors)
expect(viewer.locator(".tile[data-state='live']")).to_have_count(1, timeout=25_000)
legs = _wait_for(sharer, 1, lambda leg: leg["state"] == "connected")
assert legs and legs[0]["state"] == "connected", (key, legs)
assert legs[0]["maxBitrate"] == round(cap * factor), (key, legs)
assert legs[0]["degradationPreference"] == degradation, (key, legs)
# The picture still has to be a live one in every preset.
frames = frames_matching(viewer, ".tile video")
assert frames["distinct"] >= 2, f"the {key} leg froze: {frames}"
assert errors == [], f"uncaught browser errors on {key}: {errors}"
# Tear the whole iteration down before the next preset: the
# contexts outlive the iteration, and the wall auto-subscribes
# *every live viewer* to *every new mirror* — an uncleaned
# viewer would stack a second (third, ...) leg on the next
# preset's sharer and break the one-leg expectation. Stop the
# share first, so the viewer's tile (and its peer connection)
# is torn down before its page goes away.
stop_sharing(sharer)
viewer.close()
sharer.close()
def test_a_zero_cap_stays_uncapped_under_light(self, device, zero_cap_url):
"""`MIRROR_MAX_BITRATE=0` is a real setting — "uncapped" in the
WebRTC API. `legCap()` must return 0 and no `maxBitrate` may be
written: an uncapped leg must not become a 0 bps leg. The preset's
degradation preference still lands, and the picture still moves.
"""
import httpx
config = httpx.get(f"{zero_cap_url}/api/config", timeout=5).json()
assert config["maxBitrateBps"] == 0, config
errors: list = []
sharer = device("/mirror", errors, url=zero_cap_url)
sharer.select_option("#quality", "light")
start_sharing(sharer)
viewer = device("/watch", errors, url=zero_cap_url)
expect(viewer.locator(".tile[data-state='live']")).to_have_count(1, timeout=25_000)
legs = _wait_for(sharer, 1, lambda leg: leg["state"] == "connected")
assert legs and legs[0]["state"] == "connected", legs
# No maxBitrate written at all: an uncapped leg, not a 0 bps one.
assert legs[0]["maxBitrate"] is None, legs
assert legs[0]["degradationPreference"] == "maintain-framerate", legs
frames = frames_matching(viewer, ".tile video")
assert frames["distinct"] >= 2, f"the uncapped leg froze: {frames}"
assert errors == [], f"uncaught browser errors: {errors}"
def test_changing_quality_mid_share_re_prices_the_live_leg(self, device, base_url):
"""A preset change made while a leg is live re-prices that leg —
``setParameters`` commits without an offer, so the CPU/egress
change is instant and no second RTCPeerConnection is needed for it —
and the viewer keeps decoding moving frames across the switch. The
roster must tell the truth in *both* outcomes: its width/height
equal the track's own ``getSettings()``, whatever the engine
settled on (the synthetic device clamps; delivered pixels are the
``realdisplay`` suite's, task 06)."""
import httpx
cap = httpx.get(f"{base_url}/api/config", timeout=5).json()["maxBitrateBps"]
assert cap > 0
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer) # the default preset: sharp
viewer = device("/watch", errors)
expect(viewer.locator(".tile[data-state='live']")).to_have_count(1, timeout=25_000)
legs = _wait_for(sharer, 1, lambda leg: leg["state"] == "connected")
assert legs and legs[0]["state"] == "connected", legs
# The switch, mid-share, through the control the user has.
sharer.select_option("#quality", "light")
# The single live leg re-prices in place: ``setParameters`` commits
# within ~5 s of the change, and the connection state returns to /
# stays ``connected`` (a leg that comes up fresh for the new size
# reads ``legCap()`` at its first offer, so the cap lands either way).
def repriced(leg):
return leg["state"] == "connected" and leg["maxBitrate"] == round(cap * 0.35)
re = _wait_for(sharer, 1, repriced, timeout=5.0)
# No second RTCPeerConnection: the roster of live legs is still the
# one the viewer was subscribed on.
assert sharer.evaluate("() => window.__probe.peers.size") == 1
assert re and re[0]["maxBitrate"] == round(cap * 0.35), re
assert re[0]["state"] == "connected", re
# The switch did not freeze anybody.
frames = frames_matching(viewer, ".tile video")
assert frames["distinct"] >= 2, frames
# The roster tells the truth: whatever the engine settled on, the
# published width/height equal the track's own settings. Do not
# assert a *delivered* size here — the synthetic device clamps, and
# task 06 owns delivered pixels on a real desktop.
def track_settings():
return sharer.evaluate(
"() => { const s = document.querySelector('#preview').srcObject;"
" const t = s && s.getVideoTracks()[0];"
" return t ? t.getSettings() : null; }"
)
def roster_agrees():
settings = track_settings()
if not settings or not settings.get("width"):
return False, None, settings
mirror = httpx.get(f"{base_url}/api/streams", timeout=5).json()["mirrors"]
assert len(mirror) == 1, mirror
mirror = mirror[0]
agrees = mirror["width"] == settings["width"] and mirror["height"] == settings["height"]
return agrees, mirror, settings
deadline = time.time() + 10.0
agrees, mirror, settings = roster_agrees()
while not agrees and time.time() < deadline:
time.sleep(0.5)
agrees, mirror, settings = roster_agrees()
assert agrees, f"roster does not match the track's own settings: {mirror!r} vs {settings!r}"
assert errors == [], f"uncaught browser errors: {errors}"
def test_a_device_that_cannot_rescale_still_changes_rate(self, device, base_url):
"""A display that cannot rescale answers the full constraint with the
named ``OverconstrainedError``: the retry applies the frame rate
alone, the leg is still re-priced, the viewer still moves, the
toast says the size lands on the next share (it does not pretend
the capture rescaled), and the roster stays truthful."""
import httpx
cap = httpx.get(f"{base_url}/api/config", timeout=5).json()["maxBitrateBps"]
assert cap > 0
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer)
viewer = device("/watch", errors)
expect(viewer.locator(".tile[data-state='live']")).to_have_count(1, timeout=25_000)
legs = _wait_for(sharer, 1, lambda leg: leg["state"] == "connected")
assert legs and legs[0]["state"] == "connected", legs
# Simulate the display: a constraint that carries a size fails with
# the named error; the frame-rate-only retry passes. (This is the
# real-world behaviour the retry exists for — a capturer that holds
# its size — and nothing else in the path is stubbed.)
sharer.evaluate(
"() => { const s = document.querySelector('#preview').srcObject;"
" const t = s.getVideoTracks()[0];"
" const orig = t.applyConstraints.bind(t);"
" t.applyConstraints = (c) => (c && c.width)"
" ? Promise.reject(new DOMException('size cannot be applied', 'OverconstrainedError'))"
" : orig(c); }"
)
sharer.select_option("#quality", "light")
# The toast lands with the settle (before the leg is measured
# again), so it is read first: the preset is named, and because
# the size was never applied the line says it lands next share.
toast = sharer.locator("#toast")
expect(toast).to_contain_text("Light", timeout=5_000)
text = toast.text_content() or ""
assert "next share" in text, text
# The leg is still re-priced, and the switch did not freeze anybody.
def repriced(leg):
return leg["state"] == "connected" and leg["maxBitrate"] == round(cap * 0.35)
re = _wait_for(sharer, 1, repriced, timeout=5.0)
assert re and re[0]["maxBitrate"] == round(cap * 0.35), re
frames = frames_matching(viewer, ".tile video")
assert frames["distinct"] >= 2, frames
# The roster tells the truth about whatever the device delivered.
def track_settings():
return sharer.evaluate(
"() => { const s = document.querySelector('#preview').srcObject;"
" const t = s && s.getVideoTracks()[0];"
" return t ? t.getSettings() : null; }"
)
def roster_agrees():
settings = track_settings()
if not settings or not settings.get("width"):
return False, None, settings
mirror = httpx.get(f"{base_url}/api/streams", timeout=5).json()["mirrors"]
assert len(mirror) == 1, mirror
mirror = mirror[0]
agrees = mirror["width"] == settings["width"] and mirror["height"] == settings["height"]
return agrees, mirror, settings
deadline = time.time() + 10.0
agrees, mirror, settings = roster_agrees()
while not agrees and time.time() < deadline:
time.sleep(0.5)
agrees, mirror, settings = roster_agrees()
assert agrees, f"roster does not match the track's own settings: {mirror!r} vs {settings!r}"
assert errors == [], f"uncaught browser errors: {errors}"
def test_a_viewer_who_joins_after_a_switch_gets_the_new_cap(self, device, base_url):
"""The `addPeer` path, not just the re-apply path.
A preset change mid-share re-prices the *existing* leg (task 04's
re-apply). This proves the other half: a viewer who subscribes
*after* the switch gets a leg whose **first offer** already carries
the new cap — `legCap()` is read at call time in `addPeer()` and
written into `sendEncodings` before any negotiation exists. The
sharer's `RTCPeerConnection` is wrapped (the same pattern the other
e2e suites use for their peer-connection probes) so the parameters
are read back at `setLocalDescription`, i.e. before `negotiate()`'s
post-negotiation re-apply has run: the only code that could have
written them is `addPeer()`'s `sendEncodings`.
"""
import httpx
cap = httpx.get(f"{base_url}/api/config", timeout=5).json()["maxBitrateBps"]
assert cap > 0
errors: list = []
sharer = device("/mirror", errors)
start_sharing(sharer) # the default preset: sharp
first = device("/watch", errors)
expect(first.locator(".tile[data-state='live']")).to_have_count(1, timeout=25_000)
legs = _wait_for(sharer, 1, lambda leg: leg["state"] == "connected")
assert legs and legs[0]["state"] == "connected", legs
assert legs[0]["maxBitrate"] == cap, legs
# The switch, mid-share: the existing leg re-prices via the re-apply.
sharer.select_option("#quality", "light")
def repriced(leg):
return leg["state"] == "connected" and leg["maxBitrate"] == round(cap * 0.35)
re = _wait_for(sharer, 1, repriced, timeout=5.0)
assert re and re[0]["maxBitrate"] == round(cap * 0.35), re
# From here on, every new RTCPeerConnection on the sharer page is
# the late joiner's leg: the wrapper records the committed parameters
# at setLocalDescription time, before negotiate()'s re-apply.
assert sharer.evaluate(
"""() => {
window.__firstOffers = [];
const RealPC = window.RTCPeerConnection;
window.RTCPeerConnection = function (config) {
const pc = new RealPC(config);
const orig = pc.setLocalDescription.bind(pc);
pc.setLocalDescription = (d) => orig(d).then(() => {
const sender = pc.getSenders().find(
(s) => s.track && s.track.kind === 'video'
);
const params = sender ? sender.getParameters() : {};
const enc = (params.encodings || [])[0] || {};
window.__firstOffers.push({
maxBitrate: 'maxBitrate' in enc ? enc.maxBitrate : null,
degradationPreference: params.degradationPreference || null,
});
return pc;
});
return pc;
};
window.RTCPeerConnection.prototype = RealPC.prototype;
window.RTCPeerConnection.static = RealPC.static;
return true;
}"""
)
late = device("/watch", errors)
expect(late.locator(".tile[data-state='live']")).to_have_count(1, timeout=25_000)
# Both legs are now live under the switched preset.
legs = _wait_for(sharer, 2, lambda leg: leg["state"] == "connected")
assert legs and all(leg["state"] == "connected" for leg in legs), legs
offers = sharer.evaluate("() => window.__firstOffers") or []
assert offers, "no first offer was captured from the late joiner's leg"
# The first offer carried the new cap: addPeer's sendEncodings, not
# the post-negotiation re-apply, which ran later on the same leg.
assert offers[-1]["maxBitrate"] == round(cap * 0.35), offers
# And the settled leg is still capped — nothing drifted after.
assert all(leg["maxBitrate"] == round(cap * 0.35) for leg in legs), legs
assert sharer.evaluate("() => window.__probe.peers.size") == 2
# The late joiner's picture is a live one, and the sharer agrees
# that two viewers are watching.
frames = frames_matching(late, ".tile video")
assert frames["distinct"] >= 2, f"the late joiner froze: {frames}"
expect(sharer.locator("#viewer-count")).to_have_text("2", timeout=10_000)
assert errors == [], f"uncaught browser errors: {errors}"
def test_the_panel_shows_the_budget_and_the_codec(self, device, base_url):
"""The panel says what the share is actually costing (phase 06 task
05): the quality budget in force — the selected preset, its capture
ceiling, its frame rate and its per-leg share of the server's cap —
and the codec the leg actually negotiated, resolved in the same
`getStats()` read the 1 s tick already makes. The codec is `—` where
the engine does not report it: the panel must never guess. Both
lines reset to `—` when the share stops."""
import httpx
cap = httpx.get(f"{base_url}/api/config", timeout=5).json()["maxBitrateBps"]
assert cap > 0
errors: list = []
sharer = device("/mirror", errors)
sharer.select_option("#quality", "balanced")
start_sharing(sharer)
# The budget line is written by start() from state: an exact match,
# not a substring — the panel must not misprice the share. The cap
# is read live from /api/config (never hardcoded) and scaled by the
# preset's factor, exactly as legCap() does.
expected = (
f"balanced · ≤1920×1200 @24fps · " # noqa: RUF001 (the panel line itself carries the multiplication sign)
f"{_bitrate_format(round(cap * 0.6))} /viewer"
)
expect(sharer.locator("#stat-quality")).to_have_text(expected, timeout=10_000)
viewer = device("/watch", errors)
expect(viewer.locator(".tile[data-state='live']")).to_have_count(1, timeout=25_000)
legs = _wait_for(sharer, 1, lambda leg: leg["state"] == "connected")
assert legs and legs[0]["state"] == "connected", legs
# The codec line is written once per 1 s stats tick (no extra
# getStats() call): let a tick run after the leg is up, then read it.
time.sleep(2.5)
codec = (sharer.locator("#stat-codec").text_content() or "").strip()
# The shape, not the codec: which one the engine negotiates is not a
# contract. `—` is the honest floor where the engine does not expose
# the outbound codec; a name must be the short form
# (`video/VP8` → `VP8`), never a guess.
assert codec == "—" or re.fullmatch(r"[A-Z0-9/]{2,16}", codec), codec
# The share is still a live one while the panel reports on it.
frames = frames_matching(viewer, ".tile video")
assert frames["distinct"] >= 2, frames
# Stopping resets both lines: `—`, not a stale reading.
stop_sharing(sharer)
expect(sharer.locator("#stat-quality")).to_have_text("—", timeout=10_000)
expect(sharer.locator("#stat-codec")).to_have_text("—", timeout=10_000)
assert errors == [], f"uncaught browser errors: {errors}"
def test_q_cycles_the_preset_and_says_so(self, device):
"""``q`` cycles the preset on the share page (sharp → balanced →
light → sharp) and toasts the new one, naming it and its budget;
a ``q`` typed into ``#name`` is text, not a command, and never
changes the select (the router's text-field guard)."""
errors: list = []
page = device("/mirror", errors)
toast = page.locator("#toast")
page.keyboard.press("q")
expect(page.locator("#quality")).to_have_value("balanced", timeout=5_000)
assert page.evaluate("() => window.__probe.qualityKey") == "balanced"
expect(toast).to_contain_text("Balanced", timeout=5_000)
text = toast.text_content() or ""
assert "≤1920×1200" in text, text # noqa: RUF001 (the toast itself carries the multiplication sign)
# Nothing is shared yet: the capture size lands on the next share,
# and the line says so rather than pretending it rescaled.
assert "next share" in text, text
page.keyboard.press("q")
expect(page.locator("#quality")).to_have_value("light")
expect(toast).to_contain_text("Light", timeout=5_000)
page.keyboard.press("q")
expect(page.locator("#quality")).to_have_value("sharp")
expect(toast).to_contain_text("Sharp", timeout=5_000)
# A ``q`` typed into #name is text, not a command: the select stays.
page.locator("#name").click()
page.keyboard.type("q")
expect(page.locator("#quality")).to_have_value("sharp")
assert errors == [], f"uncaught browser errors: {errors}"
+99
View File
@@ -443,6 +443,105 @@ class TestRealX11Capture:
expect(share_page.locator("#viewer-count")).to_have_text("1", timeout=20_000)
assert errors == [], f"uncaught browser errors: {errors}"
def test_a_light_preset_delivers_a_small_picture(self, share_page, watch_page):
"""The delivered-pixel proof that the preset reaches the *real*
capturer, not just the request object.
``#quality = light`` is selected **before** the share starts, so the
request — under Playwright ``screen`` is the emulated viewport, so
the 1200x760 share window asks ``min(viewport, 960x600)`` =
960x600 — goes to Chromium's real X11 backend. The sharer's track
settings must honour the budget (at least 80% of the 960 request
wide, never above it): that is the proof the request reached the
real capturer and was not clamped away to the desktop or the
window. The viewer's decoded width must be at or under the cap —
the phase's delivered-pixels criterion — *and below the sharp
path's floor*: if a ``light`` share delivered a sharp-size picture,
the budget never reached the wire. The aspect guard keeps proving
the picture is *our* desktop, not somebody else's window.
The sharp path's floor (``SHARP_FLOOR_PX``) is deliberately
**not** asserted here: it stays the sharp test's, and a small
picture is exactly what a ``light`` share should deliver — the
small picture that is still the whole desktop.
Measured on this host (Xvfb 1280x800x24, phase 06): the capturer
delivered 960x600 (track settings, ``frameRate`` 15), and the
viewer decoded 320x200 — VP8 honouring ``light``'s
``maintain-framerate`` at ``round(25 Mbit/s x 0.35)`` = 8.75
Mbit/s, i.e. the encoder's resolution-degradation policy (the same
leg encodes 960x600 under ``maintain-resolution``); the encoder
may sacrifice resolution, which is the preset's contract, so the
decoded-width floor below guards a *collapsed* picture (>= a
quarter of the request), not the encoder's honest ladder.
"""
errors: list[str] = []
share_page.on("pageerror", lambda error: errors.append(str(error)))
share_page.select_option("#quality", "light")
share_page.locator("#start").click()
expect(share_page.locator("body")).to_have_attribute("data-live", "on", timeout=40_000)
# The preset is what the live capture belongs to (a pure parameter
# read, not a pixel claim — the pixels are the viewer's, below).
assert share_page.evaluate("() => window.__probe.qualityKey") == "light"
# The request reached the *real* capturer and was not clamped away:
# the track the X11 backend settled on honours the light budget —
# at least 80% of the 960 request wide (the capturer did not drop
# the size), never above it (the capturer did not ignore it and
# deliver the 1200x750 window-clamped desktop).
settings = share_page.evaluate(
"""() => {
const track = document.querySelector('#preview').srcObject.getVideoTracks()[0];
const s = track.getSettings();
return {width: s.width, height: s.height, surface: s.displaySurface};
}"""
)
assert settings["surface"] == "monitor", f"not a monitor capture: {settings}"
assert settings["width"] <= 990, f"the capturer ignored the light budget: {settings}"
assert settings["width"] >= 768, f"the capturer did not honour the request: {settings}"
expect(watch_page.locator(".tile[data-state='live']")).to_have_count(1, timeout=60_000)
# Start measuring on the watcher, then paint: cycling the captured
# machine's background is what a real presenter's screen does constantly.
assert watch_page.evaluate(PROBE) == "started"
for colour in ("#c81e1e", "#0b0d10", "#1e6ec8", "#0b0d10", "#1ec85a"):
share_page.evaluate("(colour) => { document.body.style.background = colour; }", colour)
time.sleep(0.28)
result = watch_page.evaluate("() => window.__probe")
# Delivered pixels follow the preset: the decoded picture is at or
# under the light cap, and below the sharp path's floor (a
# sharp-size picture from a light share would mean the budget
# never reached the wire). Measured 320x200 on this host — the
# encoder's maintain-framerate degradation, see the docstring.
assert result["width"] <= 990, (
f"the watcher decoded a {result['width']}px-wide picture: the light budget is "
f"<= 960px, so a wider decode means the budget did not reach the wire: {result}"
)
assert result["width"] < SHARP_FLOOR_PX, (
f"the watcher decoded a {result['width']}px-wide picture: that is at or above "
f"the sharp floor ({SHARP_FLOOR_PX}px), so a light share delivered a sharp-size "
f"picture: {result}"
)
assert result["width"] >= 240, (
f"the watcher decoded a {result['width']}px-wide picture: a quarter of the 960px "
f"request must arrive at least, or the picture collapsed: {result}"
)
assert abs(result["width"] / result["height"] - XVFB_ASPECT) < 0.06, (
f"{result} does not match the {XVFB_GEOMETRY} desktop aspect "
f"({XVFB_ASPECT:.3f}); this is not our Xvfb screen"
)
assert result["distinct"] >= 2, (
f"the watcher received a frozen frame: {result} — a small-but-still picture "
f"must not pass a delivered-pixel test"
)
expect(share_page.locator("#viewer-count")).to_have_text("1", timeout=20_000)
assert errors == [], f"uncaught browser errors: {errors}"
def test_a_real_desktop_shares_audio_when_the_host_offers_any(self, base_url, browsers):
"""The realdisplay counterpart of the audio-arrival assertion.
+38 -6
View File
@@ -177,15 +177,47 @@ class TestClientScripts:
"a different bitrate silently"
)
def test_the_quality_options_are_the_three_presets(self):
"""`#quality` (mirror.html) and `PRESETS` (share.js) are one list in two places.
If the option values drift from the table keys, a preset the panel offers has
no row (or an orphan row the panel can never select), and `currentPreset()`
falls back to `sharp` instead of noticing: the sharer gets a wall they did not
ask for and nothing fails. One list, two places, one test that notices (the
same shape as the bitrate-default test)."""
html = (STATIC / "mirror.html").read_text()
select = re.search(r'<select id="quality"[^>]*>.*?</select>', html, re.S)
assert select, "mirror.html must render a #quality select"
options = set(re.findall(r'value="([A-Za-z0-9_-]+)"', select.group(0)))
source = (STATIC / "share.js").read_text()
table = re.search(r"const PRESETS = Object\.freeze\(\{(.*?)\}\);", source, re.S)
assert table, "share.js must define the PRESETS table"
# Top-level keys sit two spaces in; the fields inside a preset sit four
# spaces in, so anchoring on exactly two spaces selects the keys only.
keys = set(re.findall(r"^ ([a-z]+): \{$", table.group(1), re.M))
expected = {"sharp", "balanced", "light"}
assert options == expected, f"#quality offers {sorted(options)}, not {sorted(expected)}"
assert keys == expected, f"PRESETS keys are {sorted(keys)}, not {sorted(expected)}"
assert options == keys, "the select offers presets the table cannot resolve"
def test_sender_quality_is_reapplied_on_negotiation_and_connection(self):
"""`setParameters` is per-negotiation: a re-offer (a surface switch,
a viewer that re-joined) resets the encodings. The cap must therefore
be re-applied after `setLocalDescription` *and* when the leg becomes
`connected`. Both call sites are asserted so a future edit cannot drop
one and silently soften the wall."""
a viewer that re-joined) resets the encodings. The preset's cap and
degradation preference must therefore be re-applied after
`setLocalDescription` *and* when the leg becomes `connected`. Both
call sites are asserted so a future edit cannot drop one and silently
soften the wall. The preference itself stays a single, named
decision — the PRESETS table's `degradation` field, read at call
time — so a hardcoded literal at the call site would be a second,
drifting decision."""
source = (STATIC / "share.js").read_text()
assert source.count('"maintain-resolution"') == 1, (
"the degradation preference must be a single, named decision"
assert re.search(r'degradationPreference\s*=\s*"', source) is None, (
"a hardcoded degradation literal at a call site is a second decision"
)
assert "params.degradationPreference = PRESETS[currentPreset()].degradation" in source, (
"the preference is not read from the PRESETS table at call time"
)
assert "async function applySenderQuality" in source