phase: 06_sharer_quality_presets
**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:
@@ -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
|
||||
+119
@@ -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
|
||||
+80
@@ -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
|
||||
+63
@@ -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
|
||||
Reference in New Issue
Block a user