openvibers/openvibe.cam Commits
Share camera core and save per-person settings (#116)
Changes
nnew file mode 100644@@@ -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()++);@@@ -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);@@@ -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);@@@ -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' },@@@ -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,@@@ -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.')}@@@ -9,6 +9,10 @@ const window = { document: { currentScript: { src: 'https://openvibe.cam/embed/cvm.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, '')));nnew file mode 100644@@@ -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; });
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -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()
+);
@@ -8,6 +8,51 @@
@@ -8,6 +8,51 @@
var active = null;
var active = null;
var MODES = ['video', 'photo', 'screen'];
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) {
function optionsFor(input) {
var o = input || {};
var o = input || {};
if (MODES.indexOf(o.mode || 'video') < 0) throw new TypeError('mode must be video, photo or screen');
if (MODES.indexOf(o.mode || 'video') < 0) throw new TypeError('mode must be video, photo or screen');
@@ -105,7 +150,7 @@
@@ -105,7 +150,7 @@
async function start() {
async function start() {
if (!supported(opts.mode, win.navigator)) { say('This browser cannot use the selected camera mode.'); return; }
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 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; }
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();
@@ -255,6 +300,6 @@
@@ -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 };
if (typeof module === 'object' && module.exports) module.exports = { optionsFor: optionsFor, supported: supported, secondsText: secondsText };
})(typeof window !== 'undefined' ? window : null);
})(typeof window !== 'undefined' ? window : null);
@@ -15,6 +15,8 @@
@@ -15,6 +15,8 @@
* GET /cameras/:id/grants owner
* GET /cameras/:id/grants owner
* POST /cameras/:id/grants owner: @username or subject, and days
* POST /cameras/:id/grants owner: @username or subject, and days
* DELETE /cameras/:id/grants/:grantId owner: revoke at once
* 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)
* 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
* POST /stills the Live cam button: fetch Media's frame, save to Pics
* GET /stills your stills
* GET /stills your stills
@@ -37,6 +39,19 @@ function createApi(ctx) {
@@ -37,6 +39,19 @@ function createApi(ctx) {
const r = asyncRouter();
const r = asyncRouter();
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
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);
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
// 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) {
@@ -99,6 +114,13 @@ function createApi(ctx) {
return jsonBody(req, res, next);
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 ─────────────────────────────────────────────
// ── Cameras ─────────────────────────────────────────────
r.get('/cameras', requirePerson, async (req, res) => {
r.get('/cameras', requirePerson, async (req, res) => {
const list = await cameras.camerasOf(req.principal.subject);
const list = await cameras.camerasOf(req.principal.subject);
@@ -12,6 +12,7 @@
@@ -12,6 +12,7 @@
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const TABLES = [
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.
// 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_event_shares', subject: 'owner', file: 'event-shares.json', erase: 'delete' },
{ table: 'cam_events', subject: 'owner', file: 'events.json', erase: 'delete' },
{ table: 'cam_events', subject: 'owner', file: 'events.json', erase: 'delete' },
@@ -180,6 +180,27 @@ function createStore(s, { config }) {
@@ -180,6 +180,27 @@ function createStore(s, { config }) {
const clock = () => s.iso();
const clock = () => s.iso();
const daysFromNow = (days) => new Date(s.now() + Number(days) * 86_400_000).toISOString();
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) {
async function cameraById(id, t) {
return await q(t).maybe('SELECT * FROM cam_cameras WHERE id = $1', [String(id || '')]);
return await q(t).maybe('SELECT * FROM cam_cameras WHERE id = $1', [String(id || '')]);
}
}
@@ -553,6 +574,7 @@ function createStore(s, { config }) {
@@ -553,6 +574,7 @@ function createStore(s, { config }) {
}
}
return {
return {
+ cameraSettings, putCameraSettings,
wireCamera, wireGrant, wirePhoto, wireStill,
wireCamera, wireGrant, wirePhoto, wireStill,
cameraById, camerasOf, createCamera, patchCamera, removeCamera, setFamiliarFaces, presenceOf, setPresence,
cameraById, camerasOf, createCamera, patchCamera, removeCamera, setFamiliarFaces, presenceOf, setPresence,
grantCamera, grantsOf, revokeGrant, activeGrant, accessTo, wall,
grantCamera, grantsOf, revokeGrant, activeGrant, accessTo, wall,
@@ -385,6 +385,7 @@ function developers({ base = 'https://openvibe.cam' } = {}) {
@@ -385,6 +385,7 @@ function developers({ base = 'https://openvibe.cam' } = {}) {
<section id="cm-kit"><h2>OpenVibeCam: record on your site</h2>
<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">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>
<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>
<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>
<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.')}
@@ -437,6 +438,8 @@ ${ep('GET', '/api/v1/photos/:id', 'One of your photos.')}
</dl></section>
</dl></section>
<section><h2>My cameras and viewer grants</h2><dl class="cm-eps">
<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('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('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.')}
${ep('PATCH', '/api/v1/cameras/:id', '<code>{ name?, source?, status?, slot?, notes? }</code>; the owner only.')}
@@ -9,6 +9,10 @@ const window = { document: { currentScript: { src: 'https://openvibe.cam/embed/c
@@ -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 });
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.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(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, '')));
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -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; });