Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions openspec/changes/baseline-media-capture/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-23
105 changes: 105 additions & 0 deletions openspec/changes/baseline-media-capture/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
## Context

`@bengreenier/react-user-media` already implements media capture in `packages/react-user-media`:

- `useMedia` (`src/hooks/use-media.ts`) — React hook over `getUserMedia` / `getDisplayMedia`
- `closeMedia` (`src/close-media.ts`) — stop all tracks on a stream
- `getSupportedConstraints` (`src/index.ts`) — thin, null-safe wrapper around `mediaDevices.getSupportedConstraints`

This design records the **as-built** architecture so future OpenSpec deltas can reason about lifecycle and API contracts. No new implementation is proposed.

Covered tests live in `use-media.spec.tsx` and `close-media.spec.ts` (Vitest + Testing Library, browser-oriented mocks).

## Goals / Non-Goals

**Goals:**

- Document how media acquisition, discriminated state, request generations, and cleanup work today
- Make the public surface and lifecycle guarantees explicit for baselining
- Align design language with existing tests and TypeScript types

**Non-Goals:**

- Redesigning the hook API or state machine
- Adding workers, WebCodecs, device enumeration, or MediaRecorder into this capability
- Changing cleanup semantics or introducing new dependencies
- Migrating runtime behavior; this change is documentation-only

## Decisions

### 1. Single hook with a type discriminant

**Choice:** One `useMedia(type)` entry point where `type` is `"user" | "display"`, rather than separate hooks.

**Rationale (as-built):** Shared loading/error/ready machinery and identical cleanup rules; TypeScript conditional types (`inferMediaDef`) specialize `request` argument and return state per type.

**Alternatives considered:** Separate `useUserMedia` / `useDisplayMedia` hooks — not used; would duplicate generation and teardown logic.

### 2. Discriminated boolean flags + cast return

**Choice:** Internal state is three React state fields (`media`, `error`, `isLoading`) plus derived `isReady` / `isError`. The return value is typed as a discriminated union but assembled as one object and cast (`as inferMediaDef<TType>["stateType"]`).

**Rationale:** Avoids runtime branching cost while preserving a strong consumer-facing type. Flags remain mutually exclusive by construction of setters.

**Alternatives considered:** Explicit tagged union in runtime state (`status: "idle" | ...`) — not used in current code.

### 3. Request generations for async correctness

**Choice:** A mutable `requestGeneration` ref increments on every `request`, `stop`, `type` change, and unmount. Success/error/finalize handlers ignore mismatched generations; successful-but-stale streams are passed to `closeMedia`.

**Rationale:** Media promises outlive React renders; without generations, late resolutions would leak tracks or overwrite newer media (covered by stale / unmount / type-change tests).

**Alternatives considered:** AbortController on constraints APIs — not universally available / not used here; generation counter is sufficient for ignore-and-close.

### 4. Eager close of prior stream on re-request

**Choice:** At the start of `request`, call `closeMedia(mediaRef.current)` and clear ref/state media before invoking the browser API.

**Rationale:** Prevents overlapping live tracks when the consumer re-requests; matches “stops the previous user media stream when re-requesting” test.

### 5. Type change resets like stop (without requiring consumer action)

**Choice:** An effect compares `type` to `typeRef`; on change, bump generation, close held media, and clear to idle.

**Rationale:** Switching `"user"` ↔ `"display"` must not keep the wrong stream or apply a late user promise to a display hook instance.

### 6. Unmount teardown closes tracks only

**Choice:** Cleanup effect bumps generation and `closeMedia`s the ref; it does not set React state (component is gone).

**Rationale:** Avoids setState-after-unmount while still ending tracks and invalidating in-flight handlers.

### 7. Capability checks before promise

**Choice:** If the matching capture API is missing, set a descriptive `Error` and skip `setIsLoading(true)` / promise path.

**Rationale:** Fail closed in non-secure or incomplete environments without throwing from `request`.

### 8. Shared `closeMedia` helper

**Choice:** Centralize `media?.getTracks().forEach(track => track.stop())`.

