openvibers/openvibe.cam Commits
Cam's kit and <ov-camera> on OVCapture v2 (#157)
Changes
@@@ -17,7 +17,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.216.0","pg": "^8.23.0"},"devDependencies": {@@@ -1275,9 +1275,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.214.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",-- "integrity": "sha512-HvaqQOZLLFEgwDaCkrhJFfwQfsMIlOmV05PhO796JFmC7A1NScxOqp86djT6sYsK1pARMqeo0kTua5mAdXiQFw==",++ "version": "4.216.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.216.0",++ "integrity": "sha512-SEkeaCoiZv0ZLyJpvinReZO4mK4Du+YiNGY+VQM1Ts4wY1OZEU1m8xJQSKDJsuauEul2dj28ss4Zj6UhmLuMGQ==","license": "MIT","dependencies": {"clean-css": "^5.3.3",@@@ -25,7 +25,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.216.0","pg": "^8.23.0"},"engines": {@@@ -7,49 +7,208 @@var cssUrl = script && script.src ? new URL('cam-kit.css', script.src).href : 'https://openvibe.cam/embed/cam-kit.css';var active = null;var MODES = ['video', 'photo', 'screen'];++ var CAPTURE_PATH = '/shared/ov-capture.js';++ var capture = null;++ var capturePending = null;++ var remote = null;++++ // ── The shared capture core, from this page's own origin (the same arrangement Live's go-live page uses). ─────++ function captureSrc() {++ try { return new URL(CAPTURE_PATH, win.location.href).href; } catch (e) { return ''; }++ }++++ /** A chosen camera is that camera, and its side is exact when its id has gone stale. */++ function install(core) {++ if (!core || !core.strategies || typeof core.use !== 'function') return core;++ if (!core.__ovCamExact && !core.__glExact) {++ var before = core.use('constraints', function (o) {++ var constraints = before(o || {});++ o = o || {};++ if (constraints.video && o.lens && o.lens !== 'main' && o.lens !== 'default' && o.lens !== '-') {++ var rest = { ...constraints.video };++ delete rest.facingMode;++ delete rest.deviceId;++ constraints.video = { ...rest, deviceId: { exact: o.lens } };++ }++ return constraints;++ });++ core.__ovCamExact = true;++ }++ var hasSide = core.strategies.list().some(function (s) { return s.name === 'side'; });++ if (!hasSide) {++ core.strategies.add({++ name: 'side',++ note: 'The chosen camera\'s side exactly (its id changed): the back camera stays the back camera.',++ open: function (o, c) {++ if (!o.facing || o.video === false || o.mode === 'mic_only') {++ var e = new Error('No side to ask for');++ e.name = 'NotFoundError';++ throw e;++ }++ var height = Number(o.resolution) || 720;++ return c.part('getUserMedia')({++ video: { facingMode: { exact: o.facing }, width: { ideal: Math.round(height * 16 / 9) }, height: { ideal: height } },++ audio: o.mode !== 'camera_only' && o.audio !== false,++ });++ },++ }, { before: 'basic' });++ }++ return core;++ }++++ function settingsUrl() {++ try { return new URL('/api/v1/me/camera-settings', win.location.href).href; } catch (e) { return ''; }++ }++++ /** Cam's camera settings for the person, when this page is on a site that serves them. */++ function camStore() {++ if (remote) return remote;++ var url = settingsUrl();++ if (!url || typeof win.fetch !== 'function') return null;++ var store = {++ latest: null,++ load: function () {++ return win.fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json' } }).then(function (res) {++ if (!res.ok) throw new Error('No camera settings here');++ return res.json();++ }).then(function (data) { store.latest = data; return data; });++ },++ save: function (values) {++ return win.fetch(url, {++ method: 'PUT', credentials: 'same-origin',++ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },++ body: JSON.stringify(values),++ }).then(function (res) {++ if (!res.ok) throw new Error('The camera settings were not saved');++ return res.json();++ });++ },++ };++ remote = store;++ return store;++ }++++ /** Let facing, resolution and mirror follow the person; the lens is this device's own id, applied here. */++ function syncSettings(core) {++ if (!core || !core.settings || typeof core.settings.sync !== 'function' || typeof core.on !== 'function') return Promise.resolve(core);++ var store = camStore();++ if (!store) return Promise.resolve(core);++ return new Promise(function (resolve) {++ var finished = false, offSynced = null, offFailed = null, timer = null;++ function finish() {++ if (finished) return;++ finished = true;++ if (offSynced) offSynced();++ if (offFailed) offFailed();++ if (timer) win.clearTimeout(timer);++ resolve(core);++ }++ offSynced = core.on('synced', function () {++ if (store.latest && typeof store.latest.lens === 'string' && store.latest.lens) {++ try { core.settings.save({ lens: store.latest.lens }); } catch (e) { /* this device keeps its own */ }++ }++ finish();++ });++ offFailed = core.on('sync-failed', finish);++ timer = win.setTimeout(finish, 1500);++ try { core.settings.sync(store); } catch (e) { finish(); }++ });++ }++++ function loadCapture() {++ if (capturePending) return capturePending;++ if (capture) return Promise.resolve(capture);++ if (win.OVCapture && typeof win.OVCapture.open === 'function') {++ capture = install(win.OVCapture);++ capturePending = syncSettings(capture);++ return capturePending;++ }++ var src = captureSrc();++ if (!src || !win.document || !win.document.createElement) return Promise.reject(new Error('The camera needs this page\'s address.'));++ capturePending = new Promise(function (resolve, reject) {++ var tag = win.document.createElement('script');++ tag.src = src;++ tag.async = true;++ tag.addEventListener('load', function () {++ if (win.OVCapture && typeof win.OVCapture.open === 'function') {++ capture = install(win.OVCapture);++ resolve(capture);++ } else {++ if (tag.remove) tag.remove();++ reject(new Error('The camera code loaded without OVCapture. Reload the page, then try again.'));++ }++ });++ tag.addEventListener('error', function () {++ if (tag.remove) tag.remove();++ reject(new Error('The camera code did not load. Reload the page, then try again.'));++ });++ (win.document.head || win.document.documentElement).appendChild(tag);++ }).then(syncSettings).catch(function (err) {++ capturePending = null;++ throw err;++ });++ return capturePending;++ }++++ function constraintsFor(o) {++ o = o || {};++ var height = Number(o.resolution) || 720;++ return {++ video: o.video === false ? false : {++ facingMode: { ideal: o.facing || 'user' },++ ...(o.lens && o.lens !== 'main' && o.lens !== 'default' ? { deviceId: { ideal: o.lens } } : {}),++ width: { ideal: Math.round(height * 16 / 9) }, height: { ideal: height },++ },++ audio: o.audio === false ? false : o.mic && o.mic !== 'default' ? { deviceId: { ideal: o.mic } } : true,++ };++ }++++ function networkFor() {++ if (win.navigator.onLine === false) return 'offline';++ var c = win.navigator.connection || win.navigator.mozConnection || win.navigator.webkitConnection;++ return c && (/2g|3g/.test(c.effectiveType || '') || c.downlink && c.downlink < 2.5) ? 'weak' : 'good';++ }++++ function meterFor(stream, onLevel) {++ var AC = win.AudioContext || win.webkitAudioContext;++ if (!AC || !stream.getAudioTracks().length) return function () {};++ var ctx = new AC(), analyser = ctx.createAnalyser(), data = new Uint8Array(512), frame = 0, closed = false;++ analyser.fftSize = 512;++ ctx.createMediaStreamSource(stream).connect(analyser);++ if (ctx.state === 'suspended') {++ ctx.resume().catch(function () {});++ win.document.addEventListener('pointerdown', function () { ctx.resume().catch(function () {}); }, { once: true, capture: true });++ }++ function tick() {++ if (closed) return;++ analyser.getByteTimeDomainData(data);++ var sum = 0;++ for (var i = 0; i < data.length; i++) { var d = (data[i] - 128) / 128; sum += d * d; }++ onLevel(Math.min(1, Math.sqrt(sum / data.length) * 4));++ frame = win.requestAnimationFrame(tick);++ }++ frame = win.requestAnimationFrame(tick);++ return function () { closed = true; win.cancelAnimationFrame(frame); ctx.close().catch(function () {}); };++ }// Shared capture primitives for a site's in-place studio. Nothing requests permission until open() is called.var core = Object.freeze({-- devices: function () { return win.navigator.mediaDevices.enumerateDevices(); },++ devices: function () {++ return loadCapture().then(function (c) { return c.devices(); }, function () { return win.navigator.mediaDevices.enumerateDevices(); });++ },constraints: function (o) {-- o = o || {};-- var height = Number(o.resolution) || 720;-- return {-- video: o.video === false ? false : {-- facingMode: { ideal: o.facing || 'user' },-- ...(o.lens && o.lens !== 'main' && o.lens !== 'default' ? { deviceId: { ideal: o.lens } } : {}),-- width: { ideal: Math.round(height * 16 / 9) }, height: { ideal: height },-- },-- audio: o.audio === false ? false : o.mic && o.mic !== 'default' ? { deviceId: { ideal: o.mic } } : true,-- };++ if (capture && typeof capture.part === 'function') return capture.part('constraints')(o || {});++ return constraintsFor(o);},-- open: function (o) { return win.navigator.mediaDevices.getUserMedia(core.constraints(o)); },++ open: function (o) { return loadCapture().then(function (c) { return c.open(o || {}); }); },stop: stopTracks,network: function () {-- if (win.navigator.onLine === false) return 'offline';-- var c = win.navigator.connection || win.navigator.mozConnection || win.navigator.webkitConnection;-- return c && (/2g|3g/.test(c.effectiveType || '') || c.downlink && c.downlink < 2.5) ? 'weak' : 'good';++ if (capture && typeof capture.network === 'function') return capture.network();++ return networkFor();},meter: function (stream, onLevel) {-- var AC = win.AudioContext || win.webkitAudioContext;-- if (!AC || !stream.getAudioTracks().length) return function () {};-- var ctx = new AC(), analyser = ctx.createAnalyser(), data = new Uint8Array(512), frame = 0, closed = false;-- analyser.fftSize = 512;-- ctx.createMediaStreamSource(stream).connect(analyser);-- if (ctx.state === 'suspended') {-- ctx.resume().catch(function () {});-- win.document.addEventListener('pointerdown', function () { ctx.resume().catch(function () {}); }, { once: true, capture: true });-- }-- function tick() {-- if (closed) return;-- analyser.getByteTimeDomainData(data);-- var sum = 0;-- for (var i = 0; i < data.length; i++) { var d = (data[i] - 128) / 128; sum += d * d; }-- onLevel(Math.min(1, Math.sqrt(sum / data.length) * 4));-- frame = win.requestAnimationFrame(tick);-- }-- frame = win.requestAnimationFrame(tick);-- return function () { closed = true; win.cancelAnimationFrame(frame); ctx.close().catch(function () {}); };++ if (capture && typeof capture.meter === 'function') return capture.meter(stream, onLevel);++ return meterFor(stream, onLevel);},});@@@ -109,8 +268,9 @@var review = $('.review');var reviewVideo = $('.review video');var reviewImage = $('.review img');-- var stream = null, recorder = null, chunks = [], blob = null, url = null, started = 0, tick = null, autoStop = null;-- var facing = 'user', torch = false, phase = 'permission', done = false, lastFocus = win.document.activeElement;++ var stream = null, recorder = null, blob = null, url = null, tick = null, autoStop = null;++ var facing = 'user', facingLoaded = false, torch = false, phase = 'permission', done = false, lastFocus = win.document.activeElement;++ var captureCore = null;var duration = 0, trimStart = 0, trimEnd = 0, timer = opts.timer;active = host;$('.mode-label').textContent = opts.mode === 'photo' ? 'Photo' : opts.mode === 'screen' ? 'Screen' : 'Video';@@@ -129,16 +289,25 @@function close(result, error) {if (done) return;done = true; cleanRecording();-- if (recorder && recorder.state !== 'inactive') recorder.stop();++ if (recorder) { try { recorder.cancel(); } catch (e) { /* already stopped */ } recorder = null; }stopTracks(stream); stream = null; clearBlob();win.document.removeEventListener('keydown', keydown, true);host.remove(); active = null;if (lastFocus && lastFocus.focus) lastFocus.focus();if (error) reject(error); else resolve(result || null);}++ /** What happened and what to tap, in OVCapture's plain words. */++ function explained(err, mode) {++ if (captureCore && typeof captureCore.explain === 'function') {++ try {++ var said = captureCore.explain(err, mode);++ if (said && said.title) return [said.title].concat(said.steps || []).join(' ');++ } catch (e) { /* fall back to the error's own words */ }++ }++ return (err && err.message) || 'This camera is unavailable.';++ }function permissionError(err) {-- var denied = err && (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError');-- say(denied ? 'Permission was not granted. You can try again.' : (err && err.message) || 'This camera is unavailable.');++ say(explained(err, opts.mode === 'screen' ? 'screen' : opts.mode === 'photo' ? 'camera_only' : 'camera'));phase = 'permission'; $('.permission').hidden = false;}function updateTorch() {@@@ -148,9 +317,26 @@if (caps.torch) $('.torch').setAttribute('aria-pressed', String(torch));}async function start() {-- if (!supported(opts.mode, win.navigator)) { say('This browser cannot use the selected camera mode.'); return; }try {-- var next = opts.mode === 'screen' ? await win.navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }) : await core.open({ facing: facing, audio: opts.mode === 'video' });++ captureCore = await loadCapture();++ } catch (err) { permissionError(err); return; }++ var can = typeof captureCore.can === 'function' ? captureCore.can() : null;++ var allowed = can ? (opts.mode === 'screen' ? can.screen : can.camera && (opts.mode === 'photo' || can.record))++ : supported(opts.mode, win.navigator);++ if (!allowed) {++ var noApi = new Error(opts.mode === 'screen' ? 'This browser has no screen sharing here' : 'This browser has no camera here');++ noApi.name = 'NoApi';++ permissionError(noApi);++ return;++ }++ try {++ if (!facingLoaded) {++ var saved = captureCore.settings && typeof captureCore.settings.load === 'function' ? captureCore.settings.load() : {};++ if (saved.facing === 'user' || saved.facing === 'environment') facing = saved.facing;++ facingLoaded = true;++ }++ var next = opts.mode === 'screen' ? await captureCore.screen({ video: true, audio: true })++ : await captureCore.open({ facing: facing, audio: opts.mode === 'video' });if (done) { stopTracks(next); return; }stopTracks(stream); stream = next;preview.srcObject = stream; await preview.play();@@@ -187,34 +373,34 @@$('.use').focus();}async function capturePhoto() {-- var canvas = win.document.createElement('canvas');-- canvas.width = preview.videoWidth || 1280; canvas.height = preview.videoHeight || 720;-- canvas.getContext('2d').drawImage(preview, 0, 0, canvas.width, canvas.height);-- var image = await new Promise(function (resolveImage) { canvas.toBlob(resolveImage, 'image/jpeg', 0.92); });++ var settings = captureCore.settings && typeof captureCore.settings.load === 'function' ? captureCore.settings.load() : {};++ var image = await captureCore.photo(stream, { mirror: !!settings.mirror && facing === 'user' });if (!image) throw new Error('The photo could not be encoded.');showReview(image, 0);}function stopRecord() {if (phase !== 'recording' || !recorder) return;-- phase = 'stopping'; cleanRecording(); recorder.stop();++ var stopped = recorder;++ phase = 'stopping'; cleanRecording();++ stopped.stop().then(function (recorded) {++ if (recorder === stopped) recorder = null;++ if (done || phase !== 'stopping') return;++ if (!recorded || !recorded.size) { phase = 'ready'; say('No video was recorded. Try again.'); return; }++ showReview(recorded, stopped.seconds());++ }).catch(function (err) {++ if (recorder === stopped) recorder = null;++ if (done || phase !== 'stopping') return;++ phase = 'ready';++ say(explained(err, 'camera'));++ });}function record() {-- chunks = [];-- var type = mime(); recorder = new win.MediaRecorder(stream, type ? { mimeType: type } : undefined);-- recorder.ondataavailable = function (event) { if (event.data && event.data.size) chunks.push(event.data); };-- recorder.onerror = function () { say('Recording failed. Please retake.'); };-- recorder.onstop = function () {-- if (done || phase !== 'stopping') return;-- var length = Math.min(opts.maxSeconds, (Date.now() - started) / 1000);-- var recorded = new Blob(chunks, { type: recorder.mimeType || type || 'video/webm' });-- if (!recorded.size) { phase = 'ready'; say('No video was recorded. Try again.'); return; }-- showReview(recorded, length);-- };-- recorder.start(200); started = Date.now(); phase = 'recording';++ recorder = captureCore.record(stream, { maxSeconds: opts.maxSeconds });++ phase = 'recording';$('.shutter').classList.add('recording');$('.shutter').style.setProperty('--progress', '0%');tick = win.setInterval(function () {-- var elapsed = Math.min(opts.maxSeconds, (Date.now() - started) / 1000);++ var elapsed = recorder ? recorder.seconds() : 0;$('.clock').textContent = secondsText(elapsed);$('.shutter').style.setProperty('--progress', (100 * elapsed / opts.maxSeconds) + '%');}, 100);@@@ -226,7 +412,7 @@if (!(await countdown())) { if (timer) return; }if (done || phase !== 'ready') return;try { if (opts.mode === 'photo') await capturePhoto(); else record(); }-- catch (err) { phase = 'ready'; say(err.message || 'Capture failed.'); }++ catch (err) { phase = 'ready'; say(explained(err, opts.mode === 'photo' ? 'camera_only' : 'camera')); }}function updateTrim() {var startInput = $('.trim-start'), endInput = $('.trim-end');@@@ -269,7 +455,9 @@$('.shutter').onclick = shutter;$('.flip').onclick = async function () {if (phase !== 'ready') return;-- facing = facing === 'user' ? 'environment' : 'user'; torch = false; await start();++ facing = facing === 'user' ? 'environment' : 'user'; torch = false;++ if (captureCore && captureCore.settings) captureCore.settings.save({ facing: facing });++ await start();};$('.torch').onclick = async function () {var track = stream && stream.getVideoTracks()[0]; if (!track) return;@@@ -37,6 +37,157 @@var UPLOADS = ['off', 'openvibe'];var DEFAULT_SRC = 'https://openvibe.cam/embed/ov-camera.js';var SCRIPT_ORIGIN = null;++ var CAPTURE_PATH = '/shared/ov-capture.js';++ var capture = null;++ var capturePending = null;++ var remote = null;++++ // ── The shared capture core, from the embedding page's own origin (never another site). ──────────────────────++ function captureSrc() {++ try { return new URL(CAPTURE_PATH, root.location.href).href; } catch (e) { return ''; }++ }++++ /** A chosen camera is that camera, and its side is exact when its id has gone stale (Live's setup). */++ function install(core) {++ if (!core || !core.strategies || typeof core.use !== 'function') return core;++ if (!core.__ovCamExact && !core.__glExact) {++ var before = core.use('constraints', function (o) {++ var constraints = before(o || {});++ o = o || {};++ if (constraints.video && o.lens && o.lens !== 'main' && o.lens !== 'default' && o.lens !== '-') {++ var rest = { ...constraints.video };++ delete rest.facingMode;++ delete rest.deviceId;++ constraints.video = { ...rest, deviceId: { exact: o.lens } };++ }++ return constraints;++ });++ core.__ovCamExact = true;++ }++ var hasSide = core.strategies.list().some(function (s) { return s.name === 'side'; });++ if (!hasSide) {++ core.strategies.add({++ name: 'side',++ note: 'The chosen camera\'s side exactly (its id changed): the back camera stays the back camera.',++ open: function (o, c) {++ if (!o.facing || o.video === false || o.mode === 'mic_only') {++ var e = new Error('No side to ask for');++ e.name = 'NotFoundError';++ throw e;++ }++ var height = Number(o.resolution) || 720;++ return c.part('getUserMedia')({++ video: { facingMode: { exact: o.facing }, width: { ideal: Math.round(height * 16 / 9) }, height: { ideal: height } },++ audio: o.mode !== 'camera_only' && o.audio !== false,++ });++ },++ }, { before: 'basic' });++ }++ return core;++ }++++ function settingsUrl() {++ try { return new URL('/api/v1/me/camera-settings', root.location.href).href; } catch (e) { return ''; }++ }++++ function camStore() {++ if (remote) return remote;++ var url = settingsUrl();++ if (!url || !root || typeof root.fetch !== 'function') return null;++ var store = {++ latest: null,++ load: function () {++ return root.fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json' } }).then(function (res) {++ if (!res.ok) throw new Error('No camera settings here');++ return res.json();++ }).then(function (data) { store.latest = data; return data; });++ },++ save: function (values) {++ return root.fetch(url, {++ method: 'PUT', credentials: 'same-origin',++ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },++ body: JSON.stringify(values),++ }).then(function (res) {++ if (!res.ok) throw new Error('The camera settings were not saved');++ return res.json();++ });++ },++ };++ remote = store;++ return store;++ }++++ /** Let facing, resolution and mirror follow the person; the lens is this device's own id, applied here. */++ function syncSettings(core) {++ if (!core || !core.settings || typeof core.settings.sync !== 'function' || typeof core.on !== 'function') return Promise.resolve(core);++ var store = camStore();++ if (!store) return Promise.resolve(core);++ return new Promise(function (resolve) {++ var finished = false, offSynced = null, offFailed = null, timer = null;++ function finish() {++ if (finished) return;++ finished = true;++ if (offSynced) offSynced();++ if (offFailed) offFailed();++ if (timer) root.clearTimeout(timer);++ resolve(core);++ }++ offSynced = core.on('synced', function () {++ if (store.latest && typeof store.latest.lens === 'string' && store.latest.lens) {++ try { core.settings.save({ lens: store.latest.lens }); } catch (e) { /* this device keeps its own */ }++ }++ finish();++ });++ offFailed = core.on('sync-failed', finish);++ timer = root.setTimeout(finish, 1500);++ try { core.settings.sync(store); } catch (e) { finish(); }++ });++ }++++ function loadCapture() {++ if (capturePending) return capturePending;++ if (capture) return Promise.resolve(capture);++ if (root && root.OVCapture && typeof root.OVCapture.open === 'function') {++ capture = install(root.OVCapture);++ capturePending = syncSettings(capture);++ return capturePending;++ }++ var src = root ? captureSrc() : '';++ if (!src || !root.document || !root.document.createElement) return Promise.reject(new Error('The camera needs this page\'s address.'));++ capturePending = new Promise(function (resolve, reject) {++ var tag = root.document.createElement('script');++ tag.src = src;++ tag.async = true;++ tag.addEventListener('load', function () {++ if (root.OVCapture && typeof root.OVCapture.open === 'function') {++ capture = install(root.OVCapture);++ resolve(capture);++ } else {++ if (tag.remove) tag.remove();++ reject(new Error('The camera code loaded without OVCapture. Reload the page, then try again.'));++ }++ });++ tag.addEventListener('error', function () {++ if (tag.remove) tag.remove();++ reject(new Error('The camera code did not load. Reload the page, then try again.'));++ });++ (root.document.head || root.document.documentElement).appendChild(tag);++ }).then(syncSettings).catch(function (err) {++ capturePending = null;++ throw err;++ });++ return capturePending;++ }++++ function explainedFailure(core, err, mode) {++ if (core && typeof core.explain === 'function') {++ try {++ var said = core.explain(err, mode);++ if (said && said.title) return [said.title].concat(said.steps || []).join(' ');++ } catch (e) { /* fall back to the error's own words */ }++ }++ return (err && err.message) || 'The camera could not be started. Pick a picture instead.';++ }/** "none, mono, WARM" → ['none','mono','warm']; unknown names and duplicates are dropped, empty → ['none']. */function parseFilters(value) {@@@ -145,6 +296,26 @@});}++ /** A captured picture through the chosen filter, so the file looks the way the preview did. */++ function filterPhoto(blob, filter, width, height) {++ if (!filter || filter === 'none' || !root || !root.document || !root.URL || !root.Image) return Promise.resolve(blob);++ return new Promise(function (resolve, reject) {++ var url = root.URL.createObjectURL(blob);++ var img = new root.Image();++ img.onload = function () {++ var canvas = root.document.createElement('canvas');++ try { drawTo(canvas, img, filter, width, height); }++ catch (err) { root.URL.revokeObjectURL(url); reject(err); return; }++ toBlob(canvas, 'image/jpeg', 0.92).then(function (out) {++ root.URL.revokeObjectURL(url);++ resolve(out);++ }, function (err) { root.URL.revokeObjectURL(url); reject(err); });++ };++ img.onerror = function () { root.URL.revokeObjectURL(url); reject(new Error('The picture could not be read.')); };++ img.src = url;++ });++ }++var ICONS = {timer: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="13.5" r="7"/><path d="M12 13.5V10M9.5 3.5h5"/></svg>',flip: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 9a8 8 0 0 1 14.3-3.3L20 7.5M20 3.5v4h-4M20 15a8 8 0 0 1-14.3 3.3L4 16.5M4 20.5v-4h4"/></svg>',@@@ -292,20 +463,24 @@connectedCallback() {if (!this._camera) return;++ this._explicitFacing = this.hasAttribute('facing');this._applyAttributes();this._camera.setAttribute('data-state', 'off');this._camera.setAttribute('data-mode', this._config.mode);this._camera.setAttribute('data-facing', this._config.facing);++ this._fromCapture = true;this.setAttribute('facing', this._config.facing);++ this._fromCapture = false;}disconnectedCallback() { this.stop(); }attributeChangedCallback(name, oldValue, newValue) {if (oldValue === newValue || !this._camera) return;++ if (name === 'facing' && !this._fromCapture) this._explicitFacing = true;var wasFacing = this._config.facing;this._applyAttributes();-- if (name === 'facing' && this._stream && wasFacing !== this._config.facing) {++ if (name === 'facing' && !this._fromCapture && this._stream && wasFacing !== this._config.facing) {var self = this;this.stop();this.start().catch(function () { /* start() already dispatched the error */ });@@@ -318,25 +493,29 @@var self = this;if (this._stream) return Promise.resolve();if (this._starting) return this._starting;-- if (!root.navigator || !root.navigator.mediaDevices || !root.navigator.mediaDevices.getUserMedia) {-- this._say('This browser has no camera input. Pick a picture instead.');-- this._error('camera_unavailable', 'This browser has no camera input. Pick a picture instead.');-- return Promise.resolve();-- }this._say('Turning the camera on…');this._camera.setAttribute('data-state', 'starting');var seq = (this._startSeq || 0) + 1;this._startSeq = seq;-- this._starting = root.navigator.mediaDevices.getUserMedia({-- video: { facingMode: { ideal: this._config.facing }, width: { ideal: 1920 }, height: { ideal: 1440 } },-- audio: false++ this._starting = loadCapture().then(function (core) {++ self._capture = core;++ var options = { mode: 'camera_only', video: true, audio: false };++ if (self._explicitFacing) options.facing = self._config.facing;++ return core.open(options);}).then(function (stream) {if (seq !== self._startSeq) { stream.getTracks().forEach(function (t) { t.stop(); }); return false; }self._stream = stream;self._video.srcObject = stream;var track = stream.getVideoTracks()[0];var settings = track && track.getSettings ? track.getSettings() : {};-- self._camera.setAttribute('data-facing', settings.facingMode || (self._config.facing === 'user' ? 'user' : 'environment'));++ var saved = !self._explicitFacing && self._capture && self._capture.settings ? self._capture.settings.load() : {};++ var actual = settings.facingMode || saved.facing;++ self._camera.setAttribute('data-facing', actual || (self._config.facing === 'user' ? 'user' : 'environment'));++ if (actual && actual !== self._config.facing && !self._explicitFacing) {++ self._fromCapture = true;++ self.setAttribute('facing', actual);++ self._fromCapture = false;++ }return self._video.play().catch(function () { /* muted autoplay is allowed; a blocked play is not fatal */ }).then(function () { return true; });}).then(function (ok) {if (ok === false || seq !== self._startSeq) return;@@@ -347,9 +526,7 @@if (seq !== self._startSeq) return;self._camera.setAttribute('data-state', 'off');var code = err && (err.name === 'NotAllowedError' || err.name === 'SecurityError') ? 'permission_denied' : 'camera_unavailable';-- var message = code === 'permission_denied'-- ? 'The camera was not allowed. Allow it in the browser, or pick a picture.'-- : 'The camera could not be started. Pick a picture instead.';++ var message = explainedFailure(self._capture, err, 'camera_only');self._say(message);self._error(code, message);}).then(function () { self._starting = null; });@@@ -408,7 +585,11 @@/** Switch front/back; if the camera is on it restarts on the other one. Resolves to the new facing. */flip() {var next = this._config.facing === 'user' ? 'environment' : 'user';++ this._explicitFacing = true;this.setAttribute('facing', next);++ loadCapture().then(function (core) {++ if (core.settings) core.settings.save({ facing: next });++ }).catch(function () { /* the attribute still flips this camera */ });return Promise.resolve(next);}@@@ -496,22 +677,30 @@_snap() {var self = this;-- var size;-- try { size = drawTo(this._canvas, this._video, this._selectedFilter); }-- catch (err) { this._error('capture_failed', err.message); return Promise.resolve(null); }++ var size = {++ width: this._video.videoWidth || 1280,++ height: this._video.videoHeight || 720,++ };if (this._shootBtn) {this._shootBtn.classList.add('is-firing');setTimeout(function () { if (self._shootBtn) self._shootBtn.classList.remove('is-firing'); }, 160);}if (root.navigator && root.navigator.vibrate) { try { root.navigator.vibrate(12); } catch (e) { /* not every browser */ } }-- return toBlob(this._canvas, 'image/jpeg', 0.92).then(function (blob) {++ return loadCapture().then(function (core) {++ self._capture = core;++ var settings = core.settings && typeof core.settings.load === 'function' ? core.settings.load() : {};++ var mirror = !!settings.mirror && self._config.facing === 'user';++ return core.photo(self._stream, { mirror: mirror });++ }).then(function (blob) {++ return filterPhoto(blob, self._selectedFilter, size.width, size.height);++ }).then(function (blob) {var detail = { blob: blob, width: size.width, height: size.height, filter: self._selectedFilter };self._showThumb(blob);self.dispatchEvent(new root.CustomEvent('ov-camera:capture', { detail: detail, bubbles: true, composed: true }));if (self._config.upload === 'openvibe') { self._upload(detail); }return detail;}).catch(function (err) {-- self._error('capture_failed', err.message);++ self._error('capture_failed', explainedFailure(self._capture, err, 'camera_only'));return null;});}nnew file mode 100644@@@ -0,0 +1,127 @@++'use strict';++/**++ * The kit in a real browser: OVCapture comes from the page's own /shared/ov-capture.js, and the person's saved++ * camera choices reach navigator.mediaDevices.getUserMedia as exact constraints — Front/Back as facingMode exact++ * when the "ideal" ask is refused, a chosen lens as deviceId exact. Cam's settings API is a small local fixture.++ *++ * The browser is the workstation's Chrome; where it is missing (a CI image without Chrome) the test skips.++ */++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const os = require('os');++const path = require('path');++const { execFile } = require('child_process');++++const ROOT = path.join(__dirname, '..');++const CHROME = process.env.CHROME_PATH || '/usr/bin/google-chrome';++const KIT = path.join(ROOT, 'public/embed/cam-kit.js');++const CAPTURE = path.join(ROOT, 'node_modules/openvibe-shared/ov-capture.js');++++function page(settings, failIdeal) {++ return `<!doctype html><meta charset="utf-8"><title>cam-kit</title>++<script>++window.__calls = [];++Object.defineProperty(navigator.mediaDevices, 'getUserMedia', { configurable: true, value: async function (constraints) {++ window.__calls.push(JSON.parse(JSON.stringify(constraints || {})));++ if (${failIdeal ? 'true' : 'false'} && constraints && constraints.video && constraints.video.facingMode && constraints.video.facingMode.ideal) {++ var e = new Error('the ideal ask was refused'); e.name = 'OverconstrainedError'; throw e;++ }++ var track = { stop: function () {}, addEventListener: function () {}, getSettings: function () { return { facingMode: 'environment' }; },++ getCapabilities: function () { return {}; }, applyConstraints: function () { return Promise.resolve(); } };++ return { getVideoTracks: function () { return [track]; }, getAudioTracks: function () { return []; }, getTracks: function () { return [track]; } };++} });++</script>++<script src="/embed/cam-kit.js"></script>++<script>++(async function () {++ try { await OpenVibeCam.core.open({ mode: 'camera_only' }); }++ catch (e) { window.__error = String((e && e.message) || e); }++ document.body.setAttribute('data-calls', btoa(JSON.stringify(window.__calls)));++ document.body.setAttribute('data-error', btoa(window.__error || ''));++})();++</script>++<body></body>`;++}++++function serve(settings) {++ return new Promise((resolve) => {++ const server = http.createServer((req, res) => {++ const url = new URL(req.url, 'http://127.0.0.1');++ if (url.pathname === '/embed/cam-kit.js') {++ res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });++ return fs.createReadStream(KIT).pipe(res);++ }++ if (url.pathname === '/shared/ov-capture.js') {++ res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });++ return fs.createReadStream(CAPTURE).pipe(res);++ }++ if (url.pathname === '/api/v1/me/camera-settings') {++ res.writeHead(200, { 'Content-Type': 'application/json' });++ return res.end(JSON.stringify(settings));++ }++ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });++ res.end(page(settings, url.searchParams.get('ideal') === 'fail'));++ });++ server.listen(0, '127.0.0.1', () => resolve(server));++ });++}++++function chrome(url) {++ return new Promise((resolve, reject) => {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cam-kit-chrome-'));++ execFile(CHROME, [++ '--headless=new', '--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage',++ `--user-data-dir=${dir}`, '--virtual-time-budget=6000', '--dump-dom', url,++ ], { timeout: 30000, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {++ try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* a temp profile */ }++ if (err) return reject(Object.assign(err, { stderr }));++ resolve(stdout);++ });++ });++}++++function callsOf(dom) {++ const match = /data-calls="([^"]*)"/.exec(dom);++ assert(match, 'the page reported its getUserMedia calls');++ return JSON.parse(Buffer.from(match[1], 'base64').toString('utf8'));++}++++(async () => {++ if (!fs.existsSync(CHROME)) { console.log(` skip ${CHROME} is not installed`); return; }++ assert(fs.existsSync(CAPTURE), 'the pinned openvibe-shared serves ov-capture.js');++ const cases = [++ {++ name: 'Front/Back reaches getUserMedia as facingMode exact',++ settings: { facing: 'environment', lens: 'main', resolution: '1080', mirror: false, updated_at: null },++ ideal: 'fail',++ check(calls) { return calls.some((c) => c.video && c.video.facingMode && c.video.facingMode.exact === 'environment'); },++ },++ {++ name: 'a chosen lens reaches getUserMedia as deviceId exact',++ settings: { facing: 'environment', lens: 'cam-back-2', resolution: '1080', mirror: false, updated_at: null },++ ideal: 'ok',++ check(calls) { return calls.some((c) => c.video && c.video.deviceId && c.video.deviceId.exact === 'cam-back-2'); },++ },++ ];++ for (const test of cases) {++ const server = await serve(test.settings);++ try {++ const base = `http://127.0.0.1:${server.address().port}/?ideal=${test.ideal}`;++ const dom = await chrome(base);++ const error = /data-error="([^"]*)"/.exec(dom);++ const said = error ? Buffer.from(error[1], 'base64').toString('utf8') : '';++ assert.strictEqual(said, '', `${test.name}: ${said}`);++ assert(test.check(callsOf(dom)), test.name);++ console.log(` ok ${test.name}`);++ } finally {++ await new Promise((resolve) => server.close(resolve));++ }++ }++ console.log('\nall passed\n');++})().catch((err) => {++ if (err && err.code === 'ENOENT') { console.log(` skip ${CHROME} could not start`); return; }++ console.error(err && err.stack ? err.stack : err);++ if (err && err.stderr) console.error(String(err.stderr).slice(-2000));++ process.exitCode = 1;++});@@@ -16,4 +16,67 @@ assert.strictEqual(window.OpenVibeCam.core.constraints({ facing: 'environment',assert(Object.isFrozen(window.OpenVibeCam));assert.throws(() => window.OpenVibeCam.open({ mode: 'invalid' }), /mode must be/);assert(!/\brequire\(|\bimport\s/.test(source.replace(/\/\*[\s\S]*?\*\//g, '')));++assert(!/mediaDevices\.getUserMedia\s*\(/.test(source), 'the kit opens cameras through OVCapture, never its own getUserMedia');++assert(!/mediaDevices\.getDisplayMedia\s*\(/.test(source), 'the kit opens screens through OVCapture, never its own getDisplayMedia');console.log('cam-kit API shape and standalone script: ok');++++// ── The kit against a fake OVCapture: the same public API, all opens through the core. ──────────────────────────++(async () => {++ const calls = { open: [], save: [], use: [], add: [] };++ const parts = {++ constraints: (o) => {++ o = o || {};++ const height = Number(o.resolution) || 720;++ return {++ video: {++ facingMode: { ideal: o.facing || 'user' },++ ...(o.lens && o.lens !== 'main' && o.lens !== 'default' ? { deviceId: { ideal: o.lens } } : {}),++ width: { ideal: Math.round(height * 16 / 9) }, height: { ideal: height },++ },++ audio: o.audio === false ? false : true,++ };++ },++ getUserMedia: (constraints) => { calls.gum = constraints; return Promise.resolve({ getTracks: () => [] }); },++ };++ const strategies = [];++ const fake = {++ version: 2,++ open: (o) => { calls.open.push(o); return Promise.resolve({ getTracks: () => [], getVideoTracks: () => [], getAudioTracks: () => [] }); },++ devices: () => Promise.resolve([{ kind: 'videoinput', deviceId: 'cam-9' }]),++ meter: () => () => {},++ network: () => 'good',++ part: (name) => parts[name],++ use: (name, impl) => { calls.use.push(name); const before = parts[name]; parts[name] = impl; return before; },++ strategies: {++ list: () => strategies.map((s) => ({ name: s.name })),++ add: (strategy) => { calls.add.push(strategy.name); strategies.push(strategy); },++ },++ settings: {++ load: () => ({ facing: 'environment', lens: 'applied-lens', resolution: '1080', mirror: false }),++ save: (values) => { calls.save.push(values); return values; },++ },++ on: () => () => {},++ explain: (err, mode) => ({ title: `explained ${mode}`, steps: ['Tap Allow.', 'Try again.'] }),++ };++ const win = {++ document: { currentScript: { src: 'https://openvibe.cam/embed/cam-kit.js' } },++ navigator: { mediaDevices: { getUserMedia: () => { throw new Error('the kit must not call getUserMedia itself'); } } },++ location: { href: 'https://openvibe.network/page' },++ OVCapture: fake,++ };++ vm.runInNewContext(source, { window: win, URL, Blob, File, Promise, Number, String, Date, setTimeout, clearTimeout, setInterval, clearInterval });++ const kit = win.OpenVibeCam;++ assert.strictEqual(typeof kit.open, 'function');++ assert.strictEqual(typeof kit.core.open, 'function');++ assert.deepStrictEqual(parts.constraints({ lens: 'cam-9' }).video.deviceId, { ideal: 'cam-9' });++ assert.deepStrictEqual(await kit.core.devices(), [{ kind: 'videoinput', deviceId: 'cam-9' }]);++ await kit.core.open({ mode: 'video', facing: 'environment' });++ assert.deepStrictEqual(calls.open, [{ mode: 'video', facing: 'environment' }], 'the open is OVCapture.open');++ assert(calls.use.includes('constraints'), 'the chosen lens becomes an exact constraint');++ assert.strictEqual(parts.constraints({ lens: 'cam-9' }).video.deviceId.exact, 'cam-9');++ assert.deepStrictEqual(calls.add, ['side'], 'the exact-side strategy is added');++ assert.strictEqual(strategies[0].name, 'side');++ await strategies[0].open({ facing: 'environment', mode: 'camera_only' }, fake);++ assert.strictEqual(calls.gum.video.facingMode.exact, 'environment', 'the side is asked for exactly');++ console.log('cam-kit fake OVCapture: all opens through the shared core');++})().catch((err) => { console.error(err && err.stack ? err.stack : err); process.exitCode = 1; });@@@ -18,7 +18,11 @@ check('the element is framework-free and Node can require it without a DOM', ()assert.strictEqual(typeof core.parseAttrs, 'function');assert.strictEqual(typeof core.drawTo, 'function');assert.strictEqual(typeof core.define, 'function');-- assert.ok(!/require\(|import\s/.test(require('fs').readFileSync(require.resolve('../public/embed/ov-camera.js'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')), 'no bundler or runtime import');++ const source = require('fs').readFileSync(require.resolve('../public/embed/ov-camera.js'), 'utf8');++ assert.ok(!/require\(|import\s/.test(source.replace(/\/\*[\s\S]*?\*\//g, '')), 'no bundler or runtime import');++ assert.ok(source.includes("CAPTURE_PATH = '/shared/ov-capture.js'"), 'it loads the core from the page\'s own /shared');++ assert.ok(!/navigator\.mediaDevices\.getUserMedia\s*\(/.test(source), 'the element opens cameras through OVCapture, never its own getUserMedia');++ assert.ok(!/navigator\.mediaDevices\.getDisplayMedia\s*\(/.test(source));});check('the filter table is exactly the API X-Filter list', () => {@@@ -148,6 +148,11 @@ function jsonLd(html) {const kit = await t.get('/embed/cam-kit.js');assert.strictEqual(kit.status, 200);assert.strictEqual(kit.headers.get('access-control-allow-origin'), '*');++ const capture = await t.get('/shared/ov-capture.js');++ assert.strictEqual(capture.status, 200, 'the kit and <ov-camera> load the shared core from their own origin');++ assert.match(capture.headers.get('content-type'), /javascript/);++ assert.ok(capture.text.includes('OVCapture'), 'it is the OVCapture core');++ assert.strictEqual(capture.headers.get('access-control-allow-origin'), '*');assert.ok(embed.text.includes("define('ov-camera'"), 'the embed registers the element');assert.ok(embed.text.includes('ov-camera:capture'), 'the embed emits the capture event');const plainEmbed = await t.get('/embed/ov-camera.js');
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.216.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -1275,9 +1275,9 @@
@@ -1275,9 +1275,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.214.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",
- "integrity": "sha512-HvaqQOZLLFEgwDaCkrhJFfwQfsMIlOmV05PhO796JFmC7A1NScxOqp86djT6sYsK1pARMqeo0kTua5mAdXiQFw==",
+ "version": "4.216.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.216.0",
+ "integrity": "sha512-SEkeaCoiZv0ZLyJpvinReZO4mK4Du+YiNGY+VQM1Ts4wY1OZEU1m8xJQSKDJsuauEul2dj28ss4Zj6UhmLuMGQ==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"clean-css": "^5.3.3",
"clean-css": "^5.3.3",
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.214.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.216.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
@@ -7,49 +7,208 @@
@@ -7,49 +7,208 @@
var cssUrl = script && script.src ? new URL('cam-kit.css', script.src).href : 'https://openvibe.cam/embed/cam-kit.css';
var cssUrl = script && script.src ? new URL('cam-kit.css', script.src).href : 'https://openvibe.cam/embed/cam-kit.css';
var active = null;
var active = null;
var MODES = ['video', 'photo', 'screen'];
var MODES = ['video', 'photo', 'screen'];
+ var CAPTURE_PATH = '/shared/ov-capture.js';
+ var capture = null;
+ var capturePending = null;
+ var remote = null;
+
+ // ── The shared capture core, from this page's own origin (the same arrangement Live's go-live page uses). ─────
+ function captureSrc() {
+ try { return new URL(CAPTURE_PATH, win.location.href).href; } catch (e) { return ''; }
+ }
+
+ /** A chosen camera is that camera, and its side is exact when its id has gone stale. */
+ function install(core) {
+ if (!core || !core.strategies || typeof core.use !== 'function') return core;
+ if (!core.__ovCamExact && !core.__glExact) {
+ var before = core.use('constraints', function (o) {
+ var constraints = before(o || {});
+ o = o || {};
+ if (constraints.video && o.lens && o.lens !== 'main' && o.lens !== 'default' && o.lens !== '-') {
+ var rest = { ...constraints.video };
+ delete rest.facingMode;
+ delete rest.deviceId;
+ constraints.video = { ...rest, deviceId: { exact: o.lens } };
+ }
+ return constraints;
+ });
+ core.__ovCamExact = true;
+ }
+ var hasSide = core.strategies.list().some(function (s) { return s.name === 'side'; });
+ if (!hasSide) {
+ core.strategies.add({
+ name: 'side',
+ note: 'The chosen camera\'s side exactly (its id changed): the back camera stays the back camera.',
+ open: function (o, c) {
+ if (!o.facing || o.video === false || o.mode === 'mic_only') {
+ var e = new Error('No side to ask for');
+ e.name = 'NotFoundError';
+ throw e;
+ }
+ var height = Number(o.resolution) || 720;
+ return c.part('getUserMedia')({
+ video: { facingMode: { exact: o.facing }, width: { ideal: Math.round(height * 16 / 9) }, height: { ideal: height } },
+ audio: o.mode !== 'camera_only' && o.audio !== false,
+ });
+ },
+ }, { before: 'basic' });
+ }
+ return core;
+ }
+
+ function settingsUrl() {
+ try { return new URL('/api/v1/me/camera-settings', win.location.href).href; } catch (e) { return ''; }
+ }
+
+ /** Cam's camera settings for the person, when this page is on a site that serves them. */
+ function camStore() {
+ if (remote) return remote;
+ var url = settingsUrl();
+ if (!url || typeof win.fetch !== 'function') return null;
+ var store = {
+ latest: null,
+ load: function () {
+ return win.fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json' } }).then(function (res) {
+ if (!res.ok) throw new Error('No camera settings here');
+ return res.json();
+ }).then(function (data) { store.latest = data; return data; });
+ },
+ save: function (values) {
+ return win.fetch(url, {
+ method: 'PUT', credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
+ body: JSON.stringify(values),
+ }).then(function (res) {
+ if (!res.ok) throw new Error('The camera settings were not saved');
+ return res.json();
+ });
+ },
+ };
+ remote = store;
+ return store;
+ }
+
+ /** Let facing, resolution and mirror follow the person; the lens is this device's own id, applied here. */
+ function syncSettings(core) {
+ if (!core || !core.settings || typeof core.settings.sync !== 'function' || typeof core.on !== 'function') return Promise.resolve(core);
+ var store = camStore();
+ if (!store) return Promise.resolve(core);
+ return new Promise(function (resolve) {
+ var finished = false, offSynced = null, offFailed = null, timer = null;
+ function finish() {
+ if (finished) return;
+ finished = true;
+ if (offSynced) offSynced();
+ if (offFailed) offFailed();
+ if (timer) win.clearTimeout(timer);
+ resolve(core);
+ }
+ offSynced = core.on('synced', function () {
+ if (store.latest && typeof store.latest.lens === 'string' && store.latest.lens) {
+ try { core.settings.save({ lens: store.latest.lens }); } catch (e) { /* this device keeps its own */ }
+ }
+ finish();
+ });
+ offFailed = core.on('sync-failed', finish);
+ timer = win.setTimeout(finish, 1500);
+ try { core.settings.sync(store); } catch (e) { finish(); }
+ });
+ }
+
+ function loadCapture() {
+ if (capturePending) return capturePending;
+ if (capture) return Promise.resolve(capture);
+ if (win.OVCapture && typeof win.OVCapture.open === 'function') {
+ capture = install(win.OVCapture);
+ capturePending = syncSettings(capture);
+ return capturePending;
+ }
+ var src = captureSrc();
+ if (!src || !win.document || !win.document.createElement) return Promise.reject(new Error('The camera needs this page\'s address.'));
+ capturePending = new Promise(function (resolve, reject) {
+ var tag = win.document.createElement('script');
+ tag.src = src;
+ tag.async = true;
+ tag.addEventListener('load', function () {
+ if (win.OVCapture && typeof win.OVCapture.open === 'function') {
+ capture = install(win.OVCapture);
+ resolve(capture);
+ } else {
+ if (tag.remove) tag.remove();
+ reject(new Error('The camera code loaded without OVCapture. Reload the page, then try again.'));
+ }
+ });
+ tag.addEventListener('error', function () {
+ if (tag.remove) tag.remove();
+ reject(new Error('The camera code did not load. Reload the page, then try again.'));
+ });
+ (win.document.head || win.document.documentElement).appendChild(tag);
+ }).then(syncSettings).catch(function (err) {
+ capturePending = null;
+ throw err;
+ });
+ return capturePending;
+ }
+
+ function constraintsFor(o) {
+ o = o || {};
+ var height = Number(o.resolution) || 720;
+ return {
+ video: o.video === false ? false : {
+ facingMode: { ideal: o.facing || 'user' },
+ ...(o.lens && o.lens !== 'main' && o.lens !== 'default' ? { deviceId: { ideal: o.lens } } : {}),
+ width: { ideal: Math.round(height * 16 / 9) }, height: { ideal: height },
+ },
+ audio: o.audio === false ? false : o.mic && o.mic !== 'default' ? { deviceId: { ideal: o.mic } } : true,
+ };
+ }
+
+ function networkFor() {
+ if (win.navigator.onLine === false) return 'offline';
+ var c = win.navigator.connection || win.navigator.mozConnection || win.navigator.webkitConnection;
+ return c && (/2g|3g/.test(c.effectiveType || '') || c.downlink && c.downlink < 2.5) ? 'weak' : 'good';
+ }
+
+ function meterFor(stream, onLevel) {
+ var AC = win.AudioContext || win.webkitAudioContext;
+ if (!AC || !stream.getAudioTracks().length) return function () {};
+ var ctx = new AC(), analyser = ctx.createAnalyser(), data = new Uint8Array(512), frame = 0, closed = false;
+ analyser.fftSize = 512;
+ ctx.createMediaStreamSource(stream).connect(analyser);
+ if (ctx.state === 'suspended') {
+ ctx.resume().catch(function () {});
+ win.document.addEventListener('pointerdown', function () { ctx.resume().catch(function () {}); }, { once: true, capture: true });
+ }
+ function tick() {
+ if (closed) return;
+ analyser.getByteTimeDomainData(data);
+ var sum = 0;
+ for (var i = 0; i < data.length; i++) { var d = (data[i] - 128) / 128; sum += d * d; }
+ onLevel(Math.min(1, Math.sqrt(sum / data.length) * 4));
+ frame = win.requestAnimationFrame(tick);
+ }
+ frame = win.requestAnimationFrame(tick);
+ return function () { closed = true; win.cancelAnimationFrame(frame); ctx.close().catch(function () {}); };
+ }
// Shared capture primitives for a site's in-place studio. Nothing requests permission until open() is called.
// Shared capture primitives for a site's in-place studio. Nothing requests permission until open() is called.
var core = Object.freeze({
var core = Object.freeze({
- devices: function () { return win.navigator.mediaDevices.enumerateDevices(); },
+ devices: function () {
+ return loadCapture().then(function (c) { return c.devices(); }, function () { return win.navigator.mediaDevices.enumerateDevices(); });
+ },
constraints: function (o) {
constraints: function (o) {
- o = o || {};
- var height = Number(o.resolution) || 720;
- return {
- video: o.video === false ? false : {
- facingMode: { ideal: o.facing || 'user' },
- ...(o.lens && o.lens !== 'main' && o.lens !== 'default' ? { deviceId: { ideal: o.lens } } : {}),
- width: { ideal: Math.round(height * 16 / 9) }, height: { ideal: height },
- },
- audio: o.audio === false ? false : o.mic && o.mic !== 'default' ? { deviceId: { ideal: o.mic } } : true,
- };
+ if (capture && typeof capture.part === 'function') return capture.part('constraints')(o || {});
+ return constraintsFor(o);
},
},
- open: function (o) { return win.navigator.mediaDevices.getUserMedia(core.constraints(o)); },
+ open: function (o) { return loadCapture().then(function (c) { return c.open(o || {}); }); },
stop: stopTracks,
stop: stopTracks,
network: function () {
network: function () {
- if (win.navigator.onLine === false) return 'offline';
- var c = win.navigator.connection || win.navigator.mozConnection || win.navigator.webkitConnection;
- return c && (/2g|3g/.test(c.effectiveType || '') || c.downlink && c.downlink < 2.5) ? 'weak' : 'good';
+ if (capture && typeof capture.network === 'function') return capture.network();
+ return networkFor();
},
},
meter: function (stream, onLevel) {
meter: function (stream, onLevel) {
- var AC = win.AudioContext || win.webkitAudioContext;
- if (!AC || !stream.getAudioTracks().length) return function () {};
- var ctx = new AC(), analyser = ctx.createAnalyser(), data = new Uint8Array(512), frame = 0, closed = false;
- analyser.fftSize = 512;
- ctx.createMediaStreamSource(stream).connect(analyser);
- if (ctx.state === 'suspended') {
- ctx.resume().catch(function () {});
- win.document.addEventListener('pointerdown', function () { ctx.resume().catch(function () {}); }, { once: true, capture: true });
- }
- function tick() {
- if (closed) return;
- analyser.getByteTimeDomainData(data);
- var sum = 0;
- for (var i = 0; i < data.length; i++) { var d = (data[i] - 128) / 128; sum += d * d; }
- onLevel(Math.min(1, Math.sqrt(sum / data.length) * 4));
- frame = win.requestAnimationFrame(tick);
- }
- frame = win.requestAnimationFrame(tick);
- return function () { closed = true; win.cancelAnimationFrame(frame); ctx.close().catch(function () {}); };
+ if (capture && typeof capture.meter === 'function') return capture.meter(stream, onLevel);
+ return meterFor(stream, onLevel);
},
},
});
});
@@ -109,8 +268,9 @@
@@ -109,8 +268,9 @@
var review = $('.review');
var review = $('.review');
var reviewVideo = $('.review video');
var reviewVideo = $('.review video');
var reviewImage = $('.review img');
var reviewImage = $('.review img');
- var stream = null, recorder = null, chunks = [], blob = null, url = null, started = 0, tick = null, autoStop = null;
- var facing = 'user', torch = false, phase = 'permission', done = false, lastFocus = win.document.activeElement;
+ var stream = null, recorder = null, blob = null, url = null, tick = null, autoStop = null;
+ var facing = 'user', facingLoaded = false, torch = false, phase = 'permission', done = false, lastFocus = win.document.activeElement;
+ var captureCore = null;
var duration = 0, trimStart = 0, trimEnd = 0, timer = opts.timer;
var duration = 0, trimStart = 0, trimEnd = 0, timer = opts.timer;
active = host;
active = host;
$('.mode-label').textContent = opts.mode === 'photo' ? 'Photo' : opts.mode === 'screen' ? 'Screen' : 'Video';
$('.mode-label').textContent = opts.mode === 'photo' ? 'Photo' : opts.mode === 'screen' ? 'Screen' : 'Video';
@@ -129,16 +289,25 @@
@@ -129,16 +289,25 @@
function close(result, error) {
function close(result, error) {
if (done) return;
if (done) return;
done = true; cleanRecording();
done = true; cleanRecording();
- if (recorder && recorder.state !== 'inactive') recorder.stop();
+ if (recorder) { try { recorder.cancel(); } catch (e) { /* already stopped */ } recorder = null; }
stopTracks(stream); stream = null; clearBlob();
stopTracks(stream); stream = null; clearBlob();
win.document.removeEventListener('keydown', keydown, true);
win.document.removeEventListener('keydown', keydown, true);
host.remove(); active = null;
host.remove(); active = null;
if (lastFocus && lastFocus.focus) lastFocus.focus();
if (lastFocus && lastFocus.focus) lastFocus.focus();
if (error) reject(error); else resolve(result || null);
if (error) reject(error); else resolve(result || null);
}
}
+ /** What happened and what to tap, in OVCapture's plain words. */
+ function explained(err, mode) {
+ if (captureCore && typeof captureCore.explain === 'function') {
+ try {
+ var said = captureCore.explain(err, mode);
+ if (said && said.title) return [said.title].concat(said.steps || []).join(' ');
+ } catch (e) { /* fall back to the error's own words */ }
+ }
+ return (err && err.message) || 'This camera is unavailable.';
+ }
function permissionError(err) {
function permissionError(err) {
- var denied = err && (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError');
- say(denied ? 'Permission was not granted. You can try again.' : (err && err.message) || 'This camera is unavailable.');
+ say(explained(err, opts.mode === 'screen' ? 'screen' : opts.mode === 'photo' ? 'camera_only' : 'camera'));
phase = 'permission'; $('.permission').hidden = false;
phase = 'permission'; $('.permission').hidden = false;
}
}
function updateTorch() {
function updateTorch() {
@@ -148,9 +317,26 @@
@@ -148,9 +317,26 @@
if (caps.torch) $('.torch').setAttribute('aria-pressed', String(torch));
if (caps.torch) $('.torch').setAttribute('aria-pressed', String(torch));
}
}
async function start() {
async function start() {
- if (!supported(opts.mode, win.navigator)) { say('This browser cannot use the selected camera mode.'); return; }
try {
try {
- var next = opts.mode === 'screen' ? await win.navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }) : await core.open({ facing: facing, audio: opts.mode === 'video' });
+ captureCore = await loadCapture();
+ } catch (err) { permissionError(err); return; }
+ var can = typeof captureCore.can === 'function' ? captureCore.can() : null;
+ var allowed = can ? (opts.mode === 'screen' ? can.screen : can.camera && (opts.mode === 'photo' || can.record))
+ : supported(opts.mode, win.navigator);
+ if (!allowed) {
+ var noApi = new Error(opts.mode === 'screen' ? 'This browser has no screen sharing here' : 'This browser has no camera here');
+ noApi.name = 'NoApi';
+ permissionError(noApi);
+ return;
+ }
+ try {
+ if (!facingLoaded) {
+ var saved = captureCore.settings && typeof captureCore.settings.load === 'function' ? captureCore.settings.load() : {};
+ if (saved.facing === 'user' || saved.facing === 'environment') facing = saved.facing;
+ facingLoaded = true;
+ }
+ var next = opts.mode === 'screen' ? await captureCore.screen({ video: true, audio: true })
+ : await captureCore.open({ facing: facing, audio: opts.mode === 'video' });
if (done) { stopTracks(next); return; }
if (done) { stopTracks(next); return; }
stopTracks(stream); stream = next;
stopTracks(stream); stream = next;
preview.srcObject = stream; await preview.play();
preview.srcObject = stream; await preview.play();
@@ -187,34 +373,34 @@
@@ -187,34 +373,34 @@
$('.use').focus();
$('.use').focus();
}
}
async function capturePhoto() {
async function capturePhoto() {
- var canvas = win.document.createElement('canvas');
- canvas.width = preview.videoWidth || 1280; canvas.height = preview.videoHeight || 720;
- canvas.getContext('2d').drawImage(preview, 0, 0, canvas.width, canvas.height);
- var image = await new Promise(function (resolveImage) { canvas.toBlob(resolveImage, 'image/jpeg', 0.92); });
+ var settings = captureCore.settings && typeof captureCore.settings.load === 'function' ? captureCore.settings.load() : {};
+ var image = await captureCore.photo(stream, { mirror: !!settings.mirror && facing === 'user' });
if (!image) throw new Error('The photo could not be encoded.');
if (!image) throw new Error('The photo could not be encoded.');
showReview(image, 0);
showReview(image, 0);
}
}
function stopRecord() {
function stopRecord() {
if (phase !== 'recording' || !recorder) return;
if (phase !== 'recording' || !recorder) return;
- phase = 'stopping'; cleanRecording(); recorder.stop();
+ var stopped = recorder;
+ phase = 'stopping'; cleanRecording();
+ stopped.stop().then(function (recorded) {
+ if (recorder === stopped) recorder = null;
+ if (done || phase !== 'stopping') return;
+ if (!recorded || !recorded.size) { phase = 'ready'; say('No video was recorded. Try again.'); return; }
+ showReview(recorded, stopped.seconds());
+ }).catch(function (err) {
+ if (recorder === stopped) recorder = null;
+ if (done || phase !== 'stopping') return;
+ phase = 'ready';
+ say(explained(err, 'camera'));
+ });
}
}
function record() {
function record() {
- chunks = [];
- var type = mime(); recorder = new win.MediaRecorder(stream, type ? { mimeType: type } : undefined);
- recorder.ondataavailable = function (event) { if (event.data && event.data.size) chunks.push(event.data); };
- recorder.onerror = function () { say('Recording failed. Please retake.'); };
- recorder.onstop = function () {
- if (done || phase !== 'stopping') return;
- var length = Math.min(opts.maxSeconds, (Date.now() - started) / 1000);
- var recorded = new Blob(chunks, { type: recorder.mimeType || type || 'video/webm' });
- if (!recorded.size) { phase = 'ready'; say('No video was recorded. Try again.'); return; }
- showReview(recorded, length);
- };
- recorder.start(200); started = Date.now(); phase = 'recording';
+ recorder = captureCore.record(stream, { maxSeconds: opts.maxSeconds });
+ phase = 'recording';
$('.shutter').classList.add('recording');
$('.shutter').classList.add('recording');
$('.shutter').style.setProperty('--progress', '0%');
$('.shutter').style.setProperty('--progress', '0%');
tick = win.setInterval(function () {
tick = win.setInterval(function () {
- var elapsed = Math.min(opts.maxSeconds, (Date.now() - started) / 1000);
+ var elapsed = recorder ? recorder.seconds() : 0;
$('.clock').textContent = secondsText(elapsed);
$('.clock').textContent = secondsText(elapsed);
$('.shutter').style.setProperty('--progress', (100 * elapsed / opts.maxSeconds) + '%');
$('.shutter').style.setProperty('--progress', (100 * elapsed / opts.maxSeconds) + '%');
}, 100);
}, 100);
@@ -226,7 +412,7 @@
@@ -226,7 +412,7 @@
if (!(await countdown())) { if (timer) return; }
if (!(await countdown())) { if (timer) return; }
if (done || phase !== 'ready') return;
if (done || phase !== 'ready') return;
try { if (opts.mode === 'photo') await capturePhoto(); else record(); }
try { if (opts.mode === 'photo') await capturePhoto(); else record(); }
- catch (err) { phase = 'ready'; say(err.message || 'Capture failed.'); }
+ catch (err) { phase = 'ready'; say(explained(err, opts.mode === 'photo' ? 'camera_only' : 'camera')); }
}
}
function updateTrim() {
function updateTrim() {
var startInput = $('.trim-start'), endInput = $('.trim-end');
var startInput = $('.trim-start'), endInput = $('.trim-end');
@@ -269,7 +455,9 @@
@@ -269,7 +455,9 @@
$('.shutter').onclick = shutter;
$('.shutter').onclick = shutter;
$('.flip').onclick = async function () {
$('.flip').onclick = async function () {
if (phase !== 'ready') return;
if (phase !== 'ready') return;
- facing = facing === 'user' ? 'environment' : 'user'; torch = false; await start();
+ facing = facing === 'user' ? 'environment' : 'user'; torch = false;
+ if (captureCore && captureCore.settings) captureCore.settings.save({ facing: facing });
+ await start();
};
};
$('.torch').onclick = async function () {
$('.torch').onclick = async function () {
var track = stream && stream.getVideoTracks()[0]; if (!track) return;
var track = stream && stream.getVideoTracks()[0]; if (!track) return;
@@ -37,6 +37,157 @@
@@ -37,6 +37,157 @@
var UPLOADS = ['off', 'openvibe'];
var UPLOADS = ['off', 'openvibe'];
var DEFAULT_SRC = 'https://openvibe.cam/embed/ov-camera.js';
var DEFAULT_SRC = 'https://openvibe.cam/embed/ov-camera.js';
var SCRIPT_ORIGIN = null;
var SCRIPT_ORIGIN = null;
+ var CAPTURE_PATH = '/shared/ov-capture.js';
+ var capture = null;
+ var capturePending = null;
+ var remote = null;
+
+ // ── The shared capture core, from the embedding page's own origin (never another site). ──────────────────────
+ function captureSrc() {
+ try { return new URL(CAPTURE_PATH, root.location.href).href; } catch (e) { return ''; }
+ }
+
+ /** A chosen camera is that camera, and its side is exact when its id has gone stale (Live's setup). */
+ function install(core) {
+ if (!core || !core.strategies || typeof core.use !== 'function') return core;
+ if (!core.__ovCamExact && !core.__glExact) {
+ var before = core.use('constraints', function (o) {
+ var constraints = before(o || {});
+ o = o || {};
+ if (constraints.video && o.lens && o.lens !== 'main' && o.lens !== 'default' && o.lens !== '-') {
+ var rest = { ...constraints.video };
+ delete rest.facingMode;
+ delete rest.deviceId;
+ constraints.video = { ...rest, deviceId: { exact: o.lens } };
+ }
+ return constraints;
+ });
+ core.__ovCamExact = true;
+ }
+ var hasSide = core.strategies.list().some(function (s) { return s.name === 'side'; });
+ if (!hasSide) {
+ core.strategies.add({
+ name: 'side',
+ note: 'The chosen camera\'s side exactly (its id changed): the back camera stays the back camera.',
+ open: function (o, c) {
+ if (!o.facing || o.video === false || o.mode === 'mic_only') {
+ var e = new Error('No side to ask for');
+ e.name = 'NotFoundError';
+ throw e;
+ }
+ var height = Number(o.resolution) || 720;
+ return c.part('getUserMedia')({
+ video: { facingMode: { exact: o.facing }, width: { ideal: Math.round(height * 16 / 9) }, height: { ideal: height } },
+ audio: o.mode !== 'camera_only' && o.audio !== false,
+ });
+ },
+ }, { before: 'basic' });
+ }
+ return core;
+ }
+
+ function settingsUrl() {
+ try { return new URL('/api/v1/me/camera-settings', root.location.href).href; } catch (e) { return ''; }
+ }
+
+ function camStore() {
+ if (remote) return remote;
+ var url = settingsUrl();
+ if (!url || !root || typeof root.fetch !== 'function') return null;
+ var store = {
+ latest: null,
+ load: function () {
+ return root.fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json' } }).then(function (res) {
+ if (!res.ok) throw new Error('No camera settings here');
+ return res.json();
+ }).then(function (data) { store.latest = data; return data; });
+ },
+ save: function (values) {
+ return root.fetch(url, {
+ method: 'PUT', credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
+ body: JSON.stringify(values),
+ }).then(function (res) {
+ if (!res.ok) throw new Error('The camera settings were not saved');
+ return res.json();
+ });
+ },
+ };
+ remote = store;
+ return store;
+ }
+
+ /** Let facing, resolution and mirror follow the person; the lens is this device's own id, applied here. */
+ function syncSettings(core) {
+ if (!core || !core.settings || typeof core.settings.sync !== 'function' || typeof core.on !== 'function') return Promise.resolve(core);
+ var store = camStore();
+ if (!store) return Promise.resolve(core);
+ return new Promise(function (resolve) {
+ var finished = false, offSynced = null, offFailed = null, timer = null;
+ function finish() {
+ if (finished) return;
+ finished = true;
+ if (offSynced) offSynced();
+ if (offFailed) offFailed();
+ if (timer) root.clearTimeout(timer);
+ resolve(core);
+ }
+ offSynced = core.on('synced', function () {
+ if (store.latest && typeof store.latest.lens === 'string' && store.latest.lens) {
+ try { core.settings.save({ lens: store.latest.lens }); } catch (e) { /* this device keeps its own */ }
+ }
+ finish();
+ });
+ offFailed = core.on('sync-failed', finish);
+ timer = root.setTimeout(finish, 1500);
+ try { core.settings.sync(store); } catch (e) { finish(); }
+ });
+ }
+
+ function loadCapture() {
+ if (capturePending) return capturePending;
+ if (capture) return Promise.resolve(capture);
+ if (root && root.OVCapture && typeof root.OVCapture.open === 'function') {
+ capture = install(root.OVCapture);
+ capturePending = syncSettings(capture);
+ return capturePending;
+ }
+ var src = root ? captureSrc() : '';
+ if (!src || !root.document || !root.document.createElement) return Promise.reject(new Error('The camera needs this page\'s address.'));
+ capturePending = new Promise(function (resolve, reject) {
+ var tag = root.document.createElement('script');
+ tag.src = src;
+ tag.async = true;
+ tag.addEventListener('load', function () {
+ if (root.OVCapture && typeof root.OVCapture.open === 'function') {
+ capture = install(root.OVCapture);
+ resolve(capture);
+ } else {
+ if (tag.remove) tag.remove();
+ reject(new Error('The camera code loaded without OVCapture. Reload the page, then try again.'));
+ }
+ });
+ tag.addEventListener('error', function () {
+ if (tag.remove) tag.remove();
+ reject(new Error('The camera code did not load. Reload the page, then try again.'));
+ });
+ (root.document.head || root.document.documentElement).appendChild(tag);
+ }).then(syncSettings).catch(function (err) {
+ capturePending = null;
+ throw err;
+ });
+ return capturePending;
+ }
+
+ function explainedFailure(core, err, mode) {
+ if (core && typeof core.explain === 'function') {
+ try {
+ var said = core.explain(err, mode);
+ if (said && said.title) return [said.title].concat(said.steps || []).join(' ');
+ } catch (e) { /* fall back to the error's own words */ }
+ }
+ return (err && err.message) || 'The camera could not be started. Pick a picture instead.';
+ }
/** "none, mono, WARM" → ['none','mono','warm']; unknown names and duplicates are dropped, empty → ['none']. */
/** "none, mono, WARM" → ['none','mono','warm']; unknown names and duplicates are dropped, empty → ['none']. */
function parseFilters(value) {
function parseFilters(value) {
@@ -145,6 +296,26 @@
@@ -145,6 +296,26 @@
});
});
}
}
+ /** A captured picture through the chosen filter, so the file looks the way the preview did. */
+ function filterPhoto(blob, filter, width, height) {
+ if (!filter || filter === 'none' || !root || !root.document || !root.URL || !root.Image) return Promise.resolve(blob);
+ return new Promise(function (resolve, reject) {
+ var url = root.URL.createObjectURL(blob);
+ var img = new root.Image();
+ img.onload = function () {
+ var canvas = root.document.createElement('canvas');
+ try { drawTo(canvas, img, filter, width, height); }
+ catch (err) { root.URL.revokeObjectURL(url); reject(err); return; }
+ toBlob(canvas, 'image/jpeg', 0.92).then(function (out) {
+ root.URL.revokeObjectURL(url);
+ resolve(out);
+ }, function (err) { root.URL.revokeObjectURL(url); reject(err); });
+ };
+ img.onerror = function () { root.URL.revokeObjectURL(url); reject(new Error('The picture could not be read.')); };
+ img.src = url;
+ });
+ }
+
var ICONS = {
var ICONS = {
timer: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="13.5" r="7"/><path d="M12 13.5V10M9.5 3.5h5"/></svg>',
timer: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="13.5" r="7"/><path d="M12 13.5V10M9.5 3.5h5"/></svg>',
flip: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 9a8 8 0 0 1 14.3-3.3L20 7.5M20 3.5v4h-4M20 15a8 8 0 0 1-14.3 3.3L4 16.5M4 20.5v-4h4"/></svg>',
flip: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 9a8 8 0 0 1 14.3-3.3L20 7.5M20 3.5v4h-4M20 15a8 8 0 0 1-14.3 3.3L4 16.5M4 20.5v-4h4"/></svg>',
@@ -292,20 +463,24 @@
@@ -292,20 +463,24 @@
connectedCallback() {
connectedCallback() {
if (!this._camera) return;
if (!this._camera) return;
+ this._explicitFacing = this.hasAttribute('facing');
this._applyAttributes();
this._applyAttributes();
this._camera.setAttribute('data-state', 'off');
this._camera.setAttribute('data-state', 'off');
this._camera.setAttribute('data-mode', this._config.mode);
this._camera.setAttribute('data-mode', this._config.mode);
this._camera.setAttribute('data-facing', this._config.facing);
this._camera.setAttribute('data-facing', this._config.facing);
+ this._fromCapture = true;
this.setAttribute('facing', this._config.facing);
this.setAttribute('facing', this._config.facing);
+ this._fromCapture = false;
}
}
disconnectedCallback() { this.stop(); }
disconnectedCallback() { this.stop(); }
attributeChangedCallback(name, oldValue, newValue) {
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue || !this._camera) return;
if (oldValue === newValue || !this._camera) return;
+ if (name === 'facing' && !this._fromCapture) this._explicitFacing = true;
var wasFacing = this._config.facing;
var wasFacing = this._config.facing;
this._applyAttributes();
this._applyAttributes();
- if (name === 'facing' && this._stream && wasFacing !== this._config.facing) {
+ if (name === 'facing' && !this._fromCapture && this._stream && wasFacing !== this._config.facing) {
var self = this;
var self = this;
this.stop();
this.stop();
this.start().catch(function () { /* start() already dispatched the error */ });
this.start().catch(function () { /* start() already dispatched the error */ });
@@ -318,25 +493,29 @@
@@ -318,25 +493,29 @@
var self = this;
var self = this;
if (this._stream) return Promise.resolve();
if (this._stream) return Promise.resolve();
if (this._starting) return this._starting;
if (this._starting) return this._starting;
- if (!root.navigator || !root.navigator.mediaDevices || !root.navigator.mediaDevices.getUserMedia) {
- this._say('This browser has no camera input. Pick a picture instead.');
- this._error('camera_unavailable', 'This browser has no camera input. Pick a picture instead.');
- return Promise.resolve();
- }
this._say('Turning the camera on…');
this._say('Turning the camera on…');
this._camera.setAttribute('data-state', 'starting');
this._camera.setAttribute('data-state', 'starting');
var seq = (this._startSeq || 0) + 1;
var seq = (this._startSeq || 0) + 1;
this._startSeq = seq;
this._startSeq = seq;
- this._starting = root.navigator.mediaDevices.getUserMedia({
- video: { facingMode: { ideal: this._config.facing }, width: { ideal: 1920 }, height: { ideal: 1440 } },
- audio: false
+ this._starting = loadCapture().then(function (core) {
+ self._capture = core;
+ var options = { mode: 'camera_only', video: true, audio: false };
+ if (self._explicitFacing) options.facing = self._config.facing;
+ return core.open(options);
}).then(function (stream) {
}).then(function (stream) {
if (seq !== self._startSeq) { stream.getTracks().forEach(function (t) { t.stop(); }); return false; }
if (seq !== self._startSeq) { stream.getTracks().forEach(function (t) { t.stop(); }); return false; }
self._stream = stream;
self._stream = stream;
self._video.srcObject = stream;
self._video.srcObject = stream;
var track = stream.getVideoTracks()[0];
var track = stream.getVideoTracks()[0];
var settings = track && track.getSettings ? track.getSettings() : {};
var settings = track && track.getSettings ? track.getSettings() : {};
- self._camera.setAttribute('data-facing', settings.facingMode || (self._config.facing === 'user' ? 'user' : 'environment'));
+ var saved = !self._explicitFacing && self._capture && self._capture.settings ? self._capture.settings.load() : {};
+ var actual = settings.facingMode || saved.facing;
+ self._camera.setAttribute('data-facing', actual || (self._config.facing === 'user' ? 'user' : 'environment'));
+ if (actual && actual !== self._config.facing && !self._explicitFacing) {
+ self._fromCapture = true;
+ self.setAttribute('facing', actual);
+ self._fromCapture = false;
+ }
return self._video.play().catch(function () { /* muted autoplay is allowed; a blocked play is not fatal */ }).then(function () { return true; });
return self._video.play().catch(function () { /* muted autoplay is allowed; a blocked play is not fatal */ }).then(function () { return true; });
}).then(function (ok) {
}).then(function (ok) {
if (ok === false || seq !== self._startSeq) return;
if (ok === false || seq !== self._startSeq) return;
@@ -347,9 +526,7 @@
@@ -347,9 +526,7 @@
if (seq !== self._startSeq) return;
if (seq !== self._startSeq) return;
self._camera.setAttribute('data-state', 'off');
self._camera.setAttribute('data-state', 'off');
var code = err && (err.name === 'NotAllowedError' || err.name === 'SecurityError') ? 'permission_denied' : 'camera_unavailable';
var code = err && (err.name === 'NotAllowedError' || err.name === 'SecurityError') ? 'permission_denied' : 'camera_unavailable';
- var message = code === 'permission_denied'
- ? 'The camera was not allowed. Allow it in the browser, or pick a picture.'
- : 'The camera could not be started. Pick a picture instead.';
+ var message = explainedFailure(self._capture, err, 'camera_only');
self._say(message);
self._say(message);
self._error(code, message);
self._error(code, message);
}).then(function () { self._starting = null; });
}).then(function () { self._starting = null; });
@@ -408,7 +585,11 @@
@@ -408,7 +585,11 @@
/** Switch front/back; if the camera is on it restarts on the other one. Resolves to the new facing. */
/** Switch front/back; if the camera is on it restarts on the other one. Resolves to the new facing. */
flip() {
flip() {
var next = this._config.facing === 'user' ? 'environment' : 'user';
var next = this._config.facing === 'user' ? 'environment' : 'user';
+ this._explicitFacing = true;
this.setAttribute('facing', next);
this.setAttribute('facing', next);
+ loadCapture().then(function (core) {
+ if (core.settings) core.settings.save({ facing: next });
+ }).catch(function () { /* the attribute still flips this camera */ });
return Promise.resolve(next);
return Promise.resolve(next);
}
}
@@ -496,22 +677,30 @@
@@ -496,22 +677,30 @@
_snap() {
_snap() {
var self = this;
var self = this;
- var size;
- try { size = drawTo(this._canvas, this._video, this._selectedFilter); }
- catch (err) { this._error('capture_failed', err.message); return Promise.resolve(null); }
+ var size = {
+ width: this._video.videoWidth || 1280,
+ height: this._video.videoHeight || 720,
+ };
if (this._shootBtn) {
if (this._shootBtn) {
this._shootBtn.classList.add('is-firing');
this._shootBtn.classList.add('is-firing');
setTimeout(function () { if (self._shootBtn) self._shootBtn.classList.remove('is-firing'); }, 160);
setTimeout(function () { if (self._shootBtn) self._shootBtn.classList.remove('is-firing'); }, 160);
}
}
if (root.navigator && root.navigator.vibrate) { try { root.navigator.vibrate(12); } catch (e) { /* not every browser */ } }
if (root.navigator && root.navigator.vibrate) { try { root.navigator.vibrate(12); } catch (e) { /* not every browser */ } }
- return toBlob(this._canvas, 'image/jpeg', 0.92).then(function (blob) {
+ return loadCapture().then(function (core) {
+ self._capture = core;
+ var settings = core.settings && typeof core.settings.load === 'function' ? core.settings.load() : {};
+ var mirror = !!settings.mirror && self._config.facing === 'user';
+ return core.photo(self._stream, { mirror: mirror });
+ }).then(function (blob) {
+ return filterPhoto(blob, self._selectedFilter, size.width, size.height);
+ }).then(function (blob) {
var detail = { blob: blob, width: size.width, height: size.height, filter: self._selectedFilter };
var detail = { blob: blob, width: size.width, height: size.height, filter: self._selectedFilter };
self._showThumb(blob);
self._showThumb(blob);
self.dispatchEvent(new root.CustomEvent('ov-camera:capture', { detail: detail, bubbles: true, composed: true }));
self.dispatchEvent(new root.CustomEvent('ov-camera:capture', { detail: detail, bubbles: true, composed: true }));
if (self._config.upload === 'openvibe') { self._upload(detail); }
if (self._config.upload === 'openvibe') { self._upload(detail); }
return detail;
return detail;
}).catch(function (err) {
}).catch(function (err) {
- self._error('capture_failed', err.message);
+ self._error('capture_failed', explainedFailure(self._capture, err, 'camera_only'));
return null;
return null;
});
});
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,127 @@
@@ -0,0 +1,127 @@
+'use strict';
+/**
+ * The kit in a real browser: OVCapture comes from the page's own /shared/ov-capture.js, and the person's saved
+ * camera choices reach navigator.mediaDevices.getUserMedia as exact constraints — Front/Back as facingMode exact
+ * when the "ideal" ask is refused, a chosen lens as deviceId exact. Cam's settings API is a small local fixture.
+ *
+ * The browser is the workstation's Chrome; where it is missing (a CI image without Chrome) the test skips.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const os = require('os');
+const path = require('path');
+const { execFile } = require('child_process');
+
+const ROOT = path.join(__dirname, '..');
+const CHROME = process.env.CHROME_PATH || '/usr/bin/google-chrome';
+const KIT = path.join(ROOT, 'public/embed/cam-kit.js');
+const CAPTURE = path.join(ROOT, 'node_modules/openvibe-shared/ov-capture.js');
+
+function page(settings, failIdeal) {
+ return `<!doctype html><meta charset="utf-8"><title>cam-kit</title>
+<script>
+window.__calls = [];
+Object.defineProperty(navigator.mediaDevices, 'getUserMedia', { configurable: true, value: async function (constraints) {
+ window.__calls.push(JSON.parse(JSON.stringify(constraints || {})));
+ if (${failIdeal ? 'true' : 'false'} && constraints && constraints.video && constraints.video.facingMode && constraints.video.facingMode.ideal) {
+ var e = new Error('the ideal ask was refused'); e.name = 'OverconstrainedError'; throw e;
+ }
+ var track = { stop: function () {}, addEventListener: function () {}, getSettings: function () { return { facingMode: 'environment' }; },
+ getCapabilities: function () { return {}; }, applyConstraints: function () { return Promise.resolve(); } };
+ return { getVideoTracks: function () { return [track]; }, getAudioTracks: function () { return []; }, getTracks: function () { return [track]; } };
+} });
+</script>
+<script src="/embed/cam-kit.js"></script>
+<script>
+(async function () {
+ try { await OpenVibeCam.core.open({ mode: 'camera_only' }); }
+ catch (e) { window.__error = String((e && e.message) || e); }
+ document.body.setAttribute('data-calls', btoa(JSON.stringify(window.__calls)));
+ document.body.setAttribute('data-error', btoa(window.__error || ''));
+})();
+</script>
+<body></body>`;
+}
+
+function serve(settings) {
+ return new Promise((resolve) => {
+ const server = http.createServer((req, res) => {
+ const url = new URL(req.url, 'http://127.0.0.1');
+ if (url.pathname === '/embed/cam-kit.js') {
+ res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
+ return fs.createReadStream(KIT).pipe(res);
+ }
+ if (url.pathname === '/shared/ov-capture.js') {
+ res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
+ return fs.createReadStream(CAPTURE).pipe(res);
+ }
+ if (url.pathname === '/api/v1/me/camera-settings') {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify(settings));
+ }
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
+ res.end(page(settings, url.searchParams.get('ideal') === 'fail'));
+ });
+ server.listen(0, '127.0.0.1', () => resolve(server));
+ });
+}
+
+function chrome(url) {
+ return new Promise((resolve, reject) => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cam-kit-chrome-'));
+ execFile(CHROME, [
+ '--headless=new', '--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage',
+ `--user-data-dir=${dir}`, '--virtual-time-budget=6000', '--dump-dom', url,
+ ], { timeout: 30000, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* a temp profile */ }
+ if (err) return reject(Object.assign(err, { stderr }));
+ resolve(stdout);
+ });
+ });
+}
+
+function callsOf(dom) {
+ const match = /data-calls="([^"]*)"/.exec(dom);
+ assert(match, 'the page reported its getUserMedia calls');
+ return JSON.parse(Buffer.from(match[1], 'base64').toString('utf8'));
+}
+
+(async () => {
+ if (!fs.existsSync(CHROME)) { console.log(` skip ${CHROME} is not installed`); return; }
+ assert(fs.existsSync(CAPTURE), 'the pinned openvibe-shared serves ov-capture.js');
+ const cases = [
+ {
+ name: 'Front/Back reaches getUserMedia as facingMode exact',
+ settings: { facing: 'environment', lens: 'main', resolution: '1080', mirror: false, updated_at: null },
+ ideal: 'fail',
+ check(calls) { return calls.some((c) => c.video && c.video.facingMode && c.video.facingMode.exact === 'environment'); },
+ },
+ {
+ name: 'a chosen lens reaches getUserMedia as deviceId exact',
+ settings: { facing: 'environment', lens: 'cam-back-2', resolution: '1080', mirror: false, updated_at: null },
+ ideal: 'ok',
+ check(calls) { return calls.some((c) => c.video && c.video.deviceId && c.video.deviceId.exact === 'cam-back-2'); },
+ },
+ ];
+ for (const test of cases) {
+ const server = await serve(test.settings);
+ try {
+ const base = `http://127.0.0.1:${server.address().port}/?ideal=${test.ideal}`;
+ const dom = await chrome(base);
+ const error = /data-error="([^"]*)"/.exec(dom);
+ const said = error ? Buffer.from(error[1], 'base64').toString('utf8') : '';
+ assert.strictEqual(said, '', `${test.name}: ${said}`);
+ assert(test.check(callsOf(dom)), test.name);
+ console.log(` ok ${test.name}`);
+ } finally {
+ await new Promise((resolve) => server.close(resolve));
+ }
+ }
+ console.log('\nall passed\n');
+})().catch((err) => {
+ if (err && err.code === 'ENOENT') { console.log(` skip ${CHROME} could not start`); return; }
+ console.error(err && err.stack ? err.stack : err);
+ if (err && err.stderr) console.error(String(err.stderr).slice(-2000));
+ process.exitCode = 1;
+});
@@ -16,4 +16,67 @@ assert.strictEqual(window.OpenVibeCam.core.constraints({ facing: 'environment',
@@ -16,4 +16,67 @@ assert.strictEqual(window.OpenVibeCam.core.constraints({ facing: 'environment',
assert(Object.isFrozen(window.OpenVibeCam));
assert(Object.isFrozen(window.OpenVibeCam));
assert.throws(() => window.OpenVibeCam.open({ mode: 'invalid' }), /mode must be/);
assert.throws(() => window.OpenVibeCam.open({ mode: 'invalid' }), /mode must be/);
assert(!/\brequire\(|\bimport\s/.test(source.replace(/\/\*[\s\S]*?\*\//g, '')));
assert(!/\brequire\(|\bimport\s/.test(source.replace(/\/\*[\s\S]*?\*\//g, '')));
+assert(!/mediaDevices\.getUserMedia\s*\(/.test(source), 'the kit opens cameras through OVCapture, never its own getUserMedia');
+assert(!/mediaDevices\.getDisplayMedia\s*\(/.test(source), 'the kit opens screens through OVCapture, never its own getDisplayMedia');
console.log('cam-kit API shape and standalone script: ok');
console.log('cam-kit API shape and standalone script: ok');
+
+// ── The kit against a fake OVCapture: the same public API, all opens through the core. ──────────────────────────
+(async () => {
+ const calls = { open: [], save: [], use: [], add: [] };
+ const parts = {
+ constraints: (o) => {
+ o = o || {};
+ const height = Number(o.resolution) || 720;
+ return {
+ video: {
+ facingMode: { ideal: o.facing || 'user' },
+ ...(o.lens && o.lens !== 'main' && o.lens !== 'default' ? { deviceId: { ideal: o.lens } } : {}),
+ width: { ideal: Math.round(height * 16 / 9) }, height: { ideal: height },
+ },
+ audio: o.audio === false ? false : true,
+ };
+ },
+ getUserMedia: (constraints) => { calls.gum = constraints; return Promise.resolve({ getTracks: () => [] }); },
+ };
+ const strategies = [];
+ const fake = {
+ version: 2,
+ open: (o) => { calls.open.push(o); return Promise.resolve({ getTracks: () => [], getVideoTracks: () => [], getAudioTracks: () => [] }); },
+ devices: () => Promise.resolve([{ kind: 'videoinput', deviceId: 'cam-9' }]),
+ meter: () => () => {},
+ network: () => 'good',
+ part: (name) => parts[name],
+ use: (name, impl) => { calls.use.push(name); const before = parts[name]; parts[name] = impl; return before; },
+ strategies: {
+ list: () => strategies.map((s) => ({ name: s.name })),
+ add: (strategy) => { calls.add.push(strategy.name); strategies.push(strategy); },
+ },
+ settings: {
+ load: () => ({ facing: 'environment', lens: 'applied-lens', resolution: '1080', mirror: false }),
+ save: (values) => { calls.save.push(values); return values; },
+ },
+ on: () => () => {},
+ explain: (err, mode) => ({ title: `explained ${mode}`, steps: ['Tap Allow.', 'Try again.'] }),
+ };
+ const win = {
+ document: { currentScript: { src: 'https://openvibe.cam/embed/cam-kit.js' } },
+ navigator: { mediaDevices: { getUserMedia: () => { throw new Error('the kit must not call getUserMedia itself'); } } },
+ location: { href: 'https://openvibe.network/page' },
+ OVCapture: fake,
+ };
+ vm.runInNewContext(source, { window: win, URL, Blob, File, Promise, Number, String, Date, setTimeout, clearTimeout, setInterval, clearInterval });
+ const kit = win.OpenVibeCam;
+ assert.strictEqual(typeof kit.open, 'function');
+ assert.strictEqual(typeof kit.core.open, 'function');
+ assert.deepStrictEqual(parts.constraints({ lens: 'cam-9' }).video.deviceId, { ideal: 'cam-9' });
+ assert.deepStrictEqual(await kit.core.devices(), [{ kind: 'videoinput', deviceId: 'cam-9' }]);
+ await kit.core.open({ mode: 'video', facing: 'environment' });
+ assert.deepStrictEqual(calls.open, [{ mode: 'video', facing: 'environment' }], 'the open is OVCapture.open');
+ assert(calls.use.includes('constraints'), 'the chosen lens becomes an exact constraint');
+ assert.strictEqual(parts.constraints({ lens: 'cam-9' }).video.deviceId.exact, 'cam-9');
+ assert.deepStrictEqual(calls.add, ['side'], 'the exact-side strategy is added');
+ assert.strictEqual(strategies[0].name, 'side');
+ await strategies[0].open({ facing: 'environment', mode: 'camera_only' }, fake);
+ assert.strictEqual(calls.gum.video.facingMode.exact, 'environment', 'the side is asked for exactly');
+ console.log('cam-kit fake OVCapture: all opens through the shared core');
+})().catch((err) => { console.error(err && err.stack ? err.stack : err); process.exitCode = 1; });
@@ -18,7 +18,11 @@ check('the element is framework-free and Node can require it without a DOM', ()
@@ -18,7 +18,11 @@ check('the element is framework-free and Node can require it without a DOM', ()
assert.strictEqual(typeof core.parseAttrs, 'function');
assert.strictEqual(typeof core.parseAttrs, 'function');
assert.strictEqual(typeof core.drawTo, 'function');
assert.strictEqual(typeof core.drawTo, 'function');
assert.strictEqual(typeof core.define, 'function');
assert.strictEqual(typeof core.define, 'function');
- assert.ok(!/require\(|import\s/.test(require('fs').readFileSync(require.resolve('../public/embed/ov-camera.js'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')), 'no bundler or runtime import');
+ const source = require('fs').readFileSync(require.resolve('../public/embed/ov-camera.js'), 'utf8');
+ assert.ok(!/require\(|import\s/.test(source.replace(/\/\*[\s\S]*?\*\//g, '')), 'no bundler or runtime import');
+ assert.ok(source.includes("CAPTURE_PATH = '/shared/ov-capture.js'"), 'it loads the core from the page\'s own /shared');
+ assert.ok(!/navigator\.mediaDevices\.getUserMedia\s*\(/.test(source), 'the element opens cameras through OVCapture, never its own getUserMedia');
+ assert.ok(!/navigator\.mediaDevices\.getDisplayMedia\s*\(/.test(source));
});
});
check('the filter table is exactly the API X-Filter list', () => {
check('the filter table is exactly the API X-Filter list', () => {
@@ -148,6 +148,11 @@ function jsonLd(html) {
@@ -148,6 +148,11 @@ function jsonLd(html) {
const kit = await t.get('/embed/cam-kit.js');
const kit = await t.get('/embed/cam-kit.js');
assert.strictEqual(kit.status, 200);
assert.strictEqual(kit.status, 200);
assert.strictEqual(kit.headers.get('access-control-allow-origin'), '*');
assert.strictEqual(kit.headers.get('access-control-allow-origin'), '*');
+ const capture = await t.get('/shared/ov-capture.js');
+ assert.strictEqual(capture.status, 200, 'the kit and <ov-camera> load the shared core from their own origin');
+ assert.match(capture.headers.get('content-type'), /javascript/);
+ assert.ok(capture.text.includes('OVCapture'), 'it is the OVCapture core');
+ assert.strictEqual(capture.headers.get('access-control-allow-origin'), '*');
assert.ok(embed.text.includes("define('ov-camera'"), 'the embed registers the element');
assert.ok(embed.text.includes("define('ov-camera'"), 'the embed registers the element');
assert.ok(embed.text.includes('ov-camera:capture'), 'the embed emits the capture event');
assert.ok(embed.text.includes('ov-camera:capture'), 'the embed emits the capture event');
const plainEmbed = await t.get('/embed/ov-camera.js');
const plainEmbed = await t.get('/embed/ov-camera.js');