# Phase 1: Stabilize Video Pipeline — Research
**Researched:** 2026-05-15
**Domain:** Chrome MV3 extension, offscreen documents, `getDisplayMedia`,
`MediaRecorder` ring buffer, WebM container, SW lifecycle, Vite + crxjs.
**Confidence:** HIGH on Chrome API contracts; HIGH on canonical patterns
(verified against an in-the-wild production extension); MEDIUM on
`MediaRecorder` cluster-boundary alignment with `timeslice=2000ms` (the
spec is silent and Chromium docs are silent — published evidence is
indirect; we have a mitigation in place via the D-13 fallback).
## User Constraints (from CONTEXT.md)
### Locked Decisions
**Capture API — AMENDS DEC-003**
This phase REPLACES the SPEC-locked `chrome.tabCapture` choice with
`getDisplayMedia()` capture. Done eyes-open: the operator gains broader
capture coverage at the cost of the SPEC §1 "silent operation" property.
The doc cascade is enumerated in the **Doc Amendments (precede code)**
subsection below.
- **D-01:** Capture mechanism is `navigator.mediaDevices.getDisplayMedia()`
invoked **inside the offscreen document**. No more
`chrome.tabCapture.getMediaStreamId`, no more SW-side gesture juggling.
- **D-02:** Offscreen document is created with
`chrome.offscreen.Reason.DISPLAY_MEDIA` (replaces `USER_MEDIA`).
- **D-03:** One-time source picker on session start; the operator picks
"screen" or "window" once. If they later click the Chrome "Stop sharing"
banner or the captured source disappears, the offscreen surfaces an error
to the SW and the popup re-prompts on next interaction. (Exact error-UX
copy is deferred to Phase 3 — see Deferred Ideas.)
- **D-04:** Operator UX is **NOT** silent. Chrome's permanent "Sharing your
screen" indicator is shown while recording. We accept this as the cost
of the API choice.
- **D-05:** `manifest.json` permissions follow the new API: `desktopCapture`
replaces `tabCapture`; `activeTab` becomes unnecessary for the video
pipeline but stays for `chrome.tabs.captureVisibleTab` (screenshot path,
Phase 3 concern — kept).
**Offscreen source-of-truth location**
- **D-06:** Recorder code lives at **`src/offscreen/recorder.ts`** as a real
TypeScript module with strict type-check, source maps, and IDE support.
- **D-07:** `offscreen/index.html` is rewritten to load the bundled module
via crxjs. The runtime path remains `offscreen/index.html` (referenced
from SW via `chrome.runtime.getURL('offscreen/index.html')`).
- **D-08:** **DELETE** `offscreen/index.ts` (orphaned dead code) and the
entire `copy-offscreen` plugin block in `vite.config.ts:11-184`. crxjs
picks up the new TS entry through the HTML reference.
**Ring-buffer mechanism**
- **D-09:** **Single continuous MediaRecorder** for the whole session.
`mediaRecorder.start(2000)` so chunks land on cluster boundaries per the
spec timeslice (DEC-003, SPEC §4.1). No restart strategy at this point.
- **D-10:** Retain the **first emitted chunk** (the chunk produced by the
first `dataavailable` event after `start()`) **indefinitely** — it carries
the EBML header plus the initial cluster. CON-webm-header-retention.
- **D-11:** Drop later chunks once they are older than 30 s, by chunk
arrival timestamp. Keep header + every chunk newer than `now - 30000 ms`.
- **D-12:** Acceptance gate for Phase 1: `ffprobe -v error -f matroska -i
` must return exit 0 with no decoder warnings on a
fresh-export sample. Plan-checker enforces this as a phase success
criterion.
- **D-13:** **Fallback if D-12 fails:** revise the plan mid-phase to use
*restart-segments* (stop + restart the MediaRecorder every 10 s, keep
the 3 most-recent self-contained segments, concat on save). Documented
as a known fallback so the planner can pre-stage the alternative
structure in PLAN.md.
**Tab-switch behavior**
- **D-14:** **Not applicable** under the new capture API. `getDisplayMedia()`
captures a screen or window, not a tab — there is nothing to re-attach
on `chrome.tabs.onActivated`. Phase 1 explicitly **removes** any
tab-switch handling from `src/background/index.ts`.
- **D-15:** Operator switching tabs no longer interrupts the recording —
the buffer keeps filling regardless of active tab.
**State survival across SW unload**
- **D-16:** Video buffer **ownership moves to the offscreen document**. The
offscreen survives SW unloads because it holds the
`DISPLAY_MEDIA`-reason capture; chunks accumulate there.
- **D-17:** A long-lived `chrome.runtime.connect` port from offscreen → SW
serves as the keepalive (this is the only mechanism that actually
resets the SW idle timer — `chrome.alarms` callbacks do not, contrary
to DEC-010).
- **D-18:** **DELETE** the `chrome.alarms` keepalive
(`src/background/index.ts:171-178`). DEC-010 and CON-service-worker-keepalive
are amended in the doc cascade below.
- **D-19:** On export, SW requests the buffer from offscreen over the port
(or one-shot `chrome.runtime.sendMessage`). SW does **NOT** cache
chunks. CON-buffer-storage is honored — buffer is plain JS variable in
offscreen memory, no `chrome.storage.session`, no IndexedDB. The
existing IndexedDB code path in `vite.config.ts:43-104` is **DELETED**
along with the inline plugin.
**Doc Amendments (precede code)**
These document edits **MUST** ship before any code-touching task in this
phase, so downstream phases see a consistent baseline:
- **D-A1:** Amend `.planning/intel/decisions.md` DEC-003 to record the
`getDisplayMedia` replacement, with rationale and the explicit silent-
operation trade-off. Amend DEC-010 to record port keepalive replacing
alarms keepalive.
- **D-A2:** Amend `.planning/intel/constraints.md` to **RETIRE**
CON-tab-capture-binding and CON-service-worker-keepalive. Add new
CON-display-capture-binding (one-time picker, "Sharing" indicator).
- **D-A3:** Amend `.planning/PROJECT.md` Key Decisions table (DEC-003,
DEC-010) and Constraints section accordingly.
- **D-A4:** Amend `.planning/REQUIREMENTS.md` REQ-video-ring-buffer to
remove "active-tab" wording and update API binding.
- **D-A5:** Amend `.planning/ROADMAP.md` Phase 1 description and Success
Criterion #2 (drop the "tab re-attach" clause).
- **D-A6:** Amend `manifest.json`: swap `tabCapture` → `desktopCapture`
in `permissions`. Keep `activeTab` for the screenshot path.
### Claude's Discretion
- Exact protocol choice for offscreen↔SW messaging (port for keepalive +
sendMessage for one-shot vs port-only).
- Codec strictness: enforce `video/webm; codecs=vp9` via
`MediaRecorder.isTypeSupported`; fail loud if unsupported (no fallback
chain — current code's vp9→vp8→h264→default fallback is removed).
- Internal naming for the new buffer-owning module (offscreen-recorder vs
display-recorder etc.).
- Code-style choices around TS strictness within `src/offscreen/`
(already on `"strict": true` per tsconfig).
### Deferred Ideas (OUT OF SCOPE)
- **Error UX for "user stopped sharing" mid-session.** The popup needs a
state for this — Phase 3 territory (REQ-popup-ui state machine
extension).
- **Audio capture.** `getDisplayMedia()` makes audio capture trivial
(`audio: true`), but SPEC §9 explicitly excludes audio from Phase 1
(Phase 2 work — CAP-01). Capture this as an easier-now-than-before
follow-up.
- **Per-tab silent capture mode** as an opt-in via `config.json`. Could
re-introduce tabCapture for installations that prioritize silent
operation over broad coverage. Future phase if there's demand.
- **Cluster-aware EBML trim (ts-ebml).** Not needed for Phase 1 if
continuous + age-trim verifies via ffprobe. Keep on the shelf as a
third fallback under D-13.
- **`chrome.storage.session` cold-start recovery.** Buffer pointer
rehydration after offscreen crash. Phase 5 (Harden + clean up)
territory.
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| REQ-video-ring-buffer | 30 s active-tab video ring buffer captured via `MediaRecorder` at `video/webm; codecs=vp9` @ 400 kbps with 2 s timeslice. AMENDED: capture API is `getDisplayMedia()` (D-01), not `chrome.tabCapture`. First chunk (WebM header) retained indefinitely (CON-webm-header-retention); subsequent chunks rotate out by 30 s TTL. **Capture is always-on**: starts on first popup invocation, runs continuously regardless of which tab the operator is on (no tab re-attach needed — display capture is screen/window-bound, not tab-bound). | (1) Canonical pattern for SW + offscreen + getDisplayMedia confirmed by Google sample + working production extension (Proscreen-S3). (2) WebM header / cluster trim semantics documented under "Pitfall 1" + "Validation Architecture". (3) Port-keepalive replaces alarm-keepalive per Chrome 110+ docs. (4) `MediaRecorder.start(2000)` semantics documented under Pitfall 1 with D-13 fallback if cluster alignment fails ffprobe gate. |
## Project Constraints (from CLAUDE.md)
> No project-level CLAUDE.md exists at `/home/parf/projects/work/repremium/CLAUDE.md`.
> User's global `~/.claude/CLAUDE.md` applies — relevant excerpts:
- **Iterative development:** Small, reviewable changes. Break large work
into phases. Plans should be concise (< 100 lines); detail goes into
context/research files.
- **Extension over duplication:** Add functionality to existing code via
options/parameters rather than parallel implementations. *(Applies to
reusing `videoBuffer`/`cleanupVideoBuffer` patterns from the current
SW — preserve structure, relocate to offscreen.)*
- **Defensive coding:** Validate external dependencies and environment
early; fail fast with clear error messages. *(Codec fail-loud via
`MediaRecorder.isTypeSupported`; track-ended detection.)*
- **Naming:** Full words, `isFoo`/`hasFoo`/`shouldFoo` for booleans,
`SCREAMING_SNAKE` for true constants.
- **Tools first:** Use automated tools before manual edits. *(crxjs
handles the offscreen build; do not hand-roll Vite plugins.)*
- **Verify claims before presenting.** Cite authoritative sources.
- **TypeScript:** Type arrow-function parameters explicitly.
- **Don't ignore lint/type errors without research.** *(Maps to audit
P1 #13: no `as any`, no `@ts-ignore` in new code.)*
- **Naming convention violation already in repo:** `mediaRecorder` (camel)
shadowing module-level `let mediaRecorder` is the exact P0 #2 defect we
are fixing — rename module-level to avoid recurrence.
> **Note on the codebase's Russian inline comments:** The user's global
> rule prefers Python/Google style guides, but this repo is a TypeScript
> extension built to a Russian-authored SPEC. Inline Russian comments are
> idiomatic and preserved per the SPEC's source-of-truth language (also
> reaffirmed in CONTEXT.md "Established patterns"). User-facing strings
> ("Сохранить отчёт об ошибке" etc.) are part of the contract.
## Summary
The audit's seven P0 defects boil down to two structural problems in this
phase: **(a) the offscreen runtime lives as a string literal inside
`vite.config.ts:11-184` and shadows the real `offscreen/index.ts`, with a
shadow `let mediaRecorder` that makes `stopRecording` a no-op**; **(b) the
ring-buffer math is right in `src/background/index.ts` but the lifecycle
plumbing is wrong**: `mediaRecorder.start(200)` produces too-short chunks
that mostly don't start on WebM cluster boundaries, capture only begins
when the popup is opened, the SW's `chrome.alarms` keepalive does run but
the SW still loses its `videoBuffer` array between idle unloads, and the
SW's `VIDEO_CHUNK` message handler expects a Blob that `chrome.runtime.sendMessage`
cannot transmit (forcing the buggy IndexedDB workaround in `vite.config.ts:43-104`).
CONTEXT.md amends DEC-003 to `getDisplayMedia()` instead of `chrome.tabCapture`
— eyes-open trade-off, broader capture coverage at the cost of the Chrome
"Sharing your screen" banner. This is a canonical Chrome MV3 pattern:
[CITED: developer.chrome.com/docs/extensions/how-to/web-platform/screen-capture]
"To record in the background and across navigations, use an offscreen
document with the DISPLAY_MEDIA reason." We have at least one in-the-wild
production extension (Proscreen-S3) confirming the exact architecture
works.
**Primary recommendation:** Build `src/offscreen/recorder.ts` as a real
TS module that owns: (1) a single continuous `MediaRecorder` started with
`timeslice=2000`, (2) the in-memory ring buffer with WebM-header pinning
and 30 s arrival-timestamp trim, (3) a long-lived `chrome.runtime.connect`
port to the SW that doubles as the SW keepalive, and (4) a single
on-demand `GET_BUFFER` handler that returns the chunks for ZIP packaging.
The SW shrinks to: offscreen lifecycle management + port handling +
manifest-time recording bootstrap. The verification gate is `ffprobe -v error`
on a fresh export sample — if that fails because cluster boundaries don't
align with the 2 s timeslice, fall back to D-13's restart-segments
strategy (pre-staged in PLAN.md so we don't have to re-plan mid-phase).
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Display capture (`getDisplayMedia`) | Offscreen Document | — | SW has no DOM and cannot hold a `MediaStream`. Chrome 116+ requires `chrome.offscreen.Reason.DISPLAY_MEDIA`. [CITED: developer.chrome.com/docs/extensions/reference/api/offscreen] |
| MediaRecorder lifecycle | Offscreen Document | — | `MediaRecorder` instances are tied to a `MediaStream` which lives in the offscreen DOM context. |
| In-memory ring buffer | Offscreen Document | — | SW unloads after ~30 s idle (Chrome 110+ rules); offscreen survives because it owns the `DISPLAY_MEDIA` capture. |
| Codec capability check (`isTypeSupported`) | Offscreen Document | — | API is on `MediaRecorder`, which is offscreen-bound. SW reports the result for telemetry. |
| Offscreen lifecycle (create / close / hasDocument) | Service Worker | — | `chrome.offscreen.*` API is SW-bound. |
| Long-lived port keepalive | Offscreen Document → SW | — | Offscreen initiates `chrome.runtime.connect()` because it is the long-living party with a real reason to stay alive. SW receives the port. |
| Buffer export on user action | Service Worker | Offscreen Document | SW receives popup message, requests buffer from offscreen over the port, returns chunks to popup. |
| Manifest permission boundary | Manifest | — | `desktopCapture` for the API name (CONTEXT.md D-A6); `offscreen` to gate `chrome.offscreen.*`. Note: `getDisplayMedia()` itself is a web standard API and does NOT require `desktopCapture` (which gates only `chrome.desktopCapture.chooseDesktopMedia`). Including `desktopCapture` is harmless and matches CONTEXT.md D-05. [VERIFIED: chrome.desktopCapture API docs] |
| Stop-sharing recovery | Offscreen Document | Service Worker | `MediaStreamTrack.onended` fires inside offscreen; offscreen messages SW; SW updates state for popup (popup state machine is Phase 3 territory). |
## Standard Stack
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `@crxjs/vite-plugin` | `^2.4.0` (currently `^2.0.0-beta.25` in `package.json`) | Vite plugin that reads `manifest.json`, bundles each entry (SW, content scripts, popup, offscreen HTML), and produces a Chrome-loadable `dist/`. | Standard build for MV3 + TS + Vite per the project's existing setup (DEC-012). [VERIFIED: npm view @crxjs/vite-plugin version returned 2.4.0 on 2026-05-15] |
| `@types/chrome` | `^0.1.42` (currently `^0.0.268` in `package.json`) | Type definitions for the `chrome.*` namespace including `chrome.offscreen.Reason.DISPLAY_MEDIA`. | Audit P1 #13 calls out that the current `0.0.268` is stale; the project needs to bump to drop the `as any` on `reasons: ['USER_MEDIA']`. [VERIFIED: npm view @types/chrome version returned 0.1.42 on 2026-05-15] |
| `vite` | `^8.0.13` (currently `^5.4.2` in `package.json`) | Bundler. | Already a hard project decision (DEC-012). Phase 1 does NOT mandate a Vite bump — sticking with 5.4 is fine; the bump is a Phase 5 housekeeping task. [VERIFIED: npm view vite version returned 8.0.13 on 2026-05-15] |
| `typescript` | `^6.0.3` (currently `^5.5.4` in `package.json`) | Type-check. Strict mode is already enabled in `tsconfig.json`. | Project decision. Phase 1 keeps 5.5; same Phase 5 housekeeping observation. [VERIFIED: npm view typescript version returned 6.0.3 on 2026-05-15] |
> **No new dependencies are needed for Phase 1.** `JSZip` and `rrweb`
> stay untouched (Phase 2 / 3 territory). All new code uses the standard
> Web Platform APIs (`MediaRecorder`, `navigator.mediaDevices`,
> `chrome.offscreen`, `chrome.runtime.connect`).
### Supporting (Phase 1 specifically uses)
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| Web Platform: `MediaRecorder` | Built-in | Encode the captured `MediaStream` into a chunked WebM stream. | Inside the offscreen, after `getDisplayMedia()` returns a stream. |
| Web Platform: `navigator.mediaDevices.getDisplayMedia` | Built-in | Acquire the operator's choice of screen/window/tab as a `MediaStream`. | Inside the offscreen, once on session start, in the message handler for `START_RECORDING`. |
| Chrome API: `chrome.offscreen.{createDocument, closeDocument, hasDocument, Reason}` | Chrome 109+ for API; Chrome 116+ recommended baseline (matches the canonical Google sample's `minimum_chrome_version`). | Create + tear down the offscreen runtime. | SW only. |
| Chrome API: `chrome.runtime.{connect, sendMessage, onConnect, onMessage}` | Built-in | Cross-context messaging. | Both SW and offscreen. |
### Alternatives Considered (Honored CONTEXT.md, recorded for completeness)
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| `getDisplayMedia()` in offscreen | `chrome.tabCapture.getMediaStreamId` in SW + `getUserMedia({chromeMediaSource: 'tab'})` in offscreen (canonical Google sample pattern) | Tab-scoped only; silent (no Chrome banner); requires user-gesture juggling on first activation; loses capture on tab switch. **Rejected per CONTEXT.md D-01.** |
| `getDisplayMedia()` in offscreen | `chrome.desktopCapture.chooseDesktopMedia` in SW + redeem ID in offscreen | Chrome-specific; doc explicitly says streamId not usable in offscreen MV3 [CITED: groups.google.com chromium-extensions/3RanHldyp9c]. **Not viable.** |
| Single continuous recorder + age-trim | Restart-segments (10 s self-contained segments, keep 3 most-recent) | Each segment is its own valid WebM, concat-on-save is trivial, but burns ~3× more keyframes (bigger files). **Held in reserve as D-13 fallback** if `ffprobe -v error` fails on the simpler approach. |
| Restart-segments | ts-ebml header injection on save | More plumbing, dependency, and runtime cost. **Held in reserve as third fallback per CONTEXT.md deferred.** |
**Installation:** No `npm install` needed for Phase 1 (zero new deps).
Type-bump for `@types/chrome` (`^0.0.268` → `^0.1.42`) is a one-line
`package.json` edit, optional within this phase but recommended.
**Version verification:** All package versions in the table above are
verified via `npm view version` on 2026-05-15.
## Architecture Patterns
### System Architecture Diagram
```
┌────────────────────────────────────────────────────────────────────────┐
│ Operator interactions │
└────────────────────────────────────────────────────────────────────────┘
│ click popup
▼
┌────────────┐ REQUEST_PERMISSIONS / GET_VIDEO_BUFFER ┌──────────────┐
│ popup │ ──────────────────────────────────────────► │ Service │
│ (Russian │ ◄────────────────────────────────────────── │ Worker │
│ state-mc) │ responses │ (background) │
└────────────┘ └──────┬───────┘
│
chrome.offscreen.createDocument
({reasons:['DISPLAY_MEDIA']})
│
▼
┌──────────────────┐
long-lived │ Offscreen Doc │
port (keepalive + │ (DOM context) │
buffer fetch) │ │
SW ◄──────────────────────────────►│ recorder.ts │
│ - getDisplayMedia
│ - MediaRecorder │
│ - ring buffer │
│ - track.onended │
└─────┬────────────┘
│
navigator.mediaDevices
.getDisplayMedia()
│
▼
[ Chrome native ]
[ source picker ]
[ + Sharing UI ]
│
▼
┌──────────────────┐
│ MediaStream │
│ (screen/window) │
└─────┬────────────┘
│
MediaRecorder.start(2000)
│
▼
dataavailable chunks
(every ~2000 ms)
│
▼
in-memory ring buffer
(offscreen JS array)
Data flow on export (Phase 3 territory but the SW↔offscreen contract is
locked here):
popup --SAVE_ARCHIVE--> SW --GET_BUFFER--> offscreen
offscreen --VIDEO_CHUNKS--> SW --(merge)--> popup --(jszip + download)
```
| Component | File | Responsibilities |
|-----------|------|------------------|
| Operator-facing popup | `src/popup/index.{ts,html,css}` | UI state machine, click handlers, archive trigger. Phase 3 owns most edits; Phase 1 touches it only minimally to unwire the dead `REQUEST_PERMISSIONS` path. |
| Service Worker (background coordinator) | `src/background/index.ts` | Offscreen lifecycle (`createDocument` / `closeDocument` / `hasDocument`), port handling, buffer-fetch on export, message routing. **Shrinks substantially** in this phase. |
| Offscreen recorder (NEW) | `src/offscreen/recorder.ts` | `getDisplayMedia` call, `MediaRecorder` instance, ring buffer, codec capability check, port to SW (keepalive + on-demand buffer push), `MediaStreamTrack.onended` handler. |
| Offscreen page (NEW) | `src/offscreen/index.html` | Minimal HTML referencing `recorder.ts` via ``. crxjs picks it up. |
| Manifest | `manifest.json` | Swap `tabCapture` → `desktopCapture`. Add nothing else; `offscreen` is already declared. |
| Vite config | `vite.config.ts` | Collapse to a clean `crx({manifest, contentScripts: {injectCss: false}})` + `rollupOptions.input` entry for offscreen HTML. Delete the entire 174-line `copy-offscreen` plugin block. |
### Recommended Project Structure
```
repremium/
├── manifest.json # swap tabCapture→desktopCapture
├── vite.config.ts # collapse to ~30 lines
├── src/
│ ├── background/
│ │ └── index.ts # shrinks: lifecycle + port + export
│ ├── content/
│ │ └── index.ts # untouched in Phase 1
│ ├── popup/
│ │ ├── index.html # untouched in Phase 1
│ │ ├── index.ts # minor: drop dead REQUEST_PERMISSIONS path
│ │ └── style.css # untouched
│ ├── offscreen/ # NEW directory (replaces top-level offscreen/)
│ │ ├── index.html # NEW: