openvibers/openvibe.cam Commits

Cam: <ov-camera>, the camera app as one script any site can embed (#90)

OP OpenVibers committed 6 days ago · c93d8b167e · 15 files, +886 −22

public/embed/ov-camera.js is the framework-free element: facing, filters,
timer, mode and upload attributes; ov-camera:capture and ov-camera:error;
start/stop/capture/flip; shadow DOM with --ov-camera-accent and
--ov-camera-radius. The file is also the shared FILTER_CSS and canvas
capture source public/js/cam.js now reads, and it is served cross-origin
so another site can load it.

/developers gains the copy-paste snippet, the attribute/event/method
tables, a live demo and the SDK link; the sitemap and llms.txt name it.
Tests cover the attribute parsing, the embed's type/cache/CORP and the
page render.

Changes

ddiff --git a/README.md b/README.md
iindex 748747a..0805bbb 100644
---- a/README.md
++++ b/README.md
@@@ -24,6 +24,8 @@ private to its owner, and a viewer sees it only while the grant the owner gave i
most. `@username` is resolved through OpenVibe.Network (`identity.subject.resolve`); a subject id works without
it. A re-grant extends the active grant instead of making a second one. Revocation writes a timestamp, and the
wall checks it on every read, so the next request is refused.
++- **The camera element.** `<ov-camera>` puts the same preview, filters, timer and burst on any site: one script from
++ `https://openvibe.cam/embed/ov-camera.js`, a capture event with the blob, and no upload unless the site asks for it.
- **The Live cam button.** Live calls `POST /api/v1/stills` with the viewer's own Network token. Cam fetches
OpenVibe.Media's public `GET /live/:slot/frame.jpg` (only `image/jpeg` is accepted), uploads the JPEG to
OpenVibe.Pics as that person, and returns the still and the exact line Live posts in the stream chat.
@@@ -139,6 +141,35 @@ Cam needs no app/service token for this, and Live needs no new Cam grant. A stil
`"visibility": "private"` to keep it to its owner. Media caches one frame per slot for five seconds and rate-limits
by address, so a burst of button presses can answer `429 cam.frame_busy`.
++## The camera element
++
++The camera app is also one framework-free custom element, at `https://openvibe.cam/embed/ov-camera.js` and documented
++live at [openvibe.cam/developers](https://openvibe.cam/developers). It is one script and one tag; with the default
++`upload="off"` the picture never leaves the browser.
++
++```html
++<script src="https://openvibe.cam/embed/ov-camera.js" async></script>
++<ov-camera id="cam" facing="user" filters="none,mono,warm,cool,fade,vivid,blur" timer="0" mode="photo" upload="off"></ov-camera>
++<script>
++ document.getElementById('cam').addEventListener('ov-camera:capture', function (e) {
++ // e.detail = { blob, width, height, filter }
++ });
++</script>
++```
++
++| Attribute | Values | Default |
++|---|---|---|
++| `facing` | `user` (front) or `environment` (back) | `user` |
++| `filters` | comma list of `none`, `mono`, `warm`, `cool`, `fade`, `vivid`, `blur` | `none` |
++| `timer` | `0`, `3` or `10` seconds | `0` |
++| `mode` | `photo` or `burst` (three, 450 ms apart) | `photo` |
++| `upload` | `off`, or `openvibe` to post to `POST /api/v1/photos` when the page is openvibe.cam and signed in | `off` |
++
++Events: `ov-camera:capture` with `{ blob, width, height, filter }` and `ov-camera:error` with `{ code, message }`.
++Methods: `start()`, `stop()`, `capture()`, `flip()`. Style with `--ov-camera-accent` and `--ov-camera-radius`. The
++element never starts the camera on connect; a click (or an explicit `start()`) does. OpenVibe.SDK wraps the loader as
++`openvibe-sdk/cam` (`defineOvCamera()`), with the attributes, events and methods typed.
++
## Pages
All server-rendered through the OpenVibe Frame (`openvibe-shared/shell`), readable without JavaScript, with
ddiff --git a/STATUS.json b/STATUS.json
iindex e6212fb..594a433 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -50,6 +50,7 @@
"deployed": false,
"placeholderInSites": false,
"features": [
++ "the <ov-camera> element at /embed/ov-camera.js: facing, filters, timer, mode and upload attributes; ov-camera:capture and ov-camera:error; start/stop/capture/flip; shadow DOM with --ov-camera-accent and --ov-camera-radius",
"camera app",
"photos to Pics",
"my cameras",
ddiff --git a/public/css/cam.css b/public/css/cam.css
iindex 9105c07..604ebf3 100644
---- a/public/css/cam.css
++++ b/public/css/cam.css
@@@ -285,3 +285,12 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
}
@media (min-width: 701px) { .cm-cam-app { padding-top: 18px; } .cm-stage { width: 100%; } .cm-bar { width: min(100%, 560px); margin: 0 auto; } .cm-filters { justify-content: safe center; } }
@media (prefers-reduced-motion: reduce) { .cm-flash.is-on, .cm-count.is-tick, .cm-thumb.is-new { animation: none; } .cm-stage video { transition: none; } }
++
++
++/* The /developers embed: the attributes, events and methods tables read as text, not as nowrap cells, and the live
++ element sits in the page's own column. */
++.cm-doc-table td, .cm-doc-table th { white-space: normal; vertical-align: top; overflow-wrap: anywhere; }
++.cm-doc-table td:first-child, .cm-doc-table th:first-child { width: 1%; white-space: nowrap; }
++.cm-doc-table code { overflow-wrap: anywhere; }
++.cm-embed-demo { max-width: 660px; margin: 18px 0 8px; }
++.cm-embed-demo .cm-photos { margin-top: 10px; }
ddiff --git a/public/embed/ov-camera.js b/public/embed/ov-camera.js
nnew file mode 100644
iindex 0000000..9148a81
---- /dev/null
++++ b/public/embed/ov-camera.js
@@@ -0,0 +1,631 @@
++/* OpenVibe.Cam <ov-camera>: the camera app as one framework-free custom element.
++ *
++ * <script src="https://openvibe.cam/embed/ov-camera.js" async></script>
++ * <ov-camera facing="user" filters="none,mono,warm" timer="3" mode="photo" upload="off"></ov-camera>
++ *
++ * The preview, the filters, the timer and the burst run in the browser; nothing is recorded. With upload="off" the
++ * picture never leaves the page and is delivered as an ov-camera:capture event. With upload="openvibe" and the
++ * page served from openvibe.cam, the picture is also posted to POST /api/v1/photos as the signed-in person.
++ *
++ * The same file is the camera app's filter and capture code: public/js/cam.js reads window.OVCameraCore, so the
++ * app and the element cannot drift apart. Node can require it for the parsing helpers (no DOM is touched then).
++ */
++(function (root, factory) {
++ 'use strict';
++ var api = factory(root);
++ if (typeof module === 'object' && module.exports) module.exports = api;
++ if (root) root.OVCameraCore = api;
++ if (root && root.customElements && root.HTMLElement) api.define(root);
++})(typeof window !== 'undefined' ? window : null, function (root) {
++ 'use strict';
++
++ // The FILTER_CSS names the camera app uses; the attribute and the API's X-Filter accept these.
++ var FILTERS = {
++ none: 'none',
++ mono: 'grayscale(1) contrast(1.08)',
++ warm: 'sepia(.35) saturate(1.15)',
++ cool: 'hue-rotate(-10deg) saturate(1.1)',
++ fade: 'saturate(.8) contrast(.92) brightness(1.06)',
++ vivid: 'saturate(1.45) contrast(1.06)',
++ blur: 'blur(1.5px)'
++ };
++ var LABELS = { none: 'None', mono: 'Mono', warm: 'Warm', cool: 'Cool', fade: 'Fade', vivid: 'Vivid', blur: 'Soft' };
++ var FILTER_NAMES = Object.keys(FILTERS);
++ var FACINGS = ['user', 'environment'];
++ var TIMERS = [0, 3, 10];
++ var MODES = ['photo', 'burst'];
++ var UPLOADS = ['off', 'openvibe'];
++ var DEFAULT_SRC = 'https://openvibe.cam/embed/ov-camera.js';
++ var SCRIPT_ORIGIN = null;
++
++ /** "none, mono, WARM" → ['none','mono','warm']; unknown names and duplicates are dropped, empty → ['none']. */
++ function parseFilters(value) {
++ if (value === null || value === undefined || String(value).trim() === '') return ['none'];
++ var seen = {};
++ var out = [];
++ String(value).split(',').forEach(function (raw) {
++ var name = raw.trim().toLowerCase();
++ if (name === 'soft') name = 'blur';
++ if (FILTERS[name] && !seen[name]) { seen[name] = true; out.push(name); }
++ });
++ return out.length ? out : ['none'];
++ }
++
++ /** 0, 3 or 10 seconds; anything else is 0 (no timer). */
++ function parseTimer(value) {
++ var n = parseInt(value, 10);
++ return TIMERS.indexOf(n) >= 0 ? n : 0;
++ }
++
++ /** user (the front camera) or environment (the back camera); user by default. */
++ function parseFacing(value) {
++ var v = String(value === null || value === undefined ? '' : value).trim().toLowerCase();
++ if (v === 'rear' || v === 'back') v = 'environment';
++ if (v === 'front') v = 'user';
++ return FACINGS.indexOf(v) >= 0 ? v : 'user';
++ }
++
++ /** photo or burst; photo by default. */
++ function parseMode(value) {
++ var v = String(value === null || value === undefined ? '' : value).trim().toLowerCase();
++ return MODES.indexOf(v) >= 0 ? v : 'photo';
++ }
++
++ /** off or openvibe; off by default, so an embed never uploads unless it says so. */
++ function parseUpload(value) {
++ var v = String(value === null || value === undefined ? '' : value).trim().toLowerCase();
++ return UPLOADS.indexOf(v) >= 0 ? v : 'off';
++ }
++
++ /** All five attributes at once, from a getter (element.getAttribute) or a plain object. */
++ function parseAttrs(get) {
++ var read = typeof get === 'function' ? get : function (name) { return get ? get[name] : null; };
++ return {
++ facing: parseFacing(read('facing')),
++ filters: parseFilters(read('filters')),
++ timer: parseTimer(read('timer')),
++ mode: parseMode(read('mode')),
++ upload: parseUpload(read('upload'))
++ };
++ }
++
++ function filterCss(name) { return FILTERS[name] || 'none'; }
++ function filterLabel(name) { return LABELS[name] || 'None'; }
++
++ /** Draw a video or an image to a canvas with the named filter, at its own size. Returns { width, height }. */
++ function drawTo(canvas, source, filter, width, height) {
++ var w = Math.round(width || source.videoWidth || source.naturalWidth || source.width || 1280);
++ var h = Math.round(height || source.videoHeight || source.naturalHeight || source.height || 720);
++ canvas.width = w;
++ canvas.height = h;
++ var ctx = canvas.getContext('2d');
++ if (!ctx) throw new Error('The canvas is not available in this browser.');
++ ctx.filter = filterCss(filter);
++ ctx.drawImage(source, 0, 0, w, h);
++ return { width: w, height: h };
++ }
++
++ /** canvas.toBlob as a promise; jpeg 0.92 is what the camera app posts. */
++ function toBlob(canvas, type, quality) {
++ return new Promise(function (resolve, reject) {
++ if (!canvas.toBlob) { reject(new Error('This browser cannot encode a picture.')); return; }
++ canvas.toBlob(function (blob) {
++ if (blob) resolve(blob); else reject(new Error('The picture could not be encoded.'));
++ }, type || 'image/jpeg', quality === undefined ? 0.92 : quality);
++ });
++ }
++
++ /** The origin of the page the element runs on and the origin the embed script came from. */
++ function originOf(url) {
++ try { return new URL(url, root && root.location ? root.location.href : DEFAULT_SRC).origin; } catch { return ''; }
++ }
++ function isSameOrigin(a, b) {
++ return Boolean(a && b && originOf(a) === originOf(b));
++ }
++
++ /**
++ * A picture picked from disk, drawn through the same filter and encoded the same way a capture is. Resolves to
++ * { blob, width, height, filter } and revokes the object URL it used to decode the file.
++ */
++ function fileToCapture(file, filter, makeCanvas) {
++ return new Promise(function (resolve, reject) {
++ var url = URL.createObjectURL(file);
++ var img = new Image();
++ img.onload = function () {
++ var canvas = makeCanvas();
++ var size;
++ try { size = drawTo(canvas, img, filter); } catch (err) { URL.revokeObjectURL(url); reject(err); return; }
++ toBlob(canvas, 'image/jpeg', 0.92).then(function (blob) {
++ URL.revokeObjectURL(url);
++ resolve({ blob: blob, width: size.width, height: size.height, filter: filter });
++ }, function (err) { URL.revokeObjectURL(url); reject(err); });
++ };
++ img.onerror = function () { URL.revokeObjectURL(url); reject(new Error('That file is not a picture this browser can 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>',
++ photos: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" aria-hidden="true"><rect x="3.5" y="5" width="17" height="14" rx="3"/><path d="m3.5 16 5-5 4 4 2.5-2.5 5.5 5.5"/></svg>',
++ upload: '<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="M12 15.5V4M7.5 8.5 12 4l4.5 4.5M5 15.5V19a1.5 1.5 0 0 0 1.5 1.5h11A1.5 1.5 0 0 0 19 19v-3.5"/></svg>'
++ };
++
++ // The camera app's look, in the shadow tree: the dark framed viewfinder, the off card, the filter strip, the
++ // pill controls and the shutter. The two public knobs are --ov-camera-accent and --ov-camera-radius.
++ var CSS = [
++ ':host{display:block;--_accent:var(--ov-camera-accent,#00c7d2);--_radius:var(--ov-camera-radius,26px);',
++ '--_ink:var(--ov-camera-ink,#e6edf7);--_muted:var(--ov-camera-muted,#96a7c2);--_line:var(--ov-camera-border,#1f2d47);',
++ '--_card:var(--ov-camera-card,#0f1729);--_on:var(--ov-camera-on-accent,#041416);',
++ 'color:var(--_ink);font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;line-height:1.4;',
++ 'contain:layout style;--_camera-gap:12px}',
++ '*,*::before,*::after{box-sizing:border-box}',
++ '.wrap{display:grid;gap:14px;padding:14px;border-radius:calc(var(--_radius) + 6px);background:var(--_card);border:1px solid var(--_line)}',
++ '.stage{position:relative;overflow:hidden;border-radius:var(--_radius);background:#04060b;aspect-ratio:4/3;',
++ 'box-shadow:inset 0 0 0 1px rgba(255,255,255,.06),0 30px 70px -40px rgba(0,0,0,.9);isolation:isolate}',
++ '.stage video,.stage canvas{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;transition:filter .25s ease}',
++ ':host([facing="user"]) .stage video{transform:scaleX(-1)}',
++ '.count{position:absolute;inset:0;z-index:3;display:grid;place-items:center;font-size:clamp(64px,20vw,140px);font-weight:900;color:#fff;text-shadow:0 6px 30px rgba(0,0,0,.5);pointer-events:none}',
++ '.count:empty{display:none}.count.is-tick{animation:ov-tick .9s cubic-bezier(.2,1.3,.4,1)}',
++ '@keyframes ov-tick{0%{transform:scale(1.6);opacity:0}30%{transform:scale(1);opacity:1}100%{opacity:.2}}',
++ '.off{position:absolute;inset:0;z-index:5;display:grid;place-content:center;justify-items:center;gap:10px;padding:24px;text-align:center;color:#e8ecf3;',
++ 'background:radial-gradient(120% 90% at 50% 20%,color-mix(in srgb,var(--_accent) 22%,#04060b) 0%,#04060b 70%)}',
++ '.camera:not([data-state="off"]) .off{display:none}',
++ '.off-title{margin:0;font-size:clamp(22px,4vw,30px);font-weight:800;letter-spacing:-.03em}',
++ '.off-text{margin:0;max-width:34ch;color:rgba(232,236,243,.75);font-size:14px}',
++ '.btn-big{min-height:52px;padding:0 26px;border:0;border-radius:999px;font:inherit;font-size:16px;font-weight:800;cursor:pointer;',
++ 'background:var(--_accent);color:var(--_on);box-shadow:0 10px 30px -12px var(--_accent)}',
++ '.btn-big:focus-visible,.round:focus-visible,.pill:focus-visible,.shutter:focus-visible,.swatch:focus-visible,.pick:focus-within{outline:2px solid var(--_accent);outline-offset:2px}',
++ '.pick{position:relative;display:inline-flex;align-items:center;gap:8px;min-height:42px;padding:0 14px;border-radius:999px;color:rgba(232,236,243,.85);font-size:14px;font-weight:650;cursor:pointer}',
++ '.pick svg{width:18px;height:18px}.pick:hover{background:rgba(255,255,255,.08)}',
++ '.pick input{position:absolute;inset:0;opacity:0;cursor:pointer}',
++ '.top{position:absolute;z-index:2;top:12px;left:12px;right:12px;display:flex;justify-content:flex-end;gap:8px}',
++ '.round,.pill{display:inline-flex;align-items:center;justify-content:center;gap:6px;height:44px;min-width:44px;padding:0 12px;border:0;border-radius:999px;',
++ 'color:#fff;background:rgba(4,6,11,.45);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);font:inherit;font-size:13px;font-weight:750;cursor:pointer}',
++ '.round{padding:0;width:44px}.round svg,.pill svg{width:20px;height:20px}',
++ '.pill.is-set{background:color-mix(in srgb,var(--_accent) 75%,rgba(4,6,11,.4))}',
++ '.filters{position:absolute;z-index:2;left:0;right:0;bottom:12px;display:flex;gap:8px;padding:4px 14px;overflow-x:auto;scrollbar-width:none}',
++ '.filters::-webkit-scrollbar{display:none}.filters[hidden]{display:none}',
++ '.swatch{flex:none;display:grid;justify-items:center;gap:4px;padding:4px;border:0;background:none;color:#fff;font:inherit;font-size:11.5px;font-weight:700;cursor:pointer;text-shadow:0 1px 6px rgba(0,0,0,.6)}',
++ '.swatch-chip{width:42px;height:42px;border-radius:13px;box-shadow:0 0 0 2px rgba(255,255,255,.35);transition:box-shadow .15s,transform .15s;filter:var(--_filter,none);',
++ 'background:radial-gradient(circle at 30% 30%,#ffd27a 0 18%,transparent 19%),linear-gradient(160deg,#6fb6ff,#8f6bff 45%,#ff7aa8 75%,#ffcf6b)}',
++ '.swatch[aria-checked="true"] .swatch-chip{box-shadow:0 0 0 3px #fff;transform:scale(1.06)}',
++ '.camera[data-state="off"] .top,.camera[data-state="off"] .filters{display:none}',
++ '.bar{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:12px;padding:0 4px}',
++ '.thumb{justify-self:start;display:grid;place-items:center;width:52px;height:52px;padding:0;border:1px solid var(--_line);border-radius:14px;overflow:hidden;cursor:pointer;',
++ 'color:var(--_muted);background:var(--_card)}.thumb img{width:100%;height:100%;object-fit:cover}.thumb svg{width:24px;height:24px}',
++ '.thumb[aria-label="Your last photo"]{cursor:pointer}',
++ '.flip{justify-self:end;color:var(--_ink);background:var(--_card);border:1px solid var(--_line);-webkit-backdrop-filter:none;backdrop-filter:none}',
++ '.shoot-wrap{display:grid;justify-items:center;gap:8px}',
++ '.modes{display:inline-flex;gap:2px;padding:3px;border-radius:999px;background:color-mix(in srgb,var(--_ink) 6%,transparent)}',
++ '.mode{height:30px;padding:0 12px;border:0;border-radius:999px;background:none;color:var(--_muted);font:inherit;font-size:12.5px;font-weight:750;letter-spacing:.02em;text-transform:uppercase;cursor:pointer}',
++ '.mode[aria-checked="true"]{color:var(--_accent);background:color-mix(in srgb,var(--_accent) 16%,transparent)}',
++ '.shutter{position:relative;display:grid;place-items:center;width:76px;height:76px;padding:0;border:0;border-radius:50%;background:none;cursor:pointer}',
++ '.shutter::before{content:"";position:absolute;inset:0;border-radius:50%;box-shadow:inset 0 0 0 4px var(--_ink)}',
++ '.shutter span{width:60px;height:60px;border-radius:50%;background:var(--_ink);transition:transform .12s ease,background-color .15s}',
++ '.shutter:active span,.shutter.is-firing span{transform:scale(.86)}',
++ '.camera[data-mode="burst"] .shutter span{background:var(--_accent)}',
++ '.shutter[disabled]{opacity:.45;cursor:not-allowed}',
++ '.status{margin:0;min-height:1.4em;text-align:center;font-size:13px;font-variant-numeric:tabular-nums;color:var(--_muted)}',
++ '.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}',
++ '@media (prefers-reduced-motion:reduce){.count.is-tick{animation:none}.stage video{transition:none}}'
++ ].join('');
++
++ var TEMPLATE = '<div class="wrap camera" part="wrap"><div class="stage" part="stage">'
++ + '<video part="preview" playsinline muted aria-label="Camera preview"></video>'
++ + '<canvas part="canvas" hidden></canvas>'
++ + '<div class="count" part="countdown" aria-hidden="true"></div>'
++ + '<div class="off"><p class="off-title">Camera</p><p class="off-text">Live filters on your device. Nothing is recorded.</p>'
++ + '<button class="btn-big" type="button" data-start>Turn on the camera</button>'
++ + '<label class="pick">' + ICONS.upload + '<span>Or pick a picture</span><input data-file type="file" accept="image/jpeg,image/png,image/webp"></label></div>'
++ + '<div class="top"><button class="pill" type="button" data-timer aria-label="Timer: off">' + ICONS.timer + '<b data-timer-label>Off</b></button></div>'
++ + '<div class="filters" role="radiogroup" aria-label="Filter" data-filters></div>'
++ + '</div><div class="bar"><button class="thumb" type="button" data-thumb disabled aria-label="No photo yet">' + ICONS.photos + '</button>'
++ + '<div class="shoot-wrap"><div class="modes" role="radiogroup" aria-label="Mode">'
++ + '<button class="mode" type="button" role="radio" aria-checked="true" data-mode="photo">Photo</button>'
++ + '<button class="mode" type="button" role="radio" aria-checked="false" data-mode="burst">Burst</button></div>'
++ + '<button class="shutter" type="button" data-shoot aria-label="Take the photo"><span></span></button></div>'
++ + '<button class="round flip" type="button" data-flip aria-label="Switch camera">' + ICONS.flip + '</button></div>'
++ + '<p class="status" part="status" role="status" aria-live="polite">The camera is off.</p></div>';
++
++ function sleep(ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); }
++
++ var OvCamera = root && root.HTMLElement ? class OvCamera extends root.HTMLElement {
++ constructor() {
++ super();
++ this._stream = null;
++ this._starting = null;
++ this._capturing = false;
++ this._selectedFilter = 'none';
++ this._lastUrl = null;
++ var shadow = this.attachShadow({ mode: 'open' });
++ shadow.innerHTML = '<style>' + CSS + '</style>' + TEMPLATE;
++ this._camera = shadow.querySelector('.camera') || shadow.querySelector('.wrap');
++ this._video = shadow.querySelector('video');
++ this._canvas = shadow.querySelector('canvas');
++ this._countEl = shadow.querySelector('.count');
++ this._statusEl = shadow.querySelector('.status');
++ this._startBtn = shadow.querySelector('[data-start]');
++ this._shootBtn = shadow.querySelector('[data-shoot]');
++ this._flipBtn = shadow.querySelector('[data-flip]');
++ this._timerBtn = shadow.querySelector('[data-timer]');
++ this._timerLabel = shadow.querySelector('[data-timer-label]');
++ this._filterGroup = shadow.querySelector('[data-filters]');
++ this._fileInput = shadow.querySelector('[data-file]');
++ this._thumb = shadow.querySelector('[data-thumb]');
++ this._modeBtns = Array.prototype.slice.call(shadow.querySelectorAll('[data-mode]'));
++ this._facing = 'user';
++ this._config = parseAttrs(null);
++
++ var self = this;
++ this._startBtn.addEventListener('click', function () {
++ if (self._stream) self.stop(); else self.start().catch(function () { /* start() already dispatched the error */ });
++ });
++ this._shootBtn.addEventListener('click', function () {
++ if (!self._stream) { self.start().catch(function () { /* start() already dispatched the error */ }); return; }
++ self.capture().catch(function () { /* capture() already dispatched the error */ });
++ });
++ this._flipBtn.addEventListener('click', function () { self.flip().catch(function () { /* no camera is not an error here */ }); });
++ this._timerBtn.addEventListener('click', function () {
++ var next = TIMERS[(TIMERS.indexOf(self._config.timer) + 1) % TIMERS.length];
++ self.setAttribute('timer', String(next));
++ });
++ this._filterGroup.addEventListener('click', function (e) {
++ var btn = e.target.closest ? e.target.closest('[data-filter]') : null;
++ if (!btn) return;
++ self._selectFilter(btn.getAttribute('data-filter'));
++ });
++ this._modeBtns.forEach(function (btn) {
++ btn.addEventListener('click', function () { self.setAttribute('mode', btn.getAttribute('data-mode')); });
++ });
++ this._fileInput.addEventListener('change', function () {
++ var file = self._fileInput.files && self._fileInput.files[0];
++ if (!file) return;
++ if (!/^image\//.test(file.type)) { self._say('That is not a picture.'); self._fileInput.value = ''; return; }
++ self._fromFile(file);
++ self._fileInput.value = '';
++ });
++ this._thumb.addEventListener('click', function () { self._downloadLast(); });
++ }
++
++ static get observedAttributes() { return ['facing', 'filters', 'timer', 'mode', 'upload']; }
++
++ connectedCallback() {
++ if (!this._camera) return;
++ 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.setAttribute('facing', this._config.facing);
++ }
++
++ disconnectedCallback() { this.stop(); }
++
++ attributeChangedCallback(name, oldValue, newValue) {
++ if (oldValue === newValue || !this._camera) return;
++ var wasFacing = this._config.facing;
++ this._applyAttributes();
++ if (name === 'facing' && this._stream && wasFacing !== this._config.facing) {
++ var self = this;
++ this.stop();
++ this.start().catch(function () { /* start() already dispatched the error */ });
++ }
++ }
++
++ // ── The public methods ───────────────────────────────
++ /** Turn the camera on. Never called on connect: a camera starts only from a click or an explicit call. */
++ start() {
++ 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
++ }).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'));
++ 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;
++ self._camera.setAttribute('data-state', 'on');
++ self._startBtn.textContent = 'Turn the camera off';
++ self._say('The camera is on. Nothing is recorded.');
++ }).catch(function (err) {
++ 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.';
++ self._say(message);
++ self._error(code, message);
++ }).then(function () { self._starting = null; });
++ return this._starting;
++ }
++
++ stop() {
++ this._startSeq = (this._startSeq || 0) + 1;
++ if (this._stream) {
++ this._stream.getTracks().forEach(function (t) { t.stop(); });
++ this._stream = null;
++ }
++ if (this._video) this._video.srcObject = null;
++ if (this._camera) this._camera.setAttribute('data-state', 'off');
++ if (this._startBtn) this._startBtn.textContent = 'Turn on the camera';
++ if (this._statusEl && this.isConnected) this._say('The camera is off.');
++ }
++
++ /** Take one photo (or three, in burst mode), honouring the timer. Resolves to the capture detail(s). */
++ capture() {
++ var self = this;
++ if (this._capturing) return Promise.resolve(null);
++ if (!this._stream || !this._video || !this._video.videoWidth) {
++ this._say('Turn the camera on first.');
++ this._error('not_started', 'Turn the camera on first.');
++ return Promise.resolve(null);
++ }
++ this._capturing = true;
++ var timer = this._config.timer;
++ var done = function () {
++ self._capturing = false;
++ self._say('The camera is on. Nothing is recorded.');
++ };
++ var run = timer > 0 ? this._countdown(timer) : Promise.resolve();
++ return run.then(function () {
++ if (self._config.mode === 'burst') {
++ var shots = [];
++ var chain = Promise.resolve();
++ for (var i = 0; i < 3; i++) {
++ chain = chain.then(function (i) {
++ return i ? sleep(450) : Promise.resolve();
++ }.bind(null, i)).then(function () { return self._snap(); }).then(function (detail) {
++ if (detail) shots.push(detail);
++ });
++ }
++ return chain.then(function () { done(); return shots; });
++ }
++ return self._snap().then(function (detail) { done(); return detail; });
++ }).catch(function (err) {
++ self._capturing = false;
++ self._error('capture_failed', err && err.message ? err.message : 'The picture could not be taken.');
++ return null;
++ });
++ }
++
++ /** 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.setAttribute('facing', next);
++ return Promise.resolve(next);
++ }
++
++ // ── Internals ────────────────────────────────────────
++ _applyAttributes() {
++ var config = parseAttrs(this.getAttribute.bind(this));
++ var filtersChanged = String(config.filters) !== String(this._config.filters);
++ this._config = config;
++ this._facing = config.facing;
++ if (this._camera) this._camera.setAttribute('data-mode', config.mode);
++ if (this._startBtn) this._startBtn.textContent = this._stream ? 'Turn the camera off' : 'Turn on the camera';
++ if (this._timerLabel) this._timerLabel.textContent = config.timer ? config.timer + 's' : 'Off';
++ if (this._timerBtn) {
++ this._timerBtn.setAttribute('aria-label', config.timer ? 'Timer: ' + config.timer + ' seconds' : 'Timer: off');
++ this._timerBtn.classList.toggle('is-set', config.timer !== 0);
++ }
++ this._modeBtns.forEach(function (b) { b.setAttribute('aria-checked', b.getAttribute('data-mode') === config.mode ? 'true' : 'false'); });
++ if (this._shootBtn) this._shootBtn.setAttribute('aria-label', config.mode === 'burst' ? 'Take three photos' : 'Take the photo');
++ if (filtersChanged || !this._filterGroup.children.length) this._renderFilters();
++ }
++
++ _renderFilters() {
++ var allowed = this._config.filters;
++ if (!allowed.includes(this._selectedFilter)) this._selectedFilter = allowed[0];
++ var self = this;
++ this._filterGroup.textContent = '';
++ this._filterGroup.hidden = allowed.length <= 1;
++ allowed.forEach(function (name) {
++ var btn = root.document.createElement('button');
++ btn.type = 'button';
++ btn.className = 'swatch';
++ btn.setAttribute('role', 'radio');
++ btn.setAttribute('data-filter', name);
++ btn.setAttribute('aria-label', filterLabel(name));
++ btn.setAttribute('aria-checked', name === self._selectedFilter ? 'true' : 'false');
++ var chip = root.document.createElement('span');
++ chip.className = 'swatch-chip';
++ chip.setAttribute('aria-hidden', 'true');
++ if (name !== 'none') chip.style.filter = filterCss(name);
++ var label = root.document.createElement('span');
++ label.textContent = filterLabel(name);
++ btn.appendChild(chip);
++ btn.appendChild(label);
++ self._filterGroup.appendChild(btn);
++ });
++ if (this._video) this._video.style.filter = filterCss(this._selectedFilter);
++ }
++
++ _selectFilter(name) {
++ if (!this._config.filters.includes(name)) return;
++ this._selectedFilter = name;
++ Array.prototype.forEach.call(this._filterGroup.querySelectorAll('[data-filter]'), function (b) {
++ b.setAttribute('aria-checked', b.getAttribute('data-filter') === name ? 'true' : 'false');
++ });
++ if (this._video) this._video.style.filter = filterCss(name);
++ }
++
++ _say(text) { if (this._statusEl) this._statusEl.textContent = text; }
++
++ _error(code, message) {
++ this.dispatchEvent(new root.CustomEvent('ov-camera:error', {
++ detail: { code: code, message: message }, bubbles: true, composed: true
++ }));
++ }
++
++ _countdown(n) {
++ var self = this;
++ return new Promise(function (resolve) {
++ var left = n;
++ var tick = function () {
++ if (left <= 0) { if (self._countEl) { self._countEl.textContent = ''; } resolve(); return; }
++ self._say(left + '…');
++ if (self._countEl) {
++ self._countEl.textContent = String(left);
++ self._countEl.classList.remove('is-tick');
++ void self._countEl.offsetWidth;
++ self._countEl.classList.add('is-tick');
++ }
++ left -= 1;
++ setTimeout(tick, 1000);
++ };
++ tick();
++ });
++ }
++
++ _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); }
++ 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) {
++ 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);
++ return null;
++ });
++ }
++
++ _showThumb(blob) {
++ if (!this._thumb || !root.URL || !root.URL.createObjectURL) return;
++ if (this._lastUrl) root.URL.revokeObjectURL(this._lastUrl);
++ this._lastUrl = root.URL.createObjectURL(blob);
++ this._thumb.textContent = '';
++ var img = root.document.createElement('img');
++ img.alt = '';
++ img.src = this._lastUrl;
++ this._thumb.appendChild(img);
++ this._thumb.disabled = false;
++ this._thumb.setAttribute('aria-label', 'Your last photo');
++ }
++
++ _downloadLast() {
++ if (!this._lastUrl || !root.document) return;
++ var a = root.document.createElement('a');
++ a.href = this._lastUrl;
++ a.download = 'ov-camera-' + Date.now() + '.jpg';
++ a.rel = 'noopener';
++ root.document.body.appendChild(a);
++ a.click();
++ a.remove();
++ }
++
++ _fromFile(file) {
++ var self = this;
++ this._say('Reading the picture…');
++ fileToCapture(file, this._selectedFilter, function () {
++ var canvas = root.document.createElement('canvas');
++ canvas.width = 1;
++ canvas.height = 1;
++ return canvas;
++ }).then(function (detail) {
++ self._say('The picture is ready.');
++ self._showThumb(detail.blob);
++ self.dispatchEvent(new root.CustomEvent('ov-camera:capture', { detail: detail, bubbles: true, composed: true }));
++ if (self._config.upload === 'openvibe') self._upload(detail);
++ }).catch(function (err) {
++ self._say(err && err.message ? err.message : 'That picture could not be read.');
++ self._error('file_unreadable', err && err.message ? err.message : 'That picture could not be read.');
++ });
++ }
++
++ /** POST the picture to Cam as the signed-in person; only same-origin (openvibe.cam) pages can. */
++ _upload(detail) {
++ var self = this;
++ var apiOrigin = SCRIPT_ORIGIN || originOf(DEFAULT_SRC);
++ var pageOrigin = root.location ? root.location.origin : '';
++ if (!isSameOrigin(pageOrigin, apiOrigin)) {
++ self._error('upload_unavailable', 'upload="openvibe" posts only from openvibe.cam; keep upload="off" and send the blob to your own server.');
++ return Promise.resolve(null);
++ }
++ this._say('Saving to OpenVibe.Pics…');
++ return root.fetch('/api/v1/photos', {
++ method: 'POST',
++ credentials: 'same-origin',
++ headers: {
++ 'Content-Type': 'image/jpeg',
++ 'X-Filter': detail.filter || 'none',
++ 'X-Filename': encodeURIComponent('cam-' + Date.now() + '.jpg')
++ },
++ body: detail.blob
++ }).then(function (res) {
++ return res.json().catch(function () { return {}; }).then(function (body) {
++ if (res.status === 401 || res.status === 403) {
++ self._say('Sign in to save this photo to OpenVibe.Pics.');
++ self._error('upload_denied', 'Sign in to save this photo to OpenVibe.Pics.');
++ return null;
++ }
++ if (!res.ok) {
++ var message = (body && body.detail) || 'The photo was not saved.';
++ self._say(message);
++ self._error('upload_failed', message);
++ return null;
++ }
++ self._say('Saved to OpenVibe.Pics.');
++ return body;
++ });
++ }).catch(function () {
++ self._say('The photo was not saved: the network did not answer.');
++ self._error('upload_failed', 'The photo was not saved: the network did not answer.');
++ return null;
++ });
++ }
++ } : null;
++
++ function define(win) {
++ if (!SCRIPT_ORIGIN) SCRIPT_ORIGIN = originOf((win.document && win.document.currentScript && win.document.currentScript.src) || DEFAULT_SRC);
++ if (!OvCamera || !win.customElements || win.customElements.get('ov-camera')) return;
++ win.customElements.define('ov-camera', OvCamera);
++ }
++
++ return {
++ FILTERS: FILTERS,
++ FILTER_NAMES: FILTER_NAMES,
++ LABELS: LABELS,
++ DEFAULT_SRC: DEFAULT_SRC,
++ parseFilters: parseFilters,
++ parseTimer: parseTimer,
++ parseFacing: parseFacing,
++ parseMode: parseMode,
++ parseUpload: parseUpload,
++ parseAttrs: parseAttrs,
++ filterCss: filterCss,
++ filterLabel: filterLabel,
++ drawTo: drawTo,
++ toBlob: toBlob,
++ isSameOrigin: isSameOrigin,
++ version: '1.0.0',
++ define: function () { if (root) define(root); }
++ };
++});
ddiff --git a/public/js/cam.js b/public/js/cam.js
iindex 8f93407..8dee6ab 100644
---- a/public/js/cam.js
++++ b/public/js/cam.js
@@@ -4,7 +4,10 @@
(function () {
'use strict';
-- var FILTERS = {
++ // One source for the filters and the canvas capture: the <ov-camera> element in public/embed/ov-camera.js.
++ // The layout loads that file before this one, so the app and an embed cannot drift apart.
++ var CORE = window.OVCameraCore || null;
++ var FILTERS = (CORE && CORE.FILTERS) || {
none: 'none',
mono: 'grayscale(1) contrast(1.08)',
warm: 'sepia(.35) saturate(1.15)',
@@@ -96,6 +99,7 @@
}
function drawFiltered() {
++ if (CORE && CORE.drawTo) { CORE.drawTo(canvas, video, filterSel.value); return; }
var w = video.videoWidth || 1280;
var h = video.videoHeight || 720;
canvas.width = w;
ddiff --git a/public/js/developers.js b/public/js/developers.js
nnew file mode 100644
iindex 0000000..597c5bb
---- /dev/null
++++ b/public/js/developers.js
@@@ -0,0 +1,40 @@
++/* OpenVibe.Cam /developers: the live <ov-camera> demo. A capture is shown on this page only; the element itself has
++ * upload="off", so nothing is sent. The docs' copy-paste snippet is the same listener, without this page's chrome. */
++(function () {
++ 'use strict';
++ var demo = document.getElementById('cm-ov-demo');
++ var shots = document.getElementById('cm-ov-demo-shots');
++ var status = document.getElementById('cm-ov-demo-status');
++ if (!demo || !shots) return;
++
++ function say(text) { if (status) status.textContent = text; }
++
++ document.addEventListener('ov-camera:capture', function (e) {
++ if (e.target !== demo || !e.detail || !e.detail.blob) return;
++ var d = e.detail;
++ var li = document.createElement('li');
++ li.className = 'cm-photo';
++ var link = document.createElement('a');
++ link.className = 'cm-photo-link';
++ link.rel = 'noopener';
++ link.download = 'ov-camera-demo.jpg';
++ var img = document.createElement('img');
++ img.alt = 'A photo from the ov-camera demo';
++ img.width = 320;
++ img.height = 240;
++ var url = URL.createObjectURL(d.blob);
++ img.src = url;
++ link.href = url;
++ link.appendChild(img);
++ li.appendChild(link);
++ shots.insertBefore(li, shots.firstChild);
++ say('Captured ' + d.width + '×' + d.height + ' · ' + d.filter + ' · it stayed in this page.');
++ // The link keeps the blob alive while the photo is shown; the page revokes it when it goes away.
++ window.addEventListener('pagehide', function () { URL.revokeObjectURL(url); }, { once: true });
++ });
++
++ document.addEventListener('ov-camera:error', function (e) {
++ if (e.target !== demo || !e.detail) return;
++ say(e.detail.message || 'The camera stopped.');
++ });
++})();
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
iindex af2cff2..7cc2128 100644
---- a/server/agent-pages.js
++++ b/server/agent-pages.js
@@@ -33,13 +33,14 @@ function createAgentPages({ config }) {
function developers() {
return twin('/developers', {
-- index: true, title: 'The OpenVibe.Cam API',
-- description: 'The JSON API, the viewer-grant rules and the Live cam button contract.',
++ index: true, title: 'The OpenVibe.Cam API and camera element',
++ description: 'The JSON API, the viewer-grant rules, the Live cam button contract, and the <ov-camera> element for any site.',
jsonLd: [seo.jsonLd.webPage({
-- name: 'The OpenVibe.Cam API', url: `${base()}/developers`,
-- description: 'The JSON API, the viewer-grant rules and the Live cam button contract.', siteUrl: base(),
++ name: 'The OpenVibe.Cam API and camera element', url: `${base()}/developers`,
++ description: 'The JSON API, the viewer-grant rules, the Live cam button contract, and the <ov-camera> element for any site.', siteUrl: base(),
})],
-- body: raw(views.developers()),
++ scripts: ['js/developers.js'],
++ body: raw(views.developers({ base: base() })),
});
}
ddiff --git a/server/app.js b/server/app.js
iindex b37646a..77c9386 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -140,6 +140,9 @@ async function createApp(opts = {}) {
const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
const v = res.req && res.req.query && res.req.query.v;
res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));
++ // The <ov-camera> element is meant to load from other sites (a classic <script>), so it is the one asset
++ // that says cross-origin; helmet's same-site default would make a browser refuse it there.
++ if (rel.startsWith('embed/')) res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
},
}));
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 21cc26c..be8f70f 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -35,7 +35,7 @@ function publicPages() {
return [
{ path: '/', changefreq: 'weekly', priority: 1.0, title: 'OpenVibe.Cam home', text: 'The camera app, your private cameras and viewer grants, and a still from a Live stream.' },
{ path: '/app', changefreq: 'weekly', priority: 0.9, title: 'The camera app', text: 'A photo with on-device colour, light and softness, a timer, a burst and a grid; the picture is saved to OpenVibe.Pics.' },
-- { path: '/developers', changefreq: 'weekly', priority: 0.6, title: 'The OpenVibe.Cam API', text: 'The JSON API, the viewer-grant rules and the Live cam button contract.' },
++ { path: '/developers', changefreq: 'weekly', priority: 0.6, title: 'The OpenVibe.Cam API and camera element', text: 'The JSON API, the viewer-grant rules, the Live cam button contract, and the <ov-camera> element you can put on any site.' },
];
}
@@@ -60,7 +60,7 @@ function createDiscoveryRoutes(ctx) {
// AGT-5: own pages link their Markdown twins (openvibe-shared 4.108.0); ## Optional is what an agent may skip.
markdown: true,
base: site,
-- details: 'GET /api/v1/cameras lists a signed-in person\'s cameras; POST /api/v1/cameras pairs one; POST /api/v1/cameras/:id/grants names a viewer and a number of days; DELETE /api/v1/cameras/:id/grants/:grantId revokes it at once. POST /api/v1/stills takes a frame from OpenVibe.Media\'s public GET /live/:sel/frame.jpg and saves it to OpenVibe.Pics as the person. No route is public except GET /api/v1/stills/:id for an unlisted still.',
++ details: 'The <ov-camera> element loads from /embed/ov-camera.js: facing, filters, timer, mode and upload attributes, ov-camera:capture and ov-camera:error events, and start/stop/capture/flip methods. GET /api/v1/cameras lists a signed-in person\'s cameras; POST /api/v1/cameras pairs one; POST /api/v1/cameras/:id/grants names a viewer and a number of days; DELETE /api/v1/cameras/:id/grants/:grantId revokes it at once. POST /api/v1/stills takes a frame from OpenVibe.Media\'s public GET /live/:sel/frame.jpg and saves it to OpenVibe.Pics as the person. No route is public except GET /api/v1/stills/:id for an unlisted still.',
sections: [
{ title: 'Start here', links: [
{ title: 'OpenVibe.Cam', url: abs('/'), note: 'the camera app and the privacy rules' },
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 70d6a24..9414447 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -195,10 +195,11 @@ function createPageRoutes(ctx) {
// ── Developers ───────────────────────────────────────────
r.get('/developers', (req, res) => page(req, res, {
-- index: true, cache: PUBLIC_CACHE, title: 'The OpenVibe.Cam API',
-- description: 'The JSON API, the viewer-grant rules and the Live cam button contract.',
-- jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Cam API', url: `${base()}/developers`, description: 'The JSON API, the viewer-grant rules and the Live cam button contract.', siteUrl: base() })],
-- body: raw(views.developers()),
++ index: true, cache: PUBLIC_CACHE, title: 'The OpenVibe.Cam API and camera element',
++ description: 'The JSON API, the viewer-grant rules, the Live cam button contract, and the <ov-camera> element for any site.',
++ jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Cam API and camera element', url: `${base()}/developers`, description: 'The JSON API, the viewer-grant rules, the Live cam button contract, and the <ov-camera> element for any site.', siteUrl: base() })],
++ scripts: ['js/developers.js'],
++ body: raw(views.developers({ base: base() })),
}));
// ── Discovery and 404 ────────────────────────────────────
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 23eff90..4106cf8 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -70,7 +70,7 @@ function renderPage(o) {
const crumbs = (o.crumbs || []).length
? `<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => `${i ? ' / ' : ''}${c.href ? `<a href="${c.href}">${c.label}</a>` : c.label}`).join('')}</nav>`
: '';
-- const scripts = (o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`);
++ const scripts = (o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`).join('');
return shell.page({
name: SITE_NAME,
lang: 'en',
@@@ -93,8 +93,9 @@ ${appIcon.headTags({ site: 'cam' })}
<link rel="stylesheet" href="${ovServe.url('showcase.css')}">
<link rel="stylesheet" href="${asset('css/cam.css')}">
<script src="${ovServe.url('motion.js')}" defer></script>
++<script src="${asset('embed/ov-camera.js')}" defer></script>
<script src="${asset('js/cam.js')}" defer></script>
--${render(scripts)}
++${scripts}
<meta name="ov-boost" content="cam@${require('./html').esc(RELEASE)}">
<script src="${require('openvibe-shared/serve').url('boost.js')}" data-main="#main" defer></script>`,
body: `<a class="skip" href="#main">Skip to content</a>
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 8a8750a..ead4cc6 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -328,13 +328,65 @@ function signIn({ title, text, next }) {
return `<div class="cm-wrap cm-page-pad cm-narrow"><div class="cm-empty"><h1>${esc(title)}</h1><p>${esc(text)}</p><a class="cm-btn" href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a></div></div>`;
}
--function developers() {
++function developers({ base = 'https://openvibe.cam' } = {}) {
const ep = (m, p, d) => `<div><dt><code>${esc(m)} ${esc(p)}</code></dt><dd>${d}</dd></div>`;
++ const row = (cells) => `<tr>${cells.map((c) => `<td>${c}</td>`).join('')}</tr>`;
++ const snippet = `<script src="${base}/embed/ov-camera.js" async></script>
++<ov-camera id="my-cam" facing="user" filters="none,mono,warm,cool,fade,vivid,blur" timer="0" mode="photo" upload="off"></ov-camera>
++
++<script>
++ document.getElementById('my-cam').addEventListener('ov-camera:capture', function (e) {
++ // e.detail = { blob, width, height, filter }
++ var img = new Image();
++ img.src = URL.createObjectURL(e.detail.blob);
++ document.body.appendChild(img);
++ });
++</script>`;
return `<div class="cm-wrap" id="cm-developers">
--<header class="cm-page-h"><p class="cm-kicker">Developers</p><h1>The Cam API</h1>
--<p class="cm-lede">JSON under <code>/api/v1</code>, with <code>application/problem+json</code> errors. A person is the <code>cam_at</code> session cookie or a Network user token as a Bearer. No route needs an app or service token.</p></header>
++<header class="cm-page-h"><p class="cm-kicker">Developers</p><h1>The Cam API and the camera element</h1>
++<p class="cm-lede">The camera app is also one framework-free element: <code>&lt;ov-camera&gt;</code> runs the preview, the filters, the timer and the burst in the browser, and hands you the picture. The same page has the JSON API under <code>/api/v1</code>, with <code>application/problem+json</code> errors.</p></header>
++
++<section id="cm-embed"><h2>The &lt;ov-camera&gt; element</h2>
++<p class="cm-fine">One script, one tag. Nothing starts until a person clicks or your code calls <code>start()</code>; with <code>upload="off"</code> (the default) the picture never leaves the browser. It uses a shadow root and two CSS custom properties, so a site's own styles cannot leak in.</p>
++<pre class="cm-code"><code>${esc(snippet)}</code></pre>
++<p class="cm-fine">Paste that once. <code>ov-camera:capture</code> fires for every photo (three in <code>mode="burst"</code>); <code>ov-camera:error</code> names what went wrong. The live demo below adds the photo to this page and nowhere else.</p>
++
++<div class="cm-embed-demo">
++<ov-camera id="cm-ov-demo" facing="environment" filters="none,mono,warm,cool,fade,vivid,blur" timer="0" mode="photo" upload="off"></ov-camera>
++<p class="cm-fine" id="cm-ov-demo-status" role="status">Take a photo; it stays in this page.</p>
++<ul class="cm-photos" id="cm-ov-demo-shots"></ul>
++</div>
++
++<h3>Attributes</h3>
++<div class="cm-table-wrap"><table class="cm-grants cm-doc-table"><thead><tr><th scope="col">Attribute</th><th scope="col">Values</th><th scope="col">What it does</th></tr></thead><tbody>
++${row(['<code>facing</code>', '<code>user</code> | <code>environment</code>', 'The front camera (<code>user</code>, the default) or the back one (<code>environment</code>).'])}
++${row(['<code>filters</code>', '<code>none</code>, <code>mono</code>, <code>warm</code>, <code>cool</code>, <code>fade</code>, <code>vivid</code>, <code>blur</code>', 'A comma list of the filters the strip offers. Unknown names are dropped; an empty list is <code>none</code>.'])}
++${row(['<code>timer</code>', '<code>0</code> | <code>3</code> | <code>10</code>', 'A countdown in seconds before a capture, with the number over the preview. Anything else is <code>0</code>.'])}
++${row(['<code>mode</code>', '<code>photo</code> | <code>burst</code>', '<code>photo</code> (the default) takes one; <code>burst</code> takes three, 450 ms apart, and fires three events.'])}
++${row(['<code>upload</code>', '<code>off</code> | <code>openvibe</code>', '<code>off</code> (the default) never sends the picture. <code>openvibe</code> posts it to <code>POST /api/v1/photos</code> as the signed-in person, and only when the page is served from openvibe.cam.'])}
++</tbody></table></div>
++
++<h3>Events</h3>
++<div class="cm-table-wrap"><table class="cm-grants cm-doc-table"><thead><tr><th scope="col">Event</th><th scope="col">detail</th><th scope="col">When</th></tr></thead><tbody>
++${row(['<code>ov-camera:capture</code>', '<code>{ blob, width, height, filter }</code>', 'A photo is ready: a camera capture, a burst shot or a picture picked from disk. The event bubbles and crosses the shadow boundary.'])}
++${row(['<code>ov-camera:error</code>', '<code>{ code, message }</code>', 'The camera or the upload failed. Codes: <code>camera_unavailable</code>, <code>permission_denied</code>, <code>not_started</code>, <code>capture_failed</code>, <code>file_unreadable</code>, <code>upload_unavailable</code>, <code>upload_denied</code>, <code>upload_failed</code>.'])}
++</tbody></table></div>
++
++<h3>Methods</h3>
++<div class="cm-table-wrap"><table class="cm-grants cm-doc-table"><thead><tr><th scope="col">Method</th><th scope="col">Returns</th><th scope="col">What it does</th></tr></thead><tbody>
++${row(['<code>start()</code>', 'a promise', 'Asks for the camera; a click is the safe place to call it. It is never called on connect.'])}
++${row(['<code>stop()</code>', 'nothing', 'Stops every track and returns the viewfinder to the off card.'])}
++${row(['<code>capture()</code>', 'a promise of the detail (three in burst)', 'Takes a photo through the same path as the shutter, honouring <code>timer</code> and <code>mode</code>.'])}
++${row(['<code>flip()</code>', 'a promise of the new facing', 'Switches front/back and restarts the camera if it is on.'])}
++</tbody></table></div>
++
++<p class="cm-fine">Style it with <code>--ov-camera-accent</code> (default <code>#00c7d2</code>) and <code>--ov-camera-radius</code> (default <code>26px</code>). No camera permission? The element keeps its <em>Turn on the camera</em> button and the <em>Or pick a picture</em> picker, so a site still gets an image.</p>
++<p class="cm-fine">In an app with a build step, <a href="https://github.com/OpenVibers/OpenVibe.SDK" rel="noopener">OpenVibe.SDK</a> wraps the loader: <code>import { defineOvCamera } from 'openvibe-sdk/cam'; await defineOvCamera();</code> injects the script once and returns the element class. The typed attributes, events and methods are in the entry's <code>.d.ts</code>.</p>
++</section>
--<section><h2>The camera app</h2><dl class="cm-eps">
++<section><h2>The API</h2>
++<p class="cm-fine">JSON under <code>/api/v1</code>, with <code>application/problem+json</code> errors. A person is the <code>cam_at</code> session cookie or a Network user token as a Bearer. No route needs an app or service token.</p>
++<dl class="cm-eps">
${ep('POST', '/api/v1/photos', 'A person; the picture is the raw body (<code>image/jpeg</code>, <code>image/png</code> or <code>image/webp</code>), with optional <code>X-Filter</code>, <code>X-Caption</code> and <code>X-Camera</code> headers (URI-encoded). Cam uploads it to OpenVibe.Pics and stores the link.')}
${ep('GET', '/api/v1/photos', 'Your photos, newest first.')}
${ep('GET', '/api/v1/photos/:id', 'One of your photos.')}
ddiff --git a/test/camera-element.test.js b/test/camera-element.test.js
nnew file mode 100644
iindex 0000000..4c55323
---- /dev/null
++++ b/test/camera-element.test.js
@@@ -0,0 +1,71 @@
++'use strict';
++/**
++ * The <ov-camera> element's pure helpers: the attribute parsing the page and the element share, the filter table
++ * (the same list the API accepts as X-Filter), and the same-origin rule that decides whether upload="openvibe" may
++ * post. No DOM and no database: public/embed/ov-camera.js is a UMD file so this test can require it in Node.
++ */
++const assert = require('assert');
++const core = require('../public/embed/ov-camera.js');
++const { FILTERS: API_FILTERS } = require('../server/store');
++
++let failures = 0;
++function check(name, fn) {
++ try { fn(); console.log(` ok ${name}`); }
++ catch (err) { failures += 1; console.error(` FAIL ${name}\n${err && err.stack ? err.stack : err}`); }
++}
++
++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');
++});
++
++check('the filter table is exactly the API X-Filter list', () => {
++ assert.deepStrictEqual(core.FILTER_NAMES, API_FILTERS);
++ assert.strictEqual(core.parseFilters('none, mono, WARM, cool, fade, vivid, blur').join(','), 'none,mono,warm,cool,fade,vivid,blur');
++});
++
++check('filters: a comma list, names lower-cased, unknown dropped, duplicates once, soft is blur, empty is none', () => {
++ assert.deepStrictEqual(core.parseFilters(null), ['none']);
++ assert.deepStrictEqual(core.parseFilters(' '), ['none']);
++ assert.deepStrictEqual(core.parseFilters('warm, nope, warm, SOFT'), ['warm', 'blur']);
++ assert.deepStrictEqual(core.parseFilters('nope'), ['none']);
++});
++
++check('timer is 0, 3 or 10; facing is user or environment; mode is photo or burst; upload is off or openvibe', () => {
++ assert.strictEqual(core.parseTimer('0'), 0);
++ assert.strictEqual(core.parseTimer('3'), 3);
++ assert.strictEqual(core.parseTimer('10'), 10);
++ assert.strictEqual(core.parseTimer('5'), 0);
++ assert.strictEqual(core.parseTimer(undefined), 0);
++ assert.strictEqual(core.parseFacing('environment'), 'environment');
++ assert.strictEqual(core.parseFacing('rear'), 'environment');
++ assert.strictEqual(core.parseFacing('user'), 'user');
++ assert.strictEqual(core.parseFacing('front'), 'user');
++ assert.strictEqual(core.parseFacing('sideways'), 'user');
++ assert.strictEqual(core.parseMode('burst'), 'burst');
++ assert.strictEqual(core.parseMode('video'), 'photo');
++ assert.strictEqual(core.parseUpload('openvibe'), 'openvibe');
++ assert.strictEqual(core.parseUpload('s3'), 'off');
++});
++
++check('parseAttrs reads all five at once from a getAttribute-style function or a plain object', () => {
++ const attrs = { facing: 'environment', filters: 'warm, mono', timer: '10', mode: 'burst', upload: 'openvibe' };
++ const fromObject = core.parseAttrs(attrs);
++ const fromGetter = core.parseAttrs((name) => (name in attrs ? attrs[name] : null));
++ assert.deepStrictEqual(fromObject, fromGetter);
++ assert.deepStrictEqual(fromObject, {
++ facing: 'environment', filters: ['warm', 'mono'], timer: 10, mode: 'burst', upload: 'openvibe',
++ });
++ assert.deepStrictEqual(core.parseAttrs(null), { facing: 'user', filters: ['none'], timer: 0, mode: 'photo', upload: 'off' });
++});
++
++check('the same-origin rule: upload="openvibe" may post only from the script origin', () => {
++ assert.strictEqual(core.isSameOrigin('https://openvibe.cam/app', 'https://openvibe.cam/embed/ov-camera.js?v=abc'), true);
++ assert.strictEqual(core.isSameOrigin('https://openvibe.cam', 'https://openvibe.network/embed/ov-camera.js'), false);
++ assert.strictEqual(core.isSameOrigin('', 'https://openvibe.cam/embed/ov-camera.js'), false);
++});
++
++if (failures) { console.error(`\n${failures} failed\n`); process.exit(1); }
++console.log('\nall passed\n');
ddiff --git a/test/home-scenes.test.js b/test/home-scenes.test.js
iindex a4b5257..2fb3e32 100644
---- a/test/home-scenes.test.js
++++ b/test/home-scenes.test.js
@@@ -1,14 +1,17 @@
'use strict';
// Cam's home shows the product on its sample picture, labelled Example: a live frame with the cam button's flash and the
// chat line it posts, a camera's one grant, and one picture through every filter the camera app has, with the same CSS as
--// the app, so the home can never show a filter the camera does not apply.
++// the app, so the home can never show a filter the camera does not apply. The filter table lives in the shared
++// <ov-camera> source (public/embed/ov-camera.js); public/js/cam.js reads window.OVCameraCore instead of copying it.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const views = require('../server/views');
const css = fs.readFileSync(path.join(__dirname, '..', 'public/css/cam.css'), 'utf8');
--const js = fs.readFileSync(path.join(__dirname, '..', 'public/js/cam.js'), 'utf8');
++const appJs = fs.readFileSync(path.join(__dirname, '..', 'public/js/cam.js'), 'utf8');
++const js = fs.readFileSync(path.join(__dirname, '..', 'public/embed/ov-camera.js'), 'utf8');
++assert.ok(appJs.includes('window.OVCameraCore'), 'the camera app reads the element\'s filters');
const sample = fs.readFileSync(path.join(__dirname, '..', 'public/img/cam-sample.svg'), 'utf8');
const block = js.slice(js.indexOf('var FILTERS = {'), js.indexOf('};', js.indexOf('var FILTERS = {')));
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex eb2f417..d211671 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -108,11 +108,15 @@ function jsonLd(html) {
assert.strictEqual((await t.get(`/s/${priv.id}`, { as: alice })).status, 200);
});
-- await check('/developers names the API and the Live cam button contract', async () => {
++ await check('/developers names the API, the Live cam button contract and the <ov-camera> element', async () => {
const r = await t.get('/developers');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
-- for (const marker of ['id="cm-developers"', 'POST /api/v1/stills', 'POST /api/v1/photos', '/live/:slot/frame.jpg', 'openvibe.pics/api/v1/images', 'id="cm-live-button"', '/api/v1/cameras/:id/grants']) assert.ok(r.text.includes(marker), marker);
++ for (const marker of ['id="cm-developers"', 'POST /api/v1/stills', 'POST /api/v1/photos', '/live/:slot/frame.jpg', 'openvibe.pics/api/v1/images', 'id="cm-live-button"', '/api/v1/cameras/:id/grants',
++ 'id="cm-embed"', '<ov-camera', 'ov-camera:capture', 'ov-camera:error', 'id="cm-ov-demo"', '--ov-camera-accent', '--ov-camera-radius', 'openvibe-sdk/cam', 'https://github.com/OpenVibers/OpenVibe.SDK']) assert.ok(r.text.includes(marker), marker);
++ const embedScript = r.text.indexOf('/embed/ov-camera.js?v=');
++ const camScript = r.text.indexOf('/js/cam.js?v=');
++ assert.ok(embedScript > 0 && camScript > embedScript, 'the element loads before the camera app');
});
await check('a cross-site form is refused; the 404 page is semantic', async () => {
@@@ -134,6 +138,18 @@ function jsonLd(html) {
const js = await t.get('/js/cam.js');
assert.strictEqual(js.status, 200);
assert.match(js.headers.get('content-type'), /javascript/);
++ const embedVersion = assetVersion('embed/ov-camera.js');
++ const embed = await t.get(`/embed/ov-camera.js?v=${embedVersion}`);
++ assert.strictEqual(embed.status, 200);
++ assert.match(embed.headers.get('content-type'), /javascript/);
++ assert.strictEqual(embed.headers.get('cache-control'), 'public, max-age=31536000, immutable');
++ assert.strictEqual(embed.headers.get('cross-origin-resource-policy'), 'cross-origin', 'another site may load the element');
++ assert.ok(embed.text.includes("define('ov-camera'"), 'the embed registers the element');
++ assert.ok(embed.text.includes('ov-camera:capture'), 'the embed emits the capture event');
++ const plainEmbed = await t.get('/embed/ov-camera.js');
++ assert.strictEqual(plainEmbed.status, 200);
++ assert.match(plainEmbed.headers.get('content-type'), /javascript/);
++ assert.strictEqual(plainEmbed.headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=86400');
const robots = await t.get('/robots.txt');
assert.match(robots.text, /Disallow: \/w\//);
assert.match(robots.text, /Disallow: \/s\//);