The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").
Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.
Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).
One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
74 lines
2.9 KiB
TypeScript
74 lines
2.9 KiB
TypeScript
// One screenshot of this tab, for a debug session.
|
|
//
|
|
// Through the Screen Capture API with the current tab preferred: the browser asks every time (there
|
|
// is no silent capture on the web, by design), the user can pick something else or cancel, and one
|
|
// frame is taken before the track is stopped at once — nothing keeps recording.
|
|
//
|
|
// A cancelled or refused picker is `null`, not an error: it is the user saying no. Measured in the
|
|
// desktop app's Chromium (2026-09-19): refused capture rejects with `NotAllowedError` — the same
|
|
// name Chrome gives a cancelled picker. A tab that WAS shared but gave no frame is an error, though:
|
|
// the user said yes, and a silent nothing would read as a screenshot taken.
|
|
//
|
|
// The frame is read through a <video> and a canvas rather than `ImageCapture.grabFrame`, which
|
|
// Firefox and Safari lack.
|
|
|
|
/** Whether this browser can capture a tab at all (iOS Safari cannot — the button is not shown). */
|
|
export function canCaptureTab(): boolean {
|
|
return typeof navigator !== "undefined" && !!navigator.mediaDevices?.getDisplayMedia;
|
|
}
|
|
|
|
/** Ask for this tab and return one PNG frame of it, or null when the user did not share. Throws
|
|
* `NoFrameError` when the shared tab gave no frame. */
|
|
export async function captureTab(): Promise<Blob | null> {
|
|
let stream: MediaStream;
|
|
try {
|
|
stream = await navigator.mediaDevices.getDisplayMedia({
|
|
video: { displaySurface: "browser" },
|
|
audio: false,
|
|
// Chromium hints: offer THIS tab first, and nothing that would record the whole screen.
|
|
preferCurrentTab: true,
|
|
selfBrowserSurface: "include",
|
|
surfaceSwitching: "exclude",
|
|
monitorTypeSurfaces: "exclude",
|
|
} as DisplayMediaStreamOptions);
|
|
} catch (err) {
|
|
if (
|
|
err instanceof DOMException &&
|
|
(err.name === "NotAllowedError" || err.name === "AbortError")
|
|
)
|
|
return null;
|
|
throw err;
|
|
}
|
|
try {
|
|
return await firstFrame(stream);
|
|
} finally {
|
|
for (const track of stream.getTracks()) track.stop();
|
|
}
|
|
}
|
|
|
|
class NoFrameError extends Error {
|
|
constructor() {
|
|
super("the shared tab gave no frame");
|
|
this.name = "NoFrameError";
|
|
}
|
|
}
|
|
|
|
async function firstFrame(stream: MediaStream): Promise<Blob> {
|
|
const video = document.createElement("video");
|
|
video.muted = true;
|
|
video.playsInline = true;
|
|
video.srcObject = stream;
|
|
await video.play();
|
|
// The first frame can arrive a beat after `play` resolves; wait until it has a size.
|
|
for (let i = 0; i < 20 && !video.videoWidth; i++) await new Promise((r) => setTimeout(r, 50));
|
|
if (!video.videoWidth) throw new NoFrameError();
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = video.videoWidth;
|
|
canvas.height = video.videoHeight;
|
|
canvas.getContext("2d")?.drawImage(video, 0, 0);
|
|
video.srcObject = null;
|
|
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
|
|
if (!blob) throw new NoFrameError();
|
|
return blob;
|
|
}
|