@wcstack/camera
v1.27.0
Published
Declarative camera capture and media recording for Web Components. Binds live MediaStream handles directly to elements (never through serializable state) via getUserMedia + MediaRecorder.
Maintainers
Readme
@wcstack/camera
🤖 AI coding agents: This README is a package-level reference, not the primary entry point for building a wcstack application. If you have not already done so, first read the repository README and AGENTS.md, then use the wcstack-app skill.
Declarative camera capture (<wcs-camera>) and media recording (<wcs-recorder>) for Web Components, built on getUserMedia + MediaRecorder. Framework-agnostic, zero runtime dependencies, exposed through the wc-bindable protocol.
日本語版は README.ja.md。
The idea: a live handle that never touches state
Every other @wcstack IO node moves serializable values in and out of state. A MediaStream is different — it is a live, non-serializable resource handle: reference identity is all that matters, it never "settles", and leaking it is physically observable (the camera indicator stays on).
So this package keeps the live stream out of state entirely:
<wcs-camera>owns a<video>preview in its shadow root and assignssrcObjectinternally — the handle never crosses the state boundary.- For other consumers (a recorder, an external
<video>), the stream is published via thewcs-camera:stream-readyevent-token, and handed on as a command-token argument — it passes through the token bus transiently and is never written to a reactive path. - Only derived values live in state:
active,permission, the recordedBlob, an object URL, etc.
<wcs-camera data-wcs="
command.start: $command.camStart;
eventToken.streamReady: gotStream;
active: camActive; permission: camPerm"></wcs-camera>
<wcs-recorder data-wcs="
command.attachStream: $command.feed;
command.start: $command.recStart;
command.stop: $command.recStop;
recording: recording; objectURL: clipUrl;
eventToken.recorded: onRecorded"></wcs-recorder>$commandTokens: ["camStart", "feed", "recStart", "recStop"],
$eventTokens: ["gotStream", "onRecorded"],
$on: {
// The raw MediaStream is forwarded as a command argument — never stored.
gotStream: (state, e) => state.$command.feed.emit(e.detail),
// The recorded Blob is a value — it may live in state.
onRecorded: (state, e) => { state.clipBlob = e.detail.blob; },
}<wcs-camera>
Acquires a camera stream and renders a preview. Acquisition is explicit — start() (or the autostart attribute) prompts; merely connecting does not.
Attributes: facing-mode (user/environment), device-id, audio (opt the microphone in), width, height, autostart, keep-alive (do not suspend on page-hidden — set while recording).
Commands: start(), stop(), switchCamera() (toggle front/back).
Bindable values: active (a stream is live), permission / audioPermission (prompt/granted/denied/unsupported), deviceId, devices, error, errorInfo (WcsIoErrorInfo | null — a serializable failure taxonomy derived from error, published via wcs-camera:error-info-changed; see Notes & gotchas below).
Events (event-token): streamReady (wcs-camera:stream-ready, detail = the live MediaStream), error, ended (a track was revoked by the OS). The streamReady "property" exists for event-token wiring only — never bind it as a value.
Lifecycle
- On
disconnectedCallbackevery track is stopped (track.stop()), clearing the hardware indicator. Leaking a stream is the one failure mode unique to this node. - Moving the element in the DOM (remove → re-append) runs
disconnectedCallback(dispose, stop tracks) thenconnectedCallback(observe again). Withautostartit re-acquires on reconnect (and may re-prompt). To keep a stream across a move, avoidautostartand re-start()yourself, or don't detach the element. - A constraints change (
device-id,facing-mode,switchCamera()) re-acquires (stop → newgetUserMedia), guarded by a generation counter so a superseded acquire cannot leave an orphan stream live. - While the page is hidden the stream is suspended and re-acquired on return — unless
keep-aliveis set. BindkeepAlive: recordingto keep the camera alive while recording.
<wcs-recorder>
Records a borrowed stream received via attachStream (the direct channel from a camera's stream-ready). It never owns or stops the stream — that is the camera's job.
Attributes: mime-type, timeslice (emit dataavailable chunks on this interval; omit for one Blob on stop), audio-bits, video-bits.
Commands: attachStream(stream), start(), stop(), pause(), resume().
Bindable values: recording, paused, duration (ms — see note below), mimeType (the resolved recording type, which may differ from the requested mime-type attribute or be filled in when none was requested), blob, objectURL, error, errorInfo (WcsIoErrorInfo | null — a serializable failure taxonomy derived from error, published via wcs-recorder:error-info-changed; see Notes & gotchas below).
Events (event-token): recorded (wcs-recorder:recorded, detail = { blob, objectURL, mimeType, duration }), dataavailable (only in timeslice mode), error.
durationis finalized at stop/pause, not live. There is no internal ticking timer:durationstays0fromstart()until the firstpause()orstop(). For a live elapsed counter while recording, drive your own client-side timer off therecordingflag.
mimeTypehas two sides — request vs. resolved. The input is themime-typeattribute (what you ask the recorder to use). The output is themimeTypebindable value (what the browser actually picked, published viawcs-recorder:mimetype-changed). They share a base name by design but are distinct surfaces: bind the attribute to set the request (mime-typeattribute / element setter), and bind the value property to read the resolved type. Don't expect readingmimeTypeto echo back what you wrote — it reflects the recording, not the request.
The assembled Blob is structured-clone friendly, so it is a value and may flow through state — for example new File([blob], "clip.webm") into @wcstack/upload. The object URL is managed: the previous one is revoked before a new clip and on disconnectedCallback (dispose).
objectURLlifetime is bound to the recorder. Because dispose revokes the last object URL — and a new recording revokes the previous clip's URL before minting the next — any<video src>/<wcs-upload>still pointing at an old URL breaks once the<wcs-recorder>is removed or the next clip completes. Always follow the latestobjectURL/recordedvalue; never pin a stale one. If you hand the URL to a longer-lived consumer, either keep the recorder mounted for as long as the URL is in use, or build your own URL from theBlob(URL.createObjectURL(blob)) and own its revoke. The structured-clone-friendlyblobhas no such coupling — prefer flowing theBlobthrough state and minting URLs at the point of use.
CSS styling with :state()
<wcs-camera> and <wcs-recorder> reflect their boolean output states onto
ElementInternals CustomStateSet,
so you can style them directly from CSS with the :state() pseudo-class — no
data-wcs binding or extra class toggling required.
| Element | State | On when |
|---------|-------|---------|
| wcs-camera | active | wcs-camera:active-changed fires with true (cleared on false) |
| wcs-camera | error | wcs-camera:error fires with a non-null detail (cleared on null) |
| wcs-recorder | recording | wcs-recorder:recording-changed fires with true (cleared on false) |
| wcs-recorder | paused | wcs-recorder:paused-changed fires with true (cleared on false) |
| wcs-recorder | error | wcs-recorder:error fires with a non-null detail (cleared on null) |
permission/audioPermissionhave no boolean derived getter today, so they are not reflected (v1 scope; see docs/custom-state-reflection-design.md §7).durationis a continuous value and is intentionally excluded.
wcs-camera:state(active) ~ .live-badge { display: block; }
form:has(wcs-camera:state(error)) .banner { display: block; }
wcs-recorder:state(recording) ~ .rec-dot { animation: blink 1s infinite; }
wcs-recorder:state(paused) ~ .rec-dot { animation: none; opacity: .4; }Unlike attributes or classes, :state() cannot be written from outside the
element, so there is no risk of confusing this output state with an input.
Browser support (:state(x) syntax): Chrome/Edge 125+, Safari 17.4+,
Firefox 126+. In older browsers the states are simply never set — :state()
selectors never match, but the elements themselves keep working normally
(graceful degradation, never-throw).
SSR: :state() cannot be serialized into HTML, so server-rendered markup
never carries these states on first paint (@wcstack/server is unaffected).
If you need to style the pre-hydration gap, pair your rule with
wcs-camera:not(:defined) / wcs-recorder:not(:defined) instead.
Debugging
Custom states are invisible in DevTools' Elements panel and attachInternals()
cannot be called twice, so there is no console way to inspect them directly.
Two debug-only aids are provided for that:
el.debugStates— a snapshot array of the currently-on state names (e.g.["active"]). It is not part ofwc-bindable(not a bind target) and its shape is not a guaranteed contract — use it for debugging only.The
debug-statesattribute (opt-in, default off) mirrors state changes ontodata-wcs-state-*attributes on the element, so the Elements panel highlights them as they toggle:<wcs-camera autostart debug-states></wcs-camera>
Write your CSS against :state(), not data-wcs-state-*. The mirrored
attributes exist purely to make state changes visible while debugging with
DevTools open; they are not a supported styling hook.
Headless cores
CameraCore and RecorderCore are exported for non-DOM use (bind() from @wc-bindable/core). The Shells are thin wrappers.
The structural Core surface is normative across wcstack IO nodes (async-io-node-guidelines §3.9); to bind it into signals with no element at all, see @wcstack/signals — Binding a Core directly.
Notes & gotchas
Secure context (https) required.
getUserMediais unavailable onfile:/// plainhttp://.The camera indicator = a leak detector. If it stays on after you are done, a track was not stopped.
User gesture. Some browsers require
getUserMediato be triggered by a user action; firing it from a timer may silently fail (surfaced viaerror, never thrown).Errors are classified, never thrown:
NotAllowedError(denied),NotFoundError(no device),NotReadableError(in use by another app),OverconstrainedError.errorInfo— additive failure taxonomy. Alongsideerror, both<wcs-camera>and<wcs-recorder>expose an additive bindable outputerrorInfo(WcsIoErrorInfo= a stablecode/phase/recoverable/message), derived from the same failure and published viawcs-camera:error-info-changed/wcs-recorder:error-info-changed. Theerrorshape is unchanged;errorInfotransitions exactly whenerrordoes (cleared tonullon success). Both elements share one code set (WCS_MEDIA_ERROR_CODE, defined incore/mediaCapabilities.ts):capability-missing(phaseprobe) —getUserMedia/MediaRecorderunavailable, including non-secure context.not-allowed(phasestart) —NotAllowedError/SecurityError(permission denied or feature-policy block).not-found(phasestart) —NotFoundError(no camera / mic of the requested kind).not-readable(phasestart) —NotReadableError(device busy or hardware fault).invalid-argument(phasestart) —OverconstrainedError/NotSupportedError(constraints or mimeType unsatisfiable).invalid-state(phasestart) —NoStreamError(recorder started with no stream attached).aborted(phaseexecute,recoverable: true) —AbortError(interrupted mid-flight, may recover on retry).media-error(phaseexecute) — any other runtime failure (e.g.RecorderError/ unexpectedMediaRecordererror).
The
WcsIoErrorInfotype and theWCS_MEDIA_ERROR_CODEconstants are exported.Stream ownership stays with the camera. A recorder borrows it; switching cameras while recording is not supported (stop recording first).
mimeType support varies (webm/mp4). Unsupported
mime-typevalues are ignored and the browser default is used.
Install
<script type="module" src="https://esm.run/@wcstack/camera/auto"></script>Or programmatically:
import { bootstrapCamera } from "@wcstack/camera";
bootstrapCamera();MIT © mogera551
