openvibers/openvibe.cam Commits

Cam's kit and <ov-camera> on OVCapture v2 (#157)

OP OpenVibers committed 1 days ago · 159b0559e1 · 8 files, +661 −85

The kit and the element load the shared capture core from the page's own /shared/ov-capture.js and open the camera, screen, photo and recording through it. The person's facing, lens, resolution and mirror come from Cam's camera settings through OVCapture.settings.sync; a chosen lens is an exact deviceId and Front/Back is an exact facingMode when the preferred ask fails. Failures use OVCapture.explain. The kit's public API is unchanged. openvibe-shared is pinned at 4.216.0, the release that serves ov-capture.js.

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 193aa17..1b6b502 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 6ac37c5..4d2cfc3 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/public/embed/cam-kit.js b/public/embed/cam-kit.js
iindex d6a399f..3494eee 100644
---- a/public/embed/cam-kit.js
++++ b/public/embed/cam-kit.js
@@@ -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;
ddiff --git a/public/embed/ov-camera.js b/public/embed/ov-camera.js
iindex 242ccb9..0ef915f 100644
---- a/public/embed/ov-camera.js
++++ b/public/embed/ov-camera.js
@@@ -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;
});
}
ddiff --git a/test/cam-kit-browser.test.js b/test/cam-kit-browser.test.js
nnew file mode 100644
iindex 0000000..6ca81d2
---- /dev/null
++++ b/test/cam-kit-browser.test.js
@@@ -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;
++});
ddiff --git a/test/cam-kit.test.js b/test/cam-kit.test.js
iindex 4a0abed..879e8bb 100644
---- a/test/cam-kit.test.js
++++ b/test/cam-kit.test.js
@@@ -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; });
ddiff --git a/test/camera-element.test.js b/test/camera-element.test.js
iindex 4c55323..344fbd1 100644
---- a/test/camera-element.test.js
++++ b/test/camera-element.test.js
@@@ -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', () => {
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex 3a69b8d..cad75c6 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -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');