**Rationale:** One place for teardown used by the hook; undefined-safe for idle paths.

### 9. Null-safe `getSupportedConstraints`

**Choice:** Optional chaining through `globalThis.navigator?.mediaDevices?.getSupportedConstraints?.()` with `?? {}`.

**Rationale:** Safe to call during SSR or degraded environments; returns empty constraint map rather than throwing.

## Risks / Trade-offs

- **[Risk] Cast return can drift from true discriminant invariants** → Mitigation: keep tests asserting exclusive idle/loading/ready/error presentations; prefer not widening setters.
- **[Risk] Generation counter is process-local and silent** → Mitigation: always `closeMedia` on stale success so leaks are prevented even when state updates are skipped.
- **[Risk] No AbortSignal** → Mitigation: superseded requests may still complete in the browser; library closes unused streams on resolve. Consumers should still prefer `stop` / unmount for intentional teardown.
- **[Risk] Baseline docs can drift from code** → Mitigation: verification tasks audit scenarios against `use-media.spec.tsx` / `close-media.spec.ts` before treating archive as source of truth.
- **[Trade-off] Single object + cast vs runtime tagged state** → Favor typing ergonomics and less boilerplate; correctness relies on disciplined updates and tests.

## Migration Plan

Not applicable for runtime. After archive/sync, main specs under `openspec/specs/media-capture/` SHOULD mirror this baseline so later changes use MODIFIED/ADDED deltas. No rollback beyond deleting or revising these planning artifacts.

## Open Questions

- Whether to promote this capability into `openspec/specs/` immediately after validation (optional; listed as a verification task, not required for apply-ready planning).
- Whether future deltas should add an explicit runtime `status` discriminant (out of scope for this baseline).
36 changes: 36 additions & 0 deletions openspec/changes/baseline-media-capture/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
## Why

The library already ships `useMedia`, `closeMedia`, and `getSupportedConstraints` for browser media capture, but there is no OpenSpec baseline for that behavior. Documenting it as-built gives future deltas a stable contract for lifecycle, cleanup, and API surface without changing runtime behavior.

## What Changes

- Add an OpenSpec capability `media-capture` that records existing requirements for:
- `useMedia("user" | "display")` → `getUserMedia` / `getDisplayMedia`
- Discriminated idle → loading → ready | error state, plus `request` / `stop`
- Request generations, stream close on re-request / type change / unmount
- `closeMedia` and `getSupportedConstraints`
- No application code, API, or test behavior changes.

## Non-goals

- Implementing or refactoring hooks, helpers, or examples
- Changing public API signatures or state shape
- Adding workers, WebCodecs, or MediaRecorder coverage
- Expanding browser support beyond what already exists
- Syncing into `openspec/specs/` as part of this change (optional follow-up)

## Capabilities

### New Capabilities

- `media-capture`: React hook and helpers for obtaining, managing, and releasing user/display `MediaStream`s, including lifecycle correctness and constraint discovery.

### Modified Capabilities

- (none)

## Impact

- Planning only: artifacts under `openspec/changes/baseline-media-capture/`
- Documents existing code in `packages/react-user-media` (`use-media.ts`, `close-media.ts`, `index.ts`) and tests (`use-media.spec.tsx`, `close-media.spec.ts`)
- No package publish or dependency impact
115 changes: 115 additions & 0 deletions openspec/changes/baseline-media-capture/specs/media-capture/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
## ADDED Requirements

### Requirement: User and display media acquisition
`useMedia` MUST accept a `type` of `"user"` or `"display"`. When `type` is `"user"`, `request` MUST obtain media via `navigator.mediaDevices.getUserMedia`. When `type` is `"display"`, `request` MUST obtain media via `navigator.mediaDevices.getDisplayMedia`. Optional request arguments MUST be forwarded to the corresponding browser API (`MediaStreamConstraints` for user, `DisplayMediaStreamOptions` for display).

#### Scenario: User media request uses getUserMedia
- **WHEN** a consumer calls `useMedia("user")` and invokes `request` with constraints
- **THEN** the hook MUST call `navigator.mediaDevices.getUserMedia` with those constraints
- **AND** on success enter the ready state with the returned `MediaStream`

