openvibers/openvibe.cam Commits

Share camera core and save per-person settings (#116)

OP OpenVibers committed 4 days ago · a945e78e5f · 8 files, +141 −2

* Add shared camera core and per-person settings API

* Merge remote-tracking branch 'origin/main' into feat/cam-settings-1109

* Use required lowercase migration phase header

Changes

ddiff --git a/migrations/0003_camera_settings.sql b/migrations/0003_camera_settings.sql
nnew file mode 100644
iindex 0000000..4c840fc
---- /dev/null
++++ b/migrations/0003_camera_settings.sql
@@@ -0,0 +1,12 @@
++-- phase: expand
++-- Per-person browser camera choices shared by Cam, Live, Video and Codes.
++-- Device ids are browser-scoped hints; callers fall back when a device is absent.
++CREATE TABLE cam_camera_settings (
++ owner text COLLATE "C" PRIMARY KEY CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ facing text NOT NULL DEFAULT 'user' CHECK (facing IN ('user', 'environment')),
++ lens text NOT NULL DEFAULT 'main' CHECK (length(lens) BETWEEN 1 AND 120),
++ mic text NOT NULL DEFAULT 'default' CHECK (length(mic) BETWEEN 1 AND 120),
++ resolution text NOT NULL DEFAULT '720' CHECK (resolution IN ('480', '720', '1080')),
++ mirror boolean NOT NULL DEFAULT true,
++ updated_at timestamptz NOT NULL DEFAULT now()
++);
ddiff --git a/public/embed/cam-kit.js b/public/embed/cam-kit.js
iindex a0d68bc..d6a399f 100644
---- a/public/embed/cam-kit.js
++++ b/public/embed/cam-kit.js
@@@ -8,6 +8,51 @@
var active = null;
var MODES = ['video', 'photo', 'screen'];
++ // 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(); },
++ 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,
++ };
++ },
++ open: function (o) { return win.navigator.mediaDevices.getUserMedia(core.constraints(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';
++ },
++ 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 () {}); };
++ },
++ });
++
function optionsFor(input) {
var o = input || {};
if (MODES.indexOf(o.mode || 'video') < 0) throw new TypeError('mode must be video, photo or screen');
@@@ -105,7 +150,7 @@
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 win.navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: facing } }, audio: opts.mode === 'video' });
++ var next = opts.mode === 'screen' ? await win.navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }) : await core.open({ facing: facing, audio: opts.mode === 'video' });
if (done) { stopTracks(next); return; }
stopTracks(stream); stream = next;
preview.srcObject = stream; await preview.play();
@@@ -255,6 +300,6 @@
});
}
-- win.OpenVibeCam = Object.freeze({ open: open });
++ win.OpenVibeCam = Object.freeze({ open: open, core: core });
if (typeof module === 'object' && module.exports) module.exports = { optionsFor: optionsFor, supported: supported, secondsText: secondsText };
})(typeof window !== 'undefined' ? window : null);
ddiff --git a/server/http/api.js b/server/http/api.js
iindex f94231b..37e3abd 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -15,6 +15,8 @@
* GET /cameras/:id/grants owner
* POST /cameras/:id/grants owner: @username or subject, and days
* DELETE /cameras/:id/grants/:grantId owner: revoke at once
++ * GET /me/camera-settings your camera choices on every site
++ * PUT /me/camera-settings save facing, lens, mic, resolution and mirror
* GET /live who is live on OpenVibe.Live now (the camera app's Live mode)
* POST /stills the Live cam button: fetch Media's frame, save to Pics
* GET /stills your stills
@@@ -37,6 +39,19 @@ function createApi(ctx) {
const r = asyncRouter();
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
++ // A site can call settings with a person's Bearer token; browser sessions stay same-origin.
++ r.use((req, res, next) => {
++ if (req.path !== '/me/camera-settings') return next();
++ const origin = req.get('origin');
++ if (['https://openvibe.cam', 'https://openvibe.live', 'https://openvibe.video', 'https://openvibe.codes'].includes(origin)) {
++ res.set('Access-Control-Allow-Origin', origin);
++ res.set('Vary', 'Origin');
++ res.set('Access-Control-Allow-Methods', 'GET, PUT, OPTIONS');
++ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type');
++ if (req.method === 'OPTIONS') return res.sendStatus(204);
++ }
++ return next();
++ });
r.use(principal.middleware);
// Per-actor limits, on top of the per-address limit in server/app.js: a signed-in person gets the minute and
@@@ -99,6 +114,13 @@ function createApi(ctx) {
return jsonBody(req, res, next);
});
++ r.get('/me/camera-settings', requirePerson, async (req, res) => res.json(await cameras.cameraSettings(req.principal.subject)));
++ r.put('/me/camera-settings', requirePerson, async (req, res) => {
++ const out = await cameras.putCameraSettings(req.principal.subject, req.body);
++ if (!out.ok) return problem(res, out.status, out.code, out.message);
++ return res.json(out.settings);
++ });
++
// ── Cameras ─────────────────────────────────────────────
r.get('/cameras', requirePerson, async (req, res) => {
const list = await cameras.camerasOf(req.principal.subject);
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 309b31f..579d797 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -12,6 +12,7 @@
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const TABLES = [
++ { table: 'cam_camera_settings', subject: 'owner', file: 'camera-settings.json', erase: 'delete' },
// F1's timeline and presence are the owner's. Shares and events go before the camera they hang under.
{ table: 'cam_event_shares', subject: 'owner', file: 'event-shares.json', erase: 'delete' },
{ table: 'cam_events', subject: 'owner', file: 'events.json', erase: 'delete' },
ddiff --git a/server/store.js b/server/store.js
iindex 33e1f09..5efca06 100644
---- a/server/store.js
++++ b/server/store.js
@@@ -180,6 +180,27 @@ function createStore(s, { config }) {
const clock = () => s.iso();
const daysFromNow = (days) => new Date(s.now() + Number(days) * 86_400_000).toISOString();
++ const defaultCameraSettings = Object.freeze({ facing: 'user', lens: 'main', mic: 'default', resolution: '720', mirror: true });
++ const cameraSettings = async (owner) => {
++ const row = await db.maybe('SELECT facing, lens, mic, resolution, mirror, updated_at FROM cam_camera_settings WHERE owner = $1', [owner]);
++ return row ? { facing: row.facing, lens: row.lens, mic: row.mic, resolution: row.resolution, mirror: row.mirror, updated_at: iso(row.updated_at) }
++ : { ...defaultCameraSettings, updated_at: null };
++ };
++ const putCameraSettings = async (owner, body) => {
++ if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some((key) => !['facing', 'lens', 'mic', 'resolution', 'mirror'].includes(key)))
++ return refusal(400, 'cam.invalid', 'Only facing, lens, mic, resolution and mirror may be set.');
++ const next = { ...await cameraSettings(owner), ...body };
++ if (!['user', 'environment'].includes(next.facing) || !['480', '720', '1080'].includes(next.resolution) || typeof next.mirror !== 'boolean'
++ || ![next.lens, next.mic].every((v) => typeof v === 'string' && v.length > 0 && v.length <= 120 && !controlChars(v)))
++ return refusal(400, 'cam.invalid', 'Camera settings contain an invalid value.');
++ await db.exec(`INSERT INTO cam_camera_settings (owner, facing, lens, mic, resolution, mirror, updated_at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7::timestamptz)
++ ON CONFLICT (owner) DO UPDATE SET facing = EXCLUDED.facing, lens = EXCLUDED.lens, mic = EXCLUDED.mic,
++ resolution = EXCLUDED.resolution, mirror = EXCLUDED.mirror, updated_at = EXCLUDED.updated_at`,
++ [owner, next.facing, next.lens, next.mic, next.resolution, next.mirror, clock()]);
++ return { ok: true, settings: await cameraSettings(owner) };
++ };
++
async function cameraById(id, t) {
return await q(t).maybe('SELECT * FROM cam_cameras WHERE id = $1', [String(id || '')]);
}
@@@ -553,6 +574,7 @@ function createStore(s, { config }) {
}
return {
++ cameraSettings, putCameraSettings,
wireCamera, wireGrant, wirePhoto, wireStill,
cameraById, camerasOf, createCamera, patchCamera, removeCamera, setFamiliarFaces, presenceOf, setPresence,
grantCamera, grantsOf, revokeGrant, activeGrant, accessTo, wall,
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 3794b96..6496fc2 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -385,6 +385,7 @@ function developers({ base = 'https://openvibe.cam' } = {}) {
<section id="cm-kit"><h2>OpenVibeCam: record on your site</h2>
<p class="cm-fine">One script opens Cam on the calling page. On a phone it fills the screen; on a computer it opens a centred stage. The person sees a plain permission prompt, records, reviews, trims and chooses <em>Use this</em>. Cam hands your page a <code>File</code> and metadata; your page decides where to upload it. Cancelling resolves to <code>null</code>.</p>
++<p class="cm-fine">For an in-place studio, the same SRI-pinned script exposes <code>OpenVibeCam.core</code>: <code>devices()</code>, <code>constraints({ facing, lens, mic, resolution })</code>, <code>open(options)</code>, <code>meter(stream, onLevel)</code>, <code>network()</code> and <code>stop(stream)</code>. Only <code>open()</code> asks for camera or microphone access; call it after the person's explicit action. Saved lens and mic ids are hints on another browser and fall back to its available devices.</p>
<pre class="cm-code"><code>${esc(kitSnippet)}</code></pre>
<p class="cm-fine"><code>OpenVibeCam.open({ mode: 'video' | 'photo' | 'screen', maxSeconds, timer, onDone })</code> resolves to <code>{ file, mode, mimeType, duration, width, height, maxSeconds }</code>. <code>onDone(result)</code> runs when Use this is pressed and may return a promise. <code>mode</code> defaults to <code>video</code>, <code>maxSeconds</code> defaults to 60, and <code>timer</code> is 0, 3 or 10 seconds. The screen option appears when the browser supports screen capture. Space starts or stops recording; Esc closes. Photos and recordings stay in the browser until your page handles the file. To upload, pass <code>result.file</code> to your site's uploader; use ov-upload's resumable path when it is available.</p>
<button class="cm-btn" id="cm-kit-demo" type="button">Try the recorder</button><p class="cm-fine" id="cm-kit-status" role="status">The demo keeps the file on this page.</p>
@@@ -437,6 +438,8 @@ ${ep('GET', '/api/v1/photos/:id', 'One of your photos.')}
</dl></section>
<section><h2>My cameras and viewer grants</h2><dl class="cm-eps">
++${ep('GET', '/api/v1/me/camera-settings', 'Your saved facing, lens, mic, resolution and mirror choices. Defaults are returned before you save.')}
++${ep('PUT', '/api/v1/me/camera-settings', '<code>{ facing?, lens?, mic?, resolution?, mirror? }</code>. Partial updates preserve your other choices; a signed-in person only.')}
${ep('GET', '/api/v1/cameras', 'Your paired cameras with their active grant count.')}
${ep('POST', '/api/v1/cameras', '<code>{ name, source, stream_id?, slot?, notes? }</code>. A stream id is read from Live and must be on your channel.')}
${ep('PATCH', '/api/v1/cameras/:id', '<code>{ name?, source?, status?, slot?, notes? }</code>; the owner only.')}
ddiff --git a/test/cam-kit.test.js b/test/cam-kit.test.js
iindex e965e05..4a0abed 100644
---- a/test/cam-kit.test.js
++++ b/test/cam-kit.test.js
@@@ -9,6 +9,10 @@ const window = { document: { currentScript: { src: 'https://openvibe.cam/embed/c
vm.runInNewContext(source, { window, URL, Blob, File, Promise, Number, String, Date, setTimeout, clearTimeout, setInterval, clearInterval });
assert.strictEqual(typeof window.OpenVibeCam.open, 'function');
++assert.strictEqual(typeof window.OpenVibeCam.core.open, 'function');
++assert.strictEqual(typeof window.OpenVibeCam.core.devices, 'function');
++assert.strictEqual(typeof window.OpenVibeCam.core.meter, 'function');
++assert.strictEqual(window.OpenVibeCam.core.constraints({ facing: 'environment', resolution: '1080', audio: false }).video.height.ideal, 1080);
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, '')));
ddiff --git a/test/camera-settings.test.js b/test/camera-settings.test.js
nnew file mode 100644
iindex 0000000..0f5b9cd
---- /dev/null
++++ b/test/camera-settings.test.js
@@@ -0,0 +1,30 @@
++'use strict';
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const alice = t.network.addUser('alice');
++ const bob = t.network.addUser('bob');
++ try {
++ await check('camera choices have safe defaults and belong to one person', async () => {
++ const r = await t.get('/api/v1/me/camera-settings', { as: alice });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual(r.json(), { facing: 'user', lens: 'main', mic: 'default', resolution: '720', mirror: true, updated_at: null });
++ });
++ await check('partial settings persist and do not leak to another person', async () => {
++ const r = await t.get('/api/v1/me/camera-settings', { as: alice, method: 'PUT', json: { facing: 'environment', resolution: '1080', mirror: false } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.json().facing, 'environment');
++ assert.strictEqual(r.json().lens, 'main');
++ assert.strictEqual(r.json().mirror, false);
++ assert.strictEqual((await t.get('/api/v1/me/camera-settings', { as: alice })).json().resolution, '1080');
++ assert.strictEqual((await t.get('/api/v1/me/camera-settings', { as: bob })).json().facing, 'user');
++ });
++ await check('invalid settings and an anonymous write are refused', async () => {
++ assert.strictEqual((await t.get('/api/v1/me/camera-settings', { as: alice, method: 'PUT', json: { resolution: '4096' } })).status, 400);
++ assert.strictEqual((await t.get('/api/v1/me/camera-settings', { method: 'PUT', json: { mirror: false } })).status, 401);
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((err) => { console.error(err); process.exitCode = 1; });