@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.
- package/dist/SerenadaCore.d.ts.map +1 -1
- package/dist/SerenadaCore.js +4 -0
- package/dist/SerenadaCore.js.map +1 -1
- package/dist/SerenadaSession.d.ts +8 -1
- package/dist/SerenadaSession.d.ts.map +1 -1
- package/dist/SerenadaSession.js +39 -0
- package/dist/SerenadaSession.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/media/MediaEngine.d.ts +1 -0
- package/dist/media/MediaEngine.d.ts.map +1 -1
- package/dist/media/MediaEngine.js +15 -5
- package/dist/media/MediaEngine.js.map +1 -1
- package/dist/media/captureSnapshot.d.ts +34 -0
- package/dist/media/captureSnapshot.d.ts.map +1 -0
- package/dist/media/captureSnapshot.js +213 -0
- package/dist/media/captureSnapshot.js.map +1 -0
- package/dist/types.d.ts +32 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/SerenadaCore.ts +4 -0
- package/src/SerenadaSession.ts +45 -0
- package/src/index.ts +13 -1
- package/src/media/MediaEngine.ts +14 -5
- package/src/media/captureSnapshot.ts +250 -0
- package/src/types.ts +37 -0
package/src/SerenadaSession.ts
CHANGED
|
@@ -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.
|
|
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,
|
package/src/media/MediaEngine.ts
CHANGED
|
@@ -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
|
-
|
|
326
|
-
displayTrack.enabled =
|
|
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
|
|
364
|
+
const restoreVideoEnabled = this.screenShareRestoreVideoEnabled;
|
|
361
365
|
|
|
362
366
|
try {
|
|
363
|
-
|
|
364
|
-
|
|
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;
|