openvibers/openvibe.shared ov-capture.js

ov-capture.js

main 27.0 KB · 407 lines · javascript

1/*
2 * ov-capture.js: OpenVibe's capture core, window.OVCapture. One way to open a camera, a microphone or a screen, take a
3 * photo and record a clip, on every OpenVibe site: Live's go-live page, OpenVibe.Cam's recorder and <ov-camera>, and the
4 * composer's camera button. Built from parts that can each be replaced, and served by each site from its own copy
5 * (/shared/ov-capture.js), so a camera never waits on another site, a content policy or a cache.
6 *
7 * The owner, 2026-11-12: Live's capture must be "completely customize[able] … implemented in a highly over engineered
8 * modular editable way", and then "OpenVibe.cam is actually fine … maybe we should be using that for live … Harmony".
9 * Both hold: this file began as Live's own system (OpenVibe.Live #847) and is now the one Live and Cam share. From 11-09
10 * to 11-12 nobody could go live because Live loaded Cam's kit from openvibe.cam and the browser refused it; a site's own
11 * copy of this file cannot be refused that way.
12 *
13 * Using it:
14 * OVCapture.open({ mode, facing, lens, mic, resolution }) → a MediaStream. mode: 'camera' (both), 'camera_only',
15 * 'mic_only'. Runs the strategies in order, each with its
16 * own time limit; the first that opens wins.
17 * OVCapture.screen({ audio, frameRate }) → a MediaStream of a screen, a window or a tab.
18 * OVCapture.photo(stream, { type, quality, mirror }) → a Blob: the camera's picture now.
19 * OVCapture.record(stream, { maxSeconds, mimeType }) → { stop() → Promise<Blob>, cancel(), seconds(), mimeType }.
20 * OVCapture.devices() → the browser's cameras and microphones.
21 * OVCapture.meter(stream, onLevel) → stop(): the microphone's level, 0 to 1, each frame.
22 * OVCapture.network() → 'offline' | 'weak' | 'good' | '' (unknown).
23 * OVCapture.can() → { camera, screen, record, secure }: what this browser
24 * allows here, so a page can hide what it cannot do.
25 * OVCapture.stop(stream)
26 * OVCapture.explain(error, mode, { instead }) → { title, steps }: what happened and what to tap, for
27 * this browser. `instead` adds a last step of the site's own
28 * (Live: "go live with your screen instead").
29 * OVCapture.settings.load() / .save(partial) → the person's choices: facing, lens, mic, resolution,
30 * mirror, effects (the 'store' part; this browser first).
31 * OVCapture.settings.sync({ load, save }) → unsync(): the choices that are the same on every device
32 * (the 'portable' part: facing, resolution, mirror,
33 * effects) follow the person through a remote store, such
34 * as OpenVibe.Cam's camera settings. Camera and microphone
35 * ids stay on the device that has them.
36 *
37 * Changing it:
38 * OVCapture.use(name, impl) replaces a part and returns the one it replaced, so a wrapper can call through:
39 * const before = OVCapture.use('constraints', (o) => ({ ...before(o), video: … }));
40 * OVCapture.part(name) the part now in use. Parts: constraints, getUserMedia, getDisplayMedia, enumerate,
41 * meter, network, still, recorder, mimeTypes, store, portable, explain, timeoutMs.
42 * OVCapture.strategies the ways to open a camera, in order: .list(), .add(strategy, { before, after }),
43 * .remove(name), .replace(name, strategy), .reset(). A strategy is
44 * { name, note?, timeoutMs?, open(options, capture) }.
45 * OVCapture.on(event, fn) 'opening' { strategy, options } · 'opened' { strategy, stream } ·
46 * 'failed' { strategy, error } · 'gave-up' { error } · 'settings' { settings } ·
47 * 'synced' { settings } · 'sync-failed' { error } · 'recording' { seconds } ·
48 * 'recorded' { blob, seconds }. Returns off().
49 * Every name and every default is in this file. It fetches nothing; a page that wants the person's settings to follow
50 * them passes its own remote store to settings.sync().
51 */
52(function (root) {
53 'use strict';
54 const VERSION = 2;
55 if (!root || (root.OVCapture && Number(root.OVCapture.version) >= VERSION)) return;
56 const nav = () => root.navigator || {};
57 const doc = () => root.document || null;
58
59 // ── Parts: each one plain, each one replaceable with use(). ─────────────────────────────────────────────────────
60 const parts = {
61 /** What to ask the browser for, from the options (facing, lens, mic, resolution). */
62 constraints(o = {}) {
63 const height = Number(o.resolution) || 720;
64 const video = o.video === false ? false : {
65 facingMode: { ideal: o.facing || 'user' },
66 ...(o.lens && o.lens !== 'main' && o.lens !== 'default' ? { deviceId: { ideal: o.lens } } : {}),
67 width: { ideal: Math.round(height * 16 / 9) }, height: { ideal: height },
68 };
69 const audio = o.audio === false ? false : (o.mic && o.mic !== 'default' ? { deviceId: { ideal: o.mic } } : true);
70 return { video, audio };
71 },
72 getUserMedia: (constraints) => nav().mediaDevices.getUserMedia(constraints),
73 getDisplayMedia: (constraints) => nav().mediaDevices.getDisplayMedia(constraints),
74 enumerate: () => nav().mediaDevices.enumerateDevices(),
75 meter(stream, onLevel) {
76 const AC = root.AudioContext || root.webkitAudioContext;
77 if (!AC || !stream || !stream.getAudioTracks().length) return () => {};
78 const ctx = new AC(), analyser = ctx.createAnalyser(), data = new Uint8Array(512);
79 let frame = 0, closed = false;
80 analyser.fftSize = 512;
81 ctx.createMediaStreamSource(stream).connect(analyser);
82 if (ctx.state === 'suspended') {
83 ctx.resume().catch(() => {});
84 const d = doc();
85 if (d) d.addEventListener('pointerdown', () => { ctx.resume().catch(() => {}); }, { once: true, capture: true });
86 }
87 const tick = () => {
88 if (closed) return;
89 analyser.getByteTimeDomainData(data);
90 let sum = 0;
91 for (let i = 0; i < data.length; i++) { const v = (data[i] - 128) / 128; sum += v * v; }
92 onLevel(Math.min(1, Math.sqrt(sum / data.length) * 4));
93 frame = root.requestAnimationFrame(tick);
94 };
95 frame = root.requestAnimationFrame(tick);
96 return () => { closed = true; root.cancelAnimationFrame(frame); ctx.close().catch(() => {}); };
97 },
98 network() {
99 const n = nav();
100 if (n.onLine === false) return 'offline';
101 const c = n.connection || n.mozConnection || n.webkitConnection;
102 if (!c || !(c.effectiveType || c.downlink)) return '';
103 return /(^|-)(2g|3g)$/.test(c.effectiveType || '') || (c.downlink && c.downlink < 2.5) ? 'weak' : 'good';
104 },
105 /** The camera's picture now, as a Blob: the video track drawn once into a canvas. */
106 async still(stream, o = {}) {
107 const d = doc();
108 const track = stream && stream.getVideoTracks()[0];
109 if (!d || !track) throw Object.assign(new Error('No camera picture to take'), { name: 'NotFoundError' });
110 const video = d.createElement('video');
111 video.muted = true; video.playsInline = true; video.srcObject = new root.MediaStream([track]);
112 await video.play().catch(() => {});
113 if (!video.videoWidth) await new Promise((r) => { video.addEventListener('loadeddata', r, { once: true }); root.setTimeout(r, 1500); });
114 const w = video.videoWidth || 1280, h = video.videoHeight || 720;
115 const canvas = d.createElement('canvas');
116 canvas.width = w; canvas.height = h;
117 const g = canvas.getContext('2d');
118 if (o.mirror) { g.translate(w, 0); g.scale(-1, 1); }
119 g.drawImage(video, 0, 0, w, h);
120 video.srcObject = null;
121 return new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('The picture could not be made'))), o.type || 'image/jpeg', o.quality || 0.92));
122 },
123 /** A MediaRecorder for the stream. */
124 recorder: (stream, options) => new root.MediaRecorder(stream, options),
125 /** The recording formats to try, best first; the first this browser supports is used. */
126 mimeTypes: {
127 video: ['video/mp4;codecs=avc1,mp4a.40.2', 'video/mp4', 'video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm'],
128 audio: ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm'],
129 },
130 /** Where the person's choices live on this device. Device ids are this browser's own, so this store is local. */
131 store: {
132 key: 'ov_capture_settings',
133 load() { try { return JSON.parse(root.localStorage.getItem(this.key) || 'null') || {}; } catch { return {}; } },
134 save(settings) { try { root.localStorage.setItem(this.key, JSON.stringify(settings)); } catch { /* private mode */ } },
135 },
136 /** The choices that mean the same on any device, so they may follow the person (settings.sync). */
137 portable: ['facing', 'resolution', 'mirror', 'effects'],
138 explain: null, // set below (explainFailure): the plain words for a failure, by browser
139 timeoutMs: 10000,
140 };
141
142 // ── Events ──────────────────────────────────────────────────────────────────────────────────────────────────
143 const listeners = new Map();
144 function on(event, fn) {
145 if (!listeners.has(event)) listeners.set(event, new Set());
146 listeners.get(event).add(fn);
147 return () => listeners.get(event).delete(fn);
148 }
149 function emit(event, detail) {
150 (listeners.get(event) || []).forEach((fn) => { try { fn(detail); } catch (e) { console.warn('[capture] listener', event, e); } });
151 }
152
153 // ── Strategies: the ways to open a camera, tried in order. ──────────────────────────────────────────────────────
154 const wants = (o) => ({ video: o.mode !== 'mic_only' && o.video !== false, audio: o.mode !== 'camera_only' && o.audio !== false });
155 const merge = (...streams) => {
156 const out = new root.MediaStream();
157 streams.filter(Boolean).forEach((s) => s.getTracks().forEach((t) => out.addTrack(t)));
158 return out;
159 };
160 const defaults = [
161 {
162 name: 'preferred',
163 note: 'Camera and microphone together, as chosen (facing, lens, microphone, resolution).',
164 open: (o, c) => c.part('getUserMedia')(c.part('constraints')({ ...o, ...wants(o) })),
165 },
166 {
167 name: 'separately',
168 note: 'The camera, then the microphone, each on its own (some Android phones refuse both at once).',
169 async open(o, c) {
170 const w = wants(o), gum = c.part('getUserMedia'), cons = c.part('constraints');
171 let video = null, audio = null, last = null;
172 if (w.video) { try { video = await gum({ video: cons(o).video, audio: false }); } catch (e) { last = e; } }
173 if (w.audio) { try { audio = await gum({ video: false, audio: cons(o).audio }); } catch (e) { last = e; } }
174 if (!video && !audio) throw last || new root.DOMException('No camera or microphone', 'NotFoundError');
175 return merge(video, audio);
176 },
177 },
178 {
179 name: 'basic',
180 note: 'Any camera and any microphone, nothing asked of them.',
181 open: (o, c) => c.part('getUserMedia')(wants(o)),
182 },
183 ];
184 let order = defaults.slice();
185 const strategies = {
186 list: () => order.map((s) => ({ name: s.name, note: s.note || '' })),
187 add(strategy, at = {}) {
188 if (!strategy || !strategy.name || typeof strategy.open !== 'function') throw new TypeError('a strategy is { name, open(options, capture) }');
189 order = order.filter((s) => s.name !== strategy.name);
190 const i = at.before ? order.findIndex((s) => s.name === at.before) : at.after ? order.findIndex((s) => s.name === at.after) + 1 : -1;
191 if (i < 0 || i > order.length) order.push(strategy); else order.splice(i, 0, strategy);
192 },
193 remove(name) { order = order.filter((s) => s.name !== name); },
194 replace(name, strategy) { order = order.map((s) => (s.name === name ? { ...strategy, name } : s)); },
195 reset() { order = defaults.slice(); },
196 };
197
198 // A refusal says the most about what to do, so it wins over "not found" when several strategies failed differently.
199 const WEIGHT = { NotAllowedError: 6, PermissionDeniedError: 6, SecurityError: 5, NotReadableError: 4, TrackStartError: 4, AbortError: 3, OverconstrainedError: 2, NotFoundError: 1, DevicesNotFoundError: 1, TimeoutError: 1 };
200 const heavier = (a, b) => (!a ? b : (WEIGHT[b && b.name] || 0) > (WEIGHT[a.name] || 0) ? b : a);
201 const refused = (e) => !!e && (e.name === 'NotAllowedError' || e.name === 'PermissionDeniedError' || e.name === 'SecurityError');
202 function within(promise, ms, name) {
203 let timer;
204 return Promise.race([promise, new Promise((_, reject) => { timer = root.setTimeout(() => reject(new root.DOMException(`${name} took longer than ${ms} ms`, 'TimeoutError')), ms); })])
205 .finally(() => root.clearTimeout(timer));
206 }
207 const noApi = (what) => Object.assign(new Error(`This browser has no ${what} here`), { name: 'NoApi' });
208
209 async function open(options = {}) {
210 const md = nav().mediaDevices;
211 if (!md || !md.getUserMedia) throw noApi('camera');
212 const o = { ...capture.settings.load(), ...options };
213 let worst = null;
214 for (const s of order) {
215 emit('opening', { strategy: s.name, options: o });
216 try {
217 const stream = await within(Promise.resolve(s.open(o, capture)), Number(s.timeoutMs || parts.timeoutMs), s.name);
218 const w = wants(o);
219 // A strategy that opened less than was asked for has not opened it: say so and stop the tracks.
220 if ((w.video && !stream.getVideoTracks().length) || (w.audio && !stream.getAudioTracks().length)) {
221 stream.getTracks().forEach((t) => t.stop());
222 throw new root.DOMException('The requested device was not available', 'NotFoundError');
223 }
224 emit('opened', { strategy: s.name, stream });
225 return stream;
226 } catch (error) {
227 worst = heavier(worst, error);
228 emit('failed', { strategy: s.name, error });
229 // A refusal is the person's answer: asking again in another way would only ask again.
230 if (refused(error)) break;
231 }
232 }
233 emit('gave-up', { error: worst });
234 throw worst || new Error('The camera did not open');
235 }
236
237 async function screen(options = {}) {
238 const md = nav().mediaDevices;
239 if (!md || !md.getDisplayMedia) throw noApi('screen sharing');
240 const constraints = { video: { frameRate: { ideal: Number(options.frameRate) || 30 } }, audio: options.audio !== false };
241 emit('opening', { strategy: 'screen', options });
242 try {
243 const stream = await parts.getDisplayMedia(constraints);
244 emit('opened', { strategy: 'screen', stream });
245 return stream;
246 } catch (error) {
247 emit('failed', { strategy: 'screen', error });
248 emit('gave-up', { error });
249 throw error;
250 }
251 }
252
253 // ── Recording ─────────────────────────────────────────────────────────────────────────────────────────────
254 function pickMime(kind, wanted) {
255 const MR = root.MediaRecorder;
256 const list = wanted ? [wanted] : (parts.mimeTypes[kind] || []);
257 if (!MR || typeof MR.isTypeSupported !== 'function') return '';
258 return list.find((t) => { try { return MR.isTypeSupported(t); } catch { return false; } }) || '';
259 }
260 function record(stream, o = {}) {
261 if (!root.MediaRecorder) throw noApi('recorder');
262 const kind = stream.getVideoTracks().length ? 'video' : 'audio';
263 const mimeType = pickMime(kind, o.mimeType);
264 const rec = parts.recorder(stream, mimeType ? { mimeType } : undefined);
265 const chunks = [];
266 const max = Math.max(1, Number(o.maxSeconds) || 60);
267 const started = Date.now();
268 let ticker = 0, limit = 0, done = null, cancelled = false;
269 const seconds = () => Math.min(max, Math.round((Date.now() - started) / 100) / 10);
270 const finished = new Promise((resolve, reject) => {
271 rec.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); };
272 rec.onerror = (e) => reject((e && e.error) || new Error('The recording failed'));
273 rec.onstop = () => {
274 root.clearInterval(ticker); root.clearTimeout(limit);
275 if (cancelled) { resolve(null); return; }
276 const blob = new root.Blob(chunks, { type: (mimeType || rec.mimeType || `${kind}/webm`).split(';')[0] });
277 emit('recorded', { blob, seconds: seconds() });
278 resolve(blob);
279 };
280 });
281 rec.start(1000);
282 ticker = root.setInterval(() => emit('recording', { seconds: seconds() }), 500);
283 limit = root.setTimeout(() => { if (rec.state !== 'inactive') rec.stop(); }, max * 1000);
284 return {
285 mimeType: mimeType || rec.mimeType || '',
286 seconds,
287 stop() { if (!done) { done = finished; if (rec.state !== 'inactive') rec.stop(); } return done; },
288 cancel() { cancelled = true; if (rec.state !== 'inactive') rec.stop(); },
289 };
290 }
291
292 // ── Plain words for a failure, by browser. ───────────────────────────────────────────────────────────────
293 function explainFailure(err, mode, extra = {}) {
294 const n = nav(), ua = n.userAgent || '';
295 const ios = /iPhone|iPad|iPod/.test(ua) || (/Macintosh/.test(ua) && n.maxTouchPoints > 1);
296 const mac = /Macintosh/.test(ua) && !ios;
297 const android = /Android/.test(ua);
298 const firefox = /Firefox|FxiOS/.test(ua);
299 const safari = /Safari/.test(ua) && !/Chrome|CriOS|Chromium|Edg|FxiOS/.test(ua);
300 const inApp = /FBAN|FBAV|Instagram|Line\/|Twitter|Discord|TikTok|musical_ly|Snapchat|GSA\//.test(ua) || /; wv\)/.test(ua);
301 const what = mode === 'mic_only' ? 'microphone' : mode === 'camera_only' ? 'camera' : mode === 'screen' ? 'screen' : 'camera and microphone';
302 const both = mode === 'camera_only' ? '' : ' and Microphone';
303 const name = err && err.name;
304 const instead = extra && extra.instead ? [extra.instead] : [];
305 if (name === 'NoApi' || (inApp && name !== 'NotReadableError')) {
306 return { title: `This browser can't use your ${mode === 'screen' ? 'screen' : 'camera'} here`, steps: inApp
307 ? ['You opened this inside another app (like Discord, Instagram or TikTok). Those can\'t share a camera.', `Tap the ⋮ or ⋯ menu and choose "Open in ${ios ? 'Safari' : 'Chrome'}" (or "Open in browser").`, 'Sign in there if asked, then try again.']
308 : mode === 'screen' && (ios || android) ? ['Phones can\'t share their screen from a browser. Use a computer, or your camera instead.', ...instead]
309 : ['Open this page in Chrome, Safari, Edge or Firefox, then try again.', ...instead] };
310 }
311 if (mode === 'screen' && refused(err)) {
312 return { title: 'Screen sharing was stopped', steps: mac
313 ? ['Try again and pick a screen, a window or a tab.', 'Nothing to pick? Open System Settings → Privacy & Security → Screen Recording, turn on your browser, then reopen it.']
314 : ['Try again and pick a screen, a window or a tab, then press Share.'] };
315 }
316 if (refused(err)) {
317 const steps = ios && safari ? ['Tap "aA" (or the page icon) at the left of the address bar.', 'Tap Website Settings.', `Set Camera${both} to Allow.`]
318 : ios ? ['Open the iPhone Settings app, find this browser, and turn on Camera and Microphone.', 'Come back here.']
319 : android && !firefox ? ['Tap the icon at the left of the address bar (a lock or two sliders).', 'Tap Permissions (or Site settings).', `Set Camera${both} to Allow.`]
320 : firefox ? ['Click the camera icon with a line through it, at the left of the address bar.', 'Remove "Blocked temporarily" (the ×).']
321 : safari ? ['Open Safari\'s menu → Settings for this website.', `Set Camera${both} to Allow.`]
322 : ['Click the camera icon at the right end of the address bar (or the lock at the left).', `Choose "Always allow" for the ${what}, then Done.`];
323 return { title: `Your browser said no to the ${what}`, steps: [...steps, 'Then press Try again.'] };
324 }
325 if (name === 'NotReadableError' || name === 'TrackStartError' || name === 'AbortError') {
326 return { title: 'Another app is using your camera', steps: ['Close apps that use the camera, like Zoom, FaceTime, WhatsApp, Instagram or the Camera app (and other tabs with a call).', 'Then press Try again.'] };
327 }
328 if (name === 'NotFoundError' || name === 'DevicesNotFoundError') {
329 return { title: `No ${what} found`, steps: [mode === 'mic_only' ? 'Plug in a microphone or headset, then press Try again.' : 'Plug in a camera (or open its privacy cover), then press Try again.', ...instead] };
330 }
331 if (name === 'OverconstrainedError') {
332 return { title: 'That camera can\'t start with these settings', steps: ['Press Try again: it starts with simpler settings.', 'Still stuck? Pick another camera in the camera settings.'] };
333 }
334 if (name === 'TimeoutError') {
335 return { title: 'Your browser didn\'t answer', steps: ['Look for the browser\'s question at the top of the screen and tap Allow.', 'Nothing there? Press Try again.'] };
336 }
337 return { title: `The ${mode === 'screen' ? 'screen sharing' : 'camera'} didn't start`, steps: ['Press Try again.', 'Still stuck? Reload this page, or try Chrome or Safari.'] };
338 }
339 parts.explain = explainFailure;
340
341 // ── Settings: this device first; the portable ones may follow the person. ──────────────────────────────────────
342 const DEFAULTS = { facing: 'user', lens: 'main', mic: 'default', resolution: '720', mirror: true, effects: 'none' };
343 const pick = (o, keys) => Object.fromEntries(keys.filter((k) => o && o[k] !== undefined).map((k) => [k, o[k]]));
344 let remote = null;
345 const settings = {
346 load: () => ({ ...DEFAULTS, ...parts.store.load() }),
347 save(partial) {
348 const next = { ...settings.load(), ...(partial || {}) };
349 parts.store.save(next);
350 emit('settings', { settings: next });
351 const r = remote;
352 const portable = pick(partial || {}, parts.portable);
353 if (r && Object.keys(portable).length) {
354 Promise.resolve().then(() => r.save(pick(next, parts.portable))).catch((error) => emit('sync-failed', { error }));
355 }
356 return next;
357 },
358 /** Follow the person: take the remote store's portable choices now, and send each later change to it. */
359 sync(store) {
360 if (!store || typeof store.load !== 'function' || typeof store.save !== 'function') throw new TypeError('settings.sync takes { load(), save(settings) }');
361 remote = store;
362 const mine = store;
363 Promise.resolve().then(() => store.load()).then((theirs) => {
364 if (remote !== mine || !theirs || typeof theirs !== 'object') return;
365 const next = { ...settings.load(), ...pick(theirs, parts.portable) };
366 parts.store.save(next);
367 emit('settings', { settings: next });
368 emit('synced', { settings: next });
369 }).catch((error) => emit('sync-failed', { error }));
370 return () => { if (remote === mine) remote = null; };
371 },
372 };
373
374 // ── The API ─────────────────────────────────────────────────────────────────────────────────────────────────
375 const capture = {
376 version: VERSION,
377 part(name) {
378 if (!(name in parts)) throw new RangeError(`no capture part "${name}"; the parts are ${Object.keys(parts).join(', ')}`);
379 return parts[name];
380 },
381 use(name, impl) {
382 const before = capture.part(name);
383 parts[name] = impl;
384 return before;
385 },
386 parts: () => Object.keys(parts),
387 strategies,
388 on,
389 open,
390 screen,
391 photo: (stream, o = {}) => parts.still(stream, { mirror: false, ...o }),
392 record,
393 devices: () => parts.enumerate(),
394 meter: (stream, onLevel) => parts.meter(stream, onLevel),
395 network: () => parts.network(),
396 can() {
397 const md = nav().mediaDevices || {};
398 return { camera: typeof md.getUserMedia === 'function', screen: typeof md.getDisplayMedia === 'function', record: typeof root.MediaRecorder === 'function', secure: root.isSecureContext !== false };
399 },
400 stop: (stream) => { try { stream && stream.getTracks().forEach((t) => t.stop()); } catch { /* already stopped */ } },
401 explain: (error, mode, extra) => parts.explain(error, mode, extra),
402 settings,
403 };
404 root.OVCapture = capture;
405 if (typeof module === 'object' && module && module.exports) module.exports = capture;
406})(typeof window !== 'undefined' ? window : globalThis);
407