#### Scenario: Display media request uses getDisplayMedia
- **WHEN** a consumer calls `useMedia("display")` and invokes `request` with options (for example `{ video: true }`)
- **THEN** the hook MUST call `navigator.mediaDevices.getDisplayMedia` with those options
- **AND** on success enter the ready state with the returned `MediaStream`

### Requirement: Discriminated media state
`useMedia` MUST expose a discriminated state shape with mutually exclusive idle, loading, ready, and error modes, plus `request` and `stop` actions on every mode.

- Idle: `isLoading: false`, `isError: false`, `isReady: false`, `error: null`, `media: undefined`
- Loading: `isLoading: true`, `isError: false`, `isReady: false`, `error: null`, `media: undefined`
- Ready: `isLoading: false`, `isError: false`, `isReady: true`, `error: null`, `media: MediaStream`
- Error: `isLoading: false`, `isError: true`, `isReady: false`, `error: Error`, `media: undefined`

#### Scenario: Initial state is idle
- **WHEN** `useMedia` mounts before any `request`
- **THEN** the returned state MUST be idle (`isReady`, `isLoading`, and `isError` all false; `media` undefined; `error` null)

#### Scenario: Request transitions through loading to ready
- **WHEN** `request` is invoked and the browser media promise resolves with a stream
- **THEN** state MUST first reflect loading while the request is in flight
- **AND** then reflect ready with that stream as `media`

#### Scenario: Request failure yields error state
- **WHEN** `getUserMedia` rejects or throws synchronously
- **THEN** the hook MUST NOT throw to the caller
- **AND** state MUST become error with a non-null `Error` and undefined `media`

#### Scenario: Missing getUserMedia capability yields error
- **WHEN** `type` is `"user"` and `navigator.mediaDevices.getUserMedia` is unavailable
- **THEN** invoking `request` MUST set error state with a message indicating getUserMedia is not available
- **AND** MUST NOT throw

#### Scenario: Missing getDisplayMedia capability yields error
- **WHEN** `type` is `"display"` and `navigator.mediaDevices.getDisplayMedia` is unavailable
- **THEN** invoking `request` MUST set error state with a message indicating getDisplayMedia is not available
- **AND** MUST NOT throw

### Requirement: Stop returns to idle and releases media
`stop` MUST increment the request generation, stop tracks on the current stream via `closeMedia`, clear `media` and `error`, clear loading, and return the hook to idle.

#### Scenario: Stop clears ready user media
- **WHEN** the hook is ready with an active stream and the consumer calls `stop`
- **THEN** every track on that stream MUST be stopped
- **AND** state MUST return to idle with `media` undefined

### Requirement: Re-request closes the previous stream
When `request` is invoked while a prior stream is held, the hook MUST close the prior stream before starting the new acquisition.

#### Scenario: Re-request stops previous user media stream
- **WHEN** the hook is ready with stream A and the consumer calls `request` again
- **THEN** tracks on stream A MUST be stopped before stream B becomes ready
- **AND** ready state MUST expose stream B

### Requirement: Stale in-flight requests must not leak or overwrite newer state
The hook MUST track request generations so that outcomes from superseded requests do not replace newer media or loading/error state. Streams that resolve after being superseded MUST be closed.

#### Scenario: Stale promise after stop and re-request does not replace newer media
- **WHEN** an in-flight user media request is stopped, a newer request is started, the newer request resolves first, and then the older request resolves
- **THEN** ready state MUST keep the newer stream
- **AND** the older stream's tracks MUST be stopped
- **AND** the newer stream's tracks MUST remain live

#### Scenario: Stream resolving after unmount is closed
- **WHEN** `request` is in flight, the component unmounts, and the media promise later resolves
- **THEN** the resolved stream's tracks MUST be stopped
- **AND** the unmounted component MUST NOT retain the stream as active media

#### Scenario: In-flight user media ignored after type changes to display
- **WHEN** `useMedia("user")` has an in-flight request and the `type` prop changes to `"display"`
- **THEN** state MUST reset to idle without that user stream
- **AND** when the stale user media promise later resolves, its tracks MUST be stopped
- **AND** state MUST remain idle (or otherwise not adopt that user stream)

### Requirement: Unmount and type-change cleanup
Changing `type` or unmounting MUST bump the request generation and close any currently held stream so tracks are not left live.

#### Scenario: Unmount cleanup stops ready user media tracks
- **WHEN** the hook is ready with an active stream and the component unmounts
- **THEN** every track on that stream MUST be stopped

#### Scenario: Type change closes held media and resets state
- **WHEN** `type` changes while the hook holds media or has an in-flight request
- **THEN** the hook MUST close any held stream, clear error/loading/media, and return to idle for the new type

### Requirement: closeMedia stops all tracks
`closeMedia` MUST accept `MediaStream | undefined`. When given a stream, it MUST call `stop()` on every track from `getTracks()`. When given `undefined`, it MUST return without throwing.

#### Scenario: closeMedia stops every track on the stream
- **WHEN** `closeMedia` is called with a stream that has multiple tracks
- **THEN** each track's `stop` MUST be invoked once

#### Scenario: closeMedia accepts undefined without throwing
- **WHEN** `closeMedia(undefined)` is called
- **THEN** it MUST NOT throw

### Requirement: getSupportedConstraints safe default
`getSupportedConstraints` MUST return `navigator.mediaDevices.getSupportedConstraints()` when available, otherwise an empty object. It MUST NOT throw when `navigator` or `mediaDevices` is missing.

#### Scenario: Missing mediaDevices returns empty object
- **WHEN** `navigator.mediaDevices` is undefined
- **THEN** `getSupportedConstraints()` MUST return `{}`

#### Scenario: Missing navigator returns empty object
- **WHEN** `globalThis.navigator` is unavailable
- **THEN** `getSupportedConstraints()` MUST return `{}`
22 changes: 22 additions & 0 deletions openspec/changes/baseline-media-capture/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
## 1. Spec–test coverage audit

- [x] 1.1 Map each `media-capture` scenario in `specs/media-capture/spec.md` to a test in `use-media.spec.tsx` or `close-media.spec.ts` (note any scenario without a direct test)
- [x] 1.2 Confirm user vs display acquisition scenarios match `getUserMedia` / `getDisplayMedia` spies and args
- [x] 1.3 Confirm state transition scenarios (idle, loading→ready, error, stop→idle) match existing assertions
- [x] 1.4 Confirm lifecycle scenarios (re-request close, stale after stop/re-request, unmount ready, unmount in-flight, type change) match generation/cleanup tests
- [x] 1.5 Confirm `closeMedia` and `getSupportedConstraints` scenarios match `close-media.spec.ts`

## 2. Gap documentation

- [x] 2.1 Record uncovered scenarios (if any) as follow-up test candidates without changing production code in this baseline — gaps: (a) no dedicated initial-idle assert before `request`; (b) no explicit happy-path loading→ready sequence assert; (c) no `getUserMedia` `toHaveBeenCalledWith(constraints)` assert (display has args assert); (d) no async `getUserMedia` rejection path (sync throw only); (e) no missing-`getDisplayMedia` capability test; (f) type-change covers in-flight only, not ready-held media
- [x] 2.2 Confirm proposal Non-goals still hold (no API/behavior changes implied by tasks)

## 3. Optional main-spec sync

- [x] 3.1 Decide whether to sync `media-capture` into `openspec/specs/` via archive/sync workflow (deferred — keep as change delta)
- [x] 3.2 If syncing, run the project’s OpenSpec sync/archive path so main specs mirror this baseline unchanged (deferred — keep as change delta)

## 4. Validation

- [x] 4.1 Run `OPENSPEC_TELEMETRY=0 npx --yes @fission-ai/openspec validate baseline-media-capture --json` and fix artifact issues until valid
- [x] 4.2 Run package tests for media capture (`use-media.spec.tsx`, `close-media.spec.ts`) to confirm baseline still matches green tests
2 changes: 2 additions & 0 deletions openspec/changes/baseline-media-devices/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-23
Loading
Loading