@agatx/serenada-core 0.6.10 → 0.6.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,14 @@ import type {
10
10
  SerenadaConfig,
11
11
  SerenadaSessionHandle,
12
12
  SignalingState,
13
+ SnapshotResult,
14
+ SnapshotSource,
13
15
  } from './types.js';
16
+ import {
17
+ captureFrameFromStream,
18
+ resolveSnapshotStream,
19
+ SnapshotError,
20
+ } from './media/captureSnapshot.js';
14
21
  import { resolveCameraModes } from './cameraModes.js';
15
22
  import type {
16
23
  ConnectionInfo,
@@ -454,12 +461,50 @@ export class SerenadaSession implements SerenadaSessionHandle {
454
461
 
455
462
  /** Start sharing the screen, replacing the camera video track. */
456
463
  async startScreenShare(): Promise<void> {
464
+ const wasScreenSharing = this.media.isScreenSharing;
457
465
  await this.media.startScreenShare();
466
+ if (!this.isInactive && !wasScreenSharing && this.media.isScreenSharing) {
467
+ this.broadcastLocalMediaState();
468
+ this.rebuildState();
469
+ }
458
470
  }
459
471
 
460
472
  /** Stop screen sharing and restore the camera video track. */
461
473
  async stopScreenShare(): Promise<void> {
474
+ const wasScreenSharing = this.media.isScreenSharing;
462
475
  await this.media.stopScreenShare();
476
+ if (!this.isInactive && wasScreenSharing && !this.media.isScreenSharing) {
477
+ this.broadcastLocalMediaState();
478
+ this.rebuildState();
479
+ }
480
+ }
481
+
482
+ /**
483
+ * Capture the current video frame from the chosen stream as an
484
+ * `image/jpeg` Blob at the source track's full intrinsic resolution.
485
+ * Rejects with a `SnapshotError` if the stream is missing/inactive,
486
+ * the chosen participant has video off, or no frame arrives in time.
487
+ */
488
+ async captureSnapshot(source: SnapshotSource = { kind: 'local' }): Promise<SnapshotResult> {
489
+ if (this.isInactive) {
490
+ throw new SnapshotError('streamNotActive', 'Session is not active');
491
+ }
492
+ if (source.kind === 'local') {
493
+ if (this._state.localParticipant?.videoEnabled !== true) {
494
+ throw new SnapshotError('streamNotActive', 'Local video is not enabled');
495
+ }
496
+ } else if (source.kind === 'remote') {
497
+ const participant = this._state.remoteParticipants.find((p) => p.cid === source.cid);
498
+ if (!participant) {
499
+ throw new SnapshotError('streamNotActive', `Remote participant ${source.cid} is not joined`);
500
+ }
501
+ if (!participant.videoEnabled) {
502
+ throw new SnapshotError('streamNotActive', `Remote participant ${source.cid} has video off`);
503
+ }
504
+ }
505
+ const stream = resolveSnapshotStream(source, this.media.localStream, this.media.remoteStreams);
506
+ const { blob, width, height } = await captureFrameFromStream(stream);
507
+ return { blob, width, height, timestampMs: Date.now(), source };
463
508
  }
464
509
 
465
510
  /** Clean up all resources. Call when done with the session. */
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * @agatx/serenada-core — headless call engine.
3
3
  * Vanilla TypeScript — no React dependency.
4
4
  */
5
- export const SERENADA_CORE_VERSION = '0.6.10';
5
+ export const SERENADA_CORE_VERSION = '0.6.12';
6
6
 
7
7
  // Public API
8
8
  export { SerenadaCore } from './SerenadaCore.js';
@@ -47,7 +47,19 @@ export type {
47
47
  RoomWatcherState,
48
48
  SerenadaLogLevel,
49
49
  SerenadaLogger,
50
+ SnapshotSource,
51
+ SnapshotResult,
52
+ SnapshotErrorCode,
50
53
  } from './types.js';
54
+ export {
55
+ SnapshotError,
56
+ captureFrameFromStream,
57
+ SNAPSHOT_FRAME_TIMEOUT_MS,
58
+ } from './media/captureSnapshot.js';
59
+ export type {
60
+ CapturedFrame,
61
+ CaptureFrameOptions,
62
+ } from './media/captureSnapshot.js';
51
63
  export type { RecoveryRecord } from './recoveryStorage.js';
52
64
  export type {
53
65
  ProviderCapabilities,
@@ -69,6 +69,7 @@ export class MediaEngine {
69
69
  private lastInboundBytesByCid = new Map<string, number>();
70
70
  private rtcConfig: RTCConfiguration = DEFAULT_RTC_CONFIG;
71
71
  private screenShareTrack: MediaStreamTrack | null = null;
72
+ private screenShareRestoreVideoEnabled: boolean | null = null;
72
73
  private requestingMedia = false;
73
74
  private destroyed = false;
74
75
  private cameraRecoveryInFlight = false;
@@ -277,6 +278,7 @@ export class MediaEngine {
277
278
  this.screenShareTrack.onended = null;
278
279
  this.screenShareTrack = null;
279
280
  }
281
+ this.screenShareRestoreVideoEnabled = null;
280
282
  if (this.localStream) {
281
283
  this.localStream.getTracks().forEach(t => t.stop());
282
284
  this.localStream = null;
@@ -322,8 +324,8 @@ export class MediaEngine {
322
324
  }
323
325
 
324
326
  const previousVideoTrack = this.localStream.getVideoTracks()[0];
325
- const wasVideoEnabled = previousVideoTrack ? previousVideoTrack.enabled : true;
326
- displayTrack.enabled = wasVideoEnabled;
327
+ this.screenShareRestoreVideoEnabled = previousVideoTrack ? previousVideoTrack.enabled : null;
328
+ displayTrack.enabled = true;
327
329
  if ('contentHint' in displayTrack) {
328
330
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- contentHint is a valid but untyped browser API
329
331
  try { (displayTrack as any).contentHint = 'detail'; } catch { /* ignore */ }
@@ -338,6 +340,7 @@ export class MediaEngine {
338
340
  this.sendSignalingMessage('content_state', { active: true, contentType: 'screenShare' });
339
341
  this.notifyChange();
340
342
  } catch (err) {
343
+ this.screenShareRestoreVideoEnabled = null;
341
344
  this.logger?.log('error', 'ScreenShare', `Failed to start screen share: ${formatError(err)}`);
342
345
  }
343
346
  }
@@ -346,6 +349,7 @@ export class MediaEngine {
346
349
  if (!this.isScreenSharing) return;
347
350
  if (!this.localStream) {
348
351
  this.isScreenSharing = false;
352
+ this.screenShareRestoreVideoEnabled = null;
349
353
  this.sendSignalingMessage('content_state', { active: false });
350
354
  this.notifyChange();
351
355
  return;
@@ -357,16 +361,21 @@ export class MediaEngine {
357
361
  }
358
362
 
359
363
  const previousVideoTrack = this.localStream.getVideoTracks()[0];
360
- const wasVideoEnabled = previousVideoTrack ? previousVideoTrack.enabled : true;
364
+ const restoreVideoEnabled = this.screenShareRestoreVideoEnabled;
361
365
 
362
366
  try {
363
- const cameraTrack = await this.acquireCameraTrack(this.facingMode, wasVideoEnabled);
364
- await this.swapLocalVideoTrack(cameraTrack, previousVideoTrack);
367
+ if (restoreVideoEnabled === null) {
368
+ await this.swapLocalVideoTrack(null, previousVideoTrack);
369
+ } else {
370
+ const cameraTrack = await this.acquireCameraTrack(this.facingMode, restoreVideoEnabled);
371
+ await this.swapLocalVideoTrack(cameraTrack, previousVideoTrack);
372
+ }
365
373
  } catch (err) {
366
374
  this.logger?.log('error', 'ScreenShare', `Failed to stop screen share and restore camera: ${formatError(err)}`);
367
375
  await this.swapLocalVideoTrack(null, previousVideoTrack);
368
376
  } finally {
369
377
  this.isScreenSharing = false;
378
+ this.screenShareRestoreVideoEnabled = null;
370
379
  this.sendSignalingMessage('content_state', { active: false });
371
380
  this.notifyChange();
372
381
  }
@@ -0,0 +1,250 @@
1
+ import type { SnapshotErrorCode, SnapshotSource } from '../types.js';
2
+
3
+ export class SnapshotError extends Error {
4
+ readonly code: SnapshotErrorCode;
5
+ constructor(code: SnapshotErrorCode, message?: string) {
6
+ super(message ?? `Snapshot failed: ${code}`);
7
+ this.name = 'SnapshotError';
8
+ this.code = code;
9
+ }
10
+ }
11
+
12
+ export const SNAPSHOT_FRAME_TIMEOUT_MS = 2000;
13
+ export const DEFAULT_SNAPSHOT_MIME = 'image/jpeg';
14
+ export const DEFAULT_SNAPSHOT_QUALITY = 0.95;
15
+
16
+ export interface CaptureFrameOptions {
17
+ /** Maximum time to wait for the first decoded frame. Defaults to {@link SNAPSHOT_FRAME_TIMEOUT_MS}. */
18
+ timeoutMs?: number;
19
+ /** Image MIME type. Defaults to `'image/jpeg'`. */
20
+ type?: string;
21
+ /** JPEG/WebP quality 0–1. Defaults to `0.95`. */
22
+ quality?: number;
23
+ /** Document used to create the offscreen video and canvas. Defaults to the global `document`. */
24
+ doc?: Document;
25
+ }
26
+
27
+ export interface CapturedFrame {
28
+ blob: Blob;
29
+ width: number;
30
+ height: number;
31
+ }
32
+
33
+ /**
34
+ * Capture the current video frame from a MediaStream into a Blob at the
35
+ * stream's full intrinsic resolution. Throws {@link SnapshotError} on failure.
36
+ *
37
+ * Implementation: bind the stream to an offscreen `<video>`, wait for the
38
+ * first decoded frame, draw it onto an offscreen `<canvas>` at
39
+ * `videoWidth × videoHeight`, then encode via `canvas.toBlob`.
40
+ */
41
+ export async function captureFrameFromStream(
42
+ stream: MediaStream,
43
+ options: CaptureFrameOptions = {},
44
+ ): Promise<CapturedFrame> {
45
+ const doc = options.doc ?? (typeof document !== 'undefined' ? document : null);
46
+ if (!doc) {
47
+ throw new SnapshotError('captureFailed', 'No document available for canvas');
48
+ }
49
+
50
+ const videoTracks = stream.getVideoTracks();
51
+ if (videoTracks.length === 0) {
52
+ throw new SnapshotError('noVideoTrack', 'Stream has no video track');
53
+ }
54
+ if (!videoTracks.some((t) => t.readyState === 'live' && t.enabled !== false)) {
55
+ // A track that is `live` but `enabled === false` produces black frames
56
+ // (the SDK's video-toggle path flips `enabled`, not `readyState`), so
57
+ // capturing it would silently return a blank image. Treat as inactive.
58
+ throw new SnapshotError('streamNotActive', 'Stream has no enabled live video track');
59
+ }
60
+
61
+ const video = doc.createElement('video');
62
+ video.muted = true;
63
+ (video as HTMLVideoElement & { playsInline?: boolean }).playsInline = true;
64
+ video.autoplay = true;
65
+ try {
66
+ video.srcObject = stream;
67
+ } catch (err) {
68
+ throw new SnapshotError('captureFailed', `Cannot bind stream: ${(err as Error)?.message ?? err}`);
69
+ }
70
+
71
+ try {
72
+ await waitForFirstFrame(video, options.timeoutMs ?? SNAPSHOT_FRAME_TIMEOUT_MS);
73
+
74
+ const width = video.videoWidth;
75
+ const height = video.videoHeight;
76
+ if (width <= 0 || height <= 0) {
77
+ throw new SnapshotError('captureFailed', 'Video dimensions are zero');
78
+ }
79
+
80
+ const canvas = doc.createElement('canvas');
81
+ canvas.width = width;
82
+ canvas.height = height;
83
+ const ctx = canvas.getContext('2d');
84
+ if (!ctx) {
85
+ throw new SnapshotError('captureFailed', 'Cannot get 2D canvas context');
86
+ }
87
+ ctx.drawImage(video, 0, 0, width, height);
88
+
89
+ const blob = await canvasToBlob(
90
+ canvas,
91
+ options.type ?? DEFAULT_SNAPSHOT_MIME,
92
+ options.quality ?? DEFAULT_SNAPSHOT_QUALITY,
93
+ );
94
+ if (!blob) {
95
+ throw new SnapshotError('captureFailed', 'Canvas encode returned null');
96
+ }
97
+ return { blob, width, height };
98
+ } finally {
99
+ try {
100
+ video.pause();
101
+ } catch {
102
+ // ignore
103
+ }
104
+ try {
105
+ video.srcObject = null;
106
+ } catch {
107
+ // ignore
108
+ }
109
+ }
110
+ }
111
+
112
+ type VideoElementWithRvfc = HTMLVideoElement & {
113
+ requestVideoFrameCallback?: (cb: (now: number, metadata: unknown) => void) => number;
114
+ cancelVideoFrameCallback?: (handle: number) => void;
115
+ };
116
+
117
+ function waitForFirstFrame(video: HTMLVideoElement, timeoutMs: number): Promise<void> {
118
+ return new Promise((resolve, reject) => {
119
+ let settled = false;
120
+ let timer: ReturnType<typeof setTimeout> | null = null;
121
+ let rvfcHandle: number | null = null;
122
+
123
+ const videoEx = video as VideoElementWithRvfc;
124
+ const supportsRvfc = typeof videoEx.requestVideoFrameCallback === 'function';
125
+
126
+ const cleanup = () => {
127
+ if (timer !== null) {
128
+ clearTimeout(timer);
129
+ timer = null;
130
+ }
131
+ if (rvfcHandle !== null && typeof videoEx.cancelVideoFrameCallback === 'function') {
132
+ try {
133
+ videoEx.cancelVideoFrameCallback(rvfcHandle);
134
+ } catch {
135
+ // ignore
136
+ }
137
+ rvfcHandle = null;
138
+ }
139
+ video.removeEventListener('loadedmetadata', onMetadata);
140
+ video.removeEventListener('error', onError);
141
+ };
142
+ const settleSuccess = () => {
143
+ if (settled) return;
144
+ if (video.videoWidth <= 0 || video.videoHeight <= 0) return;
145
+ settled = true;
146
+ cleanup();
147
+ resolve();
148
+ };
149
+ const onMetadata = () => {
150
+ // Without rVFC we treat metadata as a useful signal (the canvas
151
+ // path needs videoWidth/Height anyway), but we defer one task
152
+ // tick so the decoder has a chance to commit pixels before
153
+ // drawImage runs — `loadedmetadata` fires before any frame is
154
+ // actually drawable. In rVFC-supporting browsers this branch is
155
+ // skipped because the rVFC callback gates on real frame data.
156
+ if (supportsRvfc || settled) return;
157
+ setTimeout(() => {
158
+ if (settled) return;
159
+ settleSuccess();
160
+ }, 0);
161
+ };
162
+ const onError = () => {
163
+ if (settled) return;
164
+ settled = true;
165
+ cleanup();
166
+ reject(new SnapshotError('captureFailed', 'Video element reported error'));
167
+ };
168
+
169
+ video.addEventListener('loadedmetadata', onMetadata);
170
+ video.addEventListener('error', onError);
171
+
172
+ if (supportsRvfc) {
173
+ // Resolve only when a real frame is presented — this is the only
174
+ // signal that drawImage will actually have pixel data.
175
+ rvfcHandle = videoEx.requestVideoFrameCallback!(() => {
176
+ rvfcHandle = null;
177
+ settleSuccess();
178
+ });
179
+ }
180
+
181
+ const playPromise = (video as HTMLVideoElement & { play(): unknown }).play?.();
182
+ if (playPromise && typeof (playPromise as Promise<void>).catch === 'function') {
183
+ (playPromise as Promise<void>).catch(() => {
184
+ // autoplay may be rejected; the frame-ready callbacks still fire
185
+ });
186
+ }
187
+
188
+ timer = setTimeout(() => {
189
+ if (settled) return;
190
+ settled = true;
191
+ cleanup();
192
+ reject(new SnapshotError('captureTimeout', `No frame within ${timeoutMs}ms`));
193
+ }, timeoutMs);
194
+ });
195
+ }
196
+
197
+ function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob | null> {
198
+ return new Promise((resolve) => {
199
+ if (typeof canvas.toBlob === 'function') {
200
+ canvas.toBlob((blob) => resolve(blob), type, quality);
201
+ return;
202
+ }
203
+ try {
204
+ const dataUrl = canvas.toDataURL(type, quality);
205
+ resolve(dataUrlToBlob(dataUrl));
206
+ } catch {
207
+ resolve(null);
208
+ }
209
+ });
210
+ }
211
+
212
+ function dataUrlToBlob(dataUrl: string): Blob | null {
213
+ try {
214
+ const commaIndex = dataUrl.indexOf(',');
215
+ if (commaIndex < 0) return null;
216
+ const meta = dataUrl.slice(0, commaIndex);
217
+ const body = dataUrl.slice(commaIndex + 1);
218
+ const mime = meta.match(/data:([^;]+)/)?.[1] ?? 'application/octet-stream';
219
+ if (meta.includes(';base64')) {
220
+ const binary = atob(body);
221
+ const bytes = new Uint8Array(binary.length);
222
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
223
+ return new Blob([bytes], { type: mime });
224
+ }
225
+ return new Blob([decodeURIComponent(body)], { type: mime });
226
+ } catch {
227
+ return null;
228
+ }
229
+ }
230
+
231
+ export function resolveSnapshotStream(
232
+ source: SnapshotSource,
233
+ localStream: MediaStream | null,
234
+ remoteStreams: Map<string, MediaStream>,
235
+ ): MediaStream {
236
+ if (source.kind === 'local') {
237
+ if (!localStream) {
238
+ throw new SnapshotError('streamNotActive', 'Local stream is not active');
239
+ }
240
+ return localStream;
241
+ }
242
+ if (source.kind === 'remote') {
243
+ const stream = remoteStreams.get(source.cid);
244
+ if (!stream) {
245
+ throw new SnapshotError('streamNotActive', `Remote stream for cid ${source.cid} is not active`);
246
+ }
247
+ return stream;
248
+ }
249
+ throw new SnapshotError('unsupportedSource', 'Unknown snapshot source kind');
250
+ }
package/src/types.ts CHANGED
@@ -100,6 +100,36 @@ export interface CallError {
100
100
  message: string;
101
101
  }
102
102
 
103
+ /**
104
+ * Source for a video snapshot — either the local stream or a specific remote
105
+ * participant's stream identified by their per-call CID.
106
+ */
107
+ export type SnapshotSource =
108
+ | { kind: 'local' }
109
+ | { kind: 'remote'; cid: string };
110
+
111
+ /** Error codes returned by {@link SerenadaSessionHandle.captureSnapshot}. */
112
+ export type SnapshotErrorCode =
113
+ | 'streamNotActive'
114
+ | 'noVideoTrack'
115
+ | 'captureTimeout'
116
+ | 'captureFailed'
117
+ | 'unsupportedSource';
118
+
119
+ /**
120
+ * Result of a successful video snapshot. The blob holds the encoded image
121
+ * (`image/jpeg` by default) at the source video track's full intrinsic
122
+ * resolution — `width` and `height` are pixels, not CSS units.
123
+ */
124
+ export interface SnapshotResult {
125
+ blob: Blob;
126
+ width: number;
127
+ height: number;
128
+ /** Wall-clock time the snapshot was decoded, from `Date.now()`. */
129
+ timestampMs: number;
130
+ source: SnapshotSource;
131
+ }
132
+
103
133
  /**
104
134
  * Richer view of the local signaling transport state. Apps can use this to
105
135
  * render reconnect spinners, "you have been disconnected" UI, and a hard-
@@ -200,6 +230,13 @@ export interface SerenadaSessionHandle {
200
230
  setCameraMode(mode: CameraMode): void;
201
231
  startScreenShare(): Promise<void>;
202
232
  stopScreenShare(): Promise<void>;
233
+ /**
234
+ * Capture the current video frame from the chosen stream at full
235
+ * intrinsic resolution. Defaults to the local stream. Rejects with a
236
+ * `SnapshotError` (code `'streamNotActive'`) when the stream is missing
237
+ * or has no live video track.
238
+ */
239
+ captureSnapshot(source?: SnapshotSource): Promise<SnapshotResult>;
203
240
  resumeJoin(): Promise<void>;
204
241
  cancelJoin(): void;
205
242
  destroy(): void;