openvibers/openvibe.cam Commits
Reusable in-page camera recorder and review flow (#111)
Changes
@@@ -143,6 +143,25 @@ by address, so a burst of button presses can answer `429 cam.frame_busy`.## The camera element++### Recording kit++++`https://openvibe.cam/embed/cam-kit.js` opens a camera on the calling page. It uses a full-screen stage on phones and a centred stage on computers. The caller owns the result and any upload; Cam does not store video bytes.++++```html++<script src="https://openvibe.cam/embed/cam-kit.js" defer></script>++<button id="record">Record</button>++<script>++ document.getElementById('record').addEventListener('click', async () => {++ const result = await OpenVibeCam.open({ mode: 'video', maxSeconds: 60 });++ if (result) console.log(result.file, result.duration);++ });++</script>++```++++The script can be pinned with the SHA-384 SRI hash shown on [/developers](https://openvibe.cam/developers). `OpenVibeCam.open({ mode: 'video' | 'photo' | 'screen', maxSeconds, timer, onDone })` resolves to `{ file, mode, mimeType, duration, width, height, maxSeconds }` after **Use this**, or `null` when closed. `onDone(result)` may return a promise. Video and screen modes use the browser's `MediaRecorder`; screen capture appears when available. The review has two trim handles and exports the selected clip when supported. Pass the returned file to the calling site's uploader, using ov-upload's resumable uploader when available.++++### Photo element++The camera app is also one framework-free custom element, at `https://openvibe.cam/embed/ov-camera.js` and documentedlive 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.nnew file mode 100644@@@ -0,0 +1,59 @@++:host { all: initial; }++.camkit { --accent: #72e6d6; position: fixed; inset: 0; z-index: 2147483647; display: grid; place-items: center; background: rgba(5, 10, 16, .94); color: #fff; font: 500 16px/1.4 system-ui, sans-serif; box-sizing: border-box; }++.camkit *, .camkit *::before, .camkit *::after { box-sizing: border-box; }++.camkit button { font: inherit; cursor: pointer; color: inherit; border: 0; }++.camkit button:focus-visible, .camkit input:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }++.camkit .stage { position: relative; width: min(100vw, 950px); height: min(100dvh, 760px); min-height: 380px; overflow: hidden; background: #10151a; border-radius: 22px; box-shadow: 0 24px 90px #0009; }++.camkit .preview, .camkit .review video, .camkit .review img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #10151a; }++.camkit .shade { pointer-events: none; position: absolute; inset: 0; background: linear-gradient(#071016a6, transparent 25%, transparent 62%, #071016dd); }++.camkit header, .camkit footer { position: absolute; z-index: 2; left: 0; right: 0; }++.camkit header { z-index: 4; }++.camkit header { top: 0; display: flex; align-items: center; gap: 18px; padding: 18px 24px; }++.camkit header .clock { margin-left: auto; font-variant-numeric: tabular-nums; }++.camkit .brand { font-size: 15px; letter-spacing: .02em; }.camkit .brand strong { color: var(--accent); }++.camkit .icon { display: grid; place-items: center; width: 48px; height: 48px; flex: 0 0 48px; background: #fff2; border-radius: 50%; font-size: 26px; backdrop-filter: blur(12px); }++.camkit .icon:hover, .camkit .retake:hover { background: #fff4; }++.camkit .close { font-size: 32px; line-height: 1; }++.camkit footer { bottom: 0; padding: 24px 28px max(22px, env(safe-area-inset-bottom)); }++.camkit .tools { display: flex; justify-content: center; gap: 26px; margin-bottom: 20px; }++.camkit .tools .timer { font-size: 15px; font-weight: 700; }++.camkit .shutter-row { display: grid; grid-template-columns: minmax(0, 1fr) 86px minmax(0, 1fr); align-items: center; gap: 12px; }++.camkit .mode-label { justify-self: end; font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .14em; }++.camkit .limit { justify-self: start; font-size: 13px; font-variant-numeric: tabular-nums; }++.camkit .shutter { width: 86px; height: 86px; border: 4px solid #fff; padding: 7px; border-radius: 50%; background: conic-gradient(var(--accent) var(--progress, 0%), #ffffff50 0); box-shadow: 0 0 0 3px #10151a; transition: transform .15s; }++.camkit .shutter:hover { transform: scale(1.05); }++.camkit .shutter span { display: block; width: 100%; height: 100%; background: #fff; border-radius: 50%; transition: border-radius .2s, transform .2s, background .2s; }++.camkit .shutter.recording span { transform: scale(.54); border-radius: 12px; background: #fb514d; }++.camkit .hint { min-height: 20px; text-align: center; margin-top: 12px; color: #e4e9ed; font-size: 13px; }++.camkit .permission { position: absolute; z-index: 3; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 32px; text-align: center; background: radial-gradient(circle at 50% 30%, #23434b, #10151a 70%); }++.camkit .permission[hidden], .camkit .review[hidden], .camkit .countdown[hidden], .camkit [hidden] { display: none !important; }++.camkit .permission-mark { display: grid; place-items: center; width: 88px; height: 88px; border: 2px solid var(--accent); border-radius: 50%; font-size: 58px; line-height: 1; color: var(--accent); }++.camkit .permission h2 { font-size: clamp(26px, 5vw, 42px); margin: 22px 0 8px; }++.camkit .permission p { max-width: 380px; color: #d6e0e4; margin: 0 0 26px; }++.camkit .allow, .camkit .use { min-height: 48px; padding: 11px 28px; border-radius: 30px; background: var(--accent); color: #052227; font-weight: 800; }++.camkit .allow:hover, .camkit .use:hover { background: #b1fff3; }++.camkit .message { position: absolute; z-index: 5; top: 90px; left: 16px; right: 16px; text-align: center; color: #fff; text-shadow: 0 1px 5px #000; pointer-events: none; }++.camkit .countdown { position: absolute; z-index: 4; inset: 0; display: grid; place-items: center; background: #0008; font-size: 100px; font-weight: 800; }++.camkit .review { position: absolute; z-index: 3; inset: 0; background: #10151a; }++.camkit .play { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); display: grid; place-items: center; width: 64px; height: 64px; border-radius: 50%; background: #071016b8; font-size: 27px; backdrop-filter: blur(12px); }++.camkit .review-actions { position: absolute; left: 0; right: 0; bottom: max(24px, env(safe-area-inset-bottom)); display: flex; align-items: center; justify-content: center; gap: 16px; padding: 0 20px; }++.camkit .retake { min-height: 48px; padding: 10px 25px; border-radius: 30px; background: #fff3; backdrop-filter: blur(12px); }++.camkit .use:disabled { opacity: .55; cursor: wait; }++.camkit .trim { position: absolute; left: 50%; bottom: 105px; transform: translateX(-50%); width: min(90%, 520px); padding: 15px 20px; border-radius: 18px; background: #091217dd; backdrop-filter: blur(12px); }++.camkit .trim-head { display: flex; justify-content: space-between; gap: 10px; margin-bottom: 12px; font-size: 14px; }++.camkit .trim label { display: flex; align-items: center; gap: 10px; font-size: 13px; }++.camkit .trim input { width: 100%; min-height: 30px; accent-color: var(--accent); }++@media (max-width: 700px) {++ .camkit .stage { width: 100vw; height: 100dvh; min-height: 0; border-radius: 0; box-shadow: none; }++ .camkit .preview, .camkit .review video, .camkit .review img { object-fit: cover; }++ .camkit header { padding: max(14px, env(safe-area-inset-top)) 15px 12px; }++ .camkit footer { padding: 18px 14px max(22px, env(safe-area-inset-bottom)); }++ .camkit .tools { gap: 18px; }++ .camkit .brand { font-size: 13px; }++ .camkit .trim { bottom: 100px; }++}++++/* Real stroke icons in the controls (11-09): sized by the button, white on the glass. */++.camkit .icon svg { width: 22px; height: 22px; display: block; }++.camkit .play svg { display: block; }++.camkit .permission-mark svg { display: block; margin: 0 auto; }nnew file mode 100644@@@ -0,0 +1,260 @@++/* OpenVibe.Cam's browser recorder. It runs on the calling page and gives that page a File. */++(function (win) {++ 'use strict';++ if (!win || win.OpenVibeCam) return;++++ var script = win.document.currentScript;++ var cssUrl = script && script.src ? new URL('cam-kit.css', script.src).href : 'https://openvibe.cam/embed/cam-kit.css';++ var active = null;++ var MODES = ['video', 'photo', 'screen'];++++ function optionsFor(input) {++ var o = input || {};++ if (MODES.indexOf(o.mode || 'video') < 0) throw new TypeError('mode must be video, photo or screen');++ var seconds = Number(o.maxSeconds);++ if (!Number.isFinite(seconds) || seconds <= 0) seconds = 60;++ return { mode: o.mode || 'video', maxSeconds: Math.min(3600, Math.max(1, Math.round(seconds))), onDone: o.onDone, timer: [0, 3, 10].indexOf(Number(o.timer)) >= 0 ? Number(o.timer) : 0 };++ }++++ function supported(mode, nav) {++ if (mode === 'screen') return !!(nav.mediaDevices && nav.mediaDevices.getDisplayMedia && win.MediaRecorder);++ return !!(nav.mediaDevices && nav.mediaDevices.getUserMedia && (mode === 'photo' || win.MediaRecorder));++ }++++ function mime() {++ if (!win.MediaRecorder) return '';++ return ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4'].find(function (type) {++ return win.MediaRecorder.isTypeSupported && win.MediaRecorder.isTypeSupported(type);++ }) || '';++ }++++ function stopTracks(stream) {++ if (stream) stream.getTracks().forEach(function (track) { track.stop(); });++ }++++ function secondsText(value) {++ var s = Math.max(0, Math.floor(value));++ return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');++ }++++ function open(input) {++ var opts = optionsFor(input);++ if (active) return Promise.reject(new Error('A camera is already open.'));++ return new Promise(function (resolve, reject) {++ var host = win.document.createElement('div');++ host.id = 'openvibe-cam-kit';++ var shadow = host.attachShadow({ mode: 'open' });++ var link = win.document.createElement('link');++ link.rel = 'stylesheet'; link.href = cssUrl;++ shadow.appendChild(link);++ var wrap = win.document.createElement('div');++ wrap.className = 'camkit';++ wrap.setAttribute('role', 'dialog');++ wrap.setAttribute('aria-modal', 'true');++ wrap.setAttribute('aria-label', 'OpenVibe camera');++ wrap.innerHTML = '<div class="stage"><video class="preview" autoplay playsinline muted></video><div class="shade"></div>' +++ '<header><button class="icon close" aria-label="Close camera"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg></button><span class="brand">OpenVibe <strong>Cam</strong></span><span class="clock">0:00</span></header>' +++ '<div class="message" role="status"></div><div class="permission"><div class="permission-mark"><svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 8h3.5L9 5.5h6L16.5 8H20v11H4zM12 10.5a3 3 0 1 1 0 6 3 3 0 0 1 0-6z"/></svg></div><h2>Ready when you are</h2><p></p><button class="allow">Continue</button></div>' +++ '<div class="countdown" hidden></div><div class="review" hidden><video playsinline></video><img alt="Photo preview" hidden><button class="play" aria-label="Play clip"><svg viewBox="0 0 24 24" width="30" height="30" fill="currentColor" aria-hidden="true"><path d="M8 5.5v13l11-6.5z"/></svg></button><div class="trim" hidden><div class="trim-head"><strong>Trim your clip</strong><span class="trim-label"></span></div><label>Start <input class="trim-start" type="range" min="0" step="0.1"></label><label>End <input class="trim-end" type="range" min="0" step="0.1"></label></div><div class="review-actions"><button class="retake">Retake</button><button class="use">Use this</button></div></div>' +++ '<footer><div class="tools"><button class="icon flip" aria-label="Flip camera" title="Flip camera"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 8h3l1.5-2h7L17 8h3v11H4zM9.5 13.5a2.5 2.5 0 0 1 4.6-1.4M14.5 12.5a2.5 2.5 0 0 1-4.6 1.4M14.4 10.6v1.6h-1.6M9.6 15.4v-1.6h1.6"/></svg></button><button class="icon torch" aria-label="Toggle torch" title="Toggle torch"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13 3L6 13.5h5L10 21l8-11h-5.2z"/></svg></button><button class="icon timer" aria-label="Set timer" title="Set timer">0s</button></div><div class="shutter-row"><span class="mode-label"></span><button class="shutter" aria-label="Take photo or start recording"><span></span></button><span class="limit"></span></div><div class="hint"></div></footer></div>';++ shadow.appendChild(wrap);++ win.document.body.appendChild(host);++ var $ = function (s) { return shadow.querySelector(s); };++ var preview = $('.preview');++ var review = $('.review');++ var reviewVideo = $('.review video');++ var reviewImage = $('.review img');++ var stream = null, recorder = null, chunks = [], blob = null, url = null, started = 0, tick = null, autoStop = null;++ var facing = 'user', torch = false, phase = 'permission', done = false, lastFocus = win.document.activeElement;++ var duration = 0, trimStart = 0, trimEnd = 0, timer = opts.timer;++ active = host;++ $('.mode-label').textContent = opts.mode === 'photo' ? 'Photo' : opts.mode === 'screen' ? 'Screen' : 'Video';++ $('.limit').textContent = opts.mode === 'photo' ? '' : secondsText(opts.maxSeconds) + ' max';++ $('.permission p').textContent = opts.mode === 'screen' ? 'Choose the screen or window to record. Your browser will ask what to share.' : opts.mode === 'photo' ? 'Allow camera access to take a photo. Your photo stays here until you choose Use this.' : 'Allow camera and microphone access to record. Your clip stays here until you choose Use this.';++ $('.hint').textContent = opts.mode === 'photo' ? 'Tap the shutter for a photo' : 'Tap to start and stop · Space to record';++ $('.timer').textContent = timer + 's';++ $('.flip').hidden = opts.mode === 'screen';++ $('.torch').hidden = opts.mode === 'screen';++ $('.timer').hidden = opts.mode === 'screen';++ $('.allow').focus();++++ function say(message) { $('.message').textContent = message || ''; }++ function cleanRecording() { win.clearInterval(tick); win.clearTimeout(autoStop); tick = null; autoStop = null; }++ function clearBlob() { if (url) win.URL.revokeObjectURL(url); url = null; blob = null; reviewVideo.removeAttribute('src'); reviewImage.removeAttribute('src'); }++ function close(result, error) {++ if (done) return;++ done = true; cleanRecording();++ if (recorder && recorder.state !== 'inactive') recorder.stop();++ stopTracks(stream); stream = null; clearBlob();++ win.document.removeEventListener('keydown', keydown, true);++ host.remove(); active = null;++ if (lastFocus && lastFocus.focus) lastFocus.focus();++ if (error) reject(error); else resolve(result || null);++ }++ function permissionError(err) {++ var denied = err && (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError');++ say(denied ? 'Permission was not granted. You can try again.' : (err && err.message) || 'This camera is unavailable.');++ phase = 'permission'; $('.permission').hidden = false;++ }++ function updateTorch() {++ var track = stream && stream.getVideoTracks()[0];++ var caps = track && track.getCapabilities ? track.getCapabilities() : {};++ $('.torch').hidden = !caps.torch;++ if (caps.torch) $('.torch').setAttribute('aria-pressed', String(torch));++ }++ async function start() {++ if (!supported(opts.mode, win.navigator)) { say('This browser cannot use the selected camera mode.'); return; }++ try {++ var next = opts.mode === 'screen' ? await win.navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }) : await win.navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: facing } }, audio: opts.mode === 'video' });++ if (done) { stopTracks(next); return; }++ stopTracks(stream); stream = next;++ preview.srcObject = stream; await preview.play();++ if (done) return;++ var videoTrack = stream.getVideoTracks()[0];++ if (videoTrack) videoTrack.addEventListener('ended', function () { if (phase === 'recording') stopRecord(); else if (!done) say('Sharing ended.'); });++ $('.permission').hidden = true; phase = 'ready'; say(''); updateTorch();++ } catch (err) { permissionError(err); }++ }++ function countdown() {++ if (!timer) return Promise.resolve();++ phase = 'countdown';++ return new Promise(function (resolveCountdown) {++ var left = timer, el = $('.countdown'); el.hidden = false; el.textContent = left;++ var id = win.setInterval(function () {++ if (done || phase !== 'countdown') { win.clearInterval(id); el.hidden = true; resolveCountdown(false); return; }++ left -= 1;++ if (!left) { win.clearInterval(id); el.hidden = true; phase = 'ready'; resolveCountdown(true); }++ else el.textContent = left;++ }, 1000);++ });++ }++ function showReview(media, length) {++ blob = media; duration = length || 0; trimStart = 0; trimEnd = duration;++ stopTracks(stream); stream = null; preview.srcObject = null;++ url = win.URL.createObjectURL(blob);++ var photo = opts.mode === 'photo';++ reviewImage.hidden = !photo; reviewVideo.hidden = photo; $('.play').hidden = photo;++ if (photo) reviewImage.src = url; else reviewVideo.src = url;++ $('.trim').hidden = photo || duration < 0.2;++ $('.trim-start').max = String(duration); $('.trim-end').max = String(duration);++ $('.trim-start').value = '0'; $('.trim-end').value = String(duration);++ updateTrim(); review.hidden = false; phase = 'review';++ $('.use').focus();++ }++ async function capturePhoto() {++ var canvas = win.document.createElement('canvas');++ canvas.width = preview.videoWidth || 1280; canvas.height = preview.videoHeight || 720;++ canvas.getContext('2d').drawImage(preview, 0, 0, canvas.width, canvas.height);++ var image = await new Promise(function (resolveImage) { canvas.toBlob(resolveImage, 'image/jpeg', 0.92); });++ if (!image) throw new Error('The photo could not be encoded.');++ showReview(image, 0);++ }++ function stopRecord() {++ if (phase !== 'recording' || !recorder) return;++ phase = 'stopping'; cleanRecording(); recorder.stop();++ }++ function record() {++ chunks = [];++ var type = mime(); recorder = new win.MediaRecorder(stream, type ? { mimeType: type } : undefined);++ recorder.ondataavailable = function (event) { if (event.data && event.data.size) chunks.push(event.data); };++ recorder.onerror = function () { say('Recording failed. Please retake.'); };++ recorder.onstop = function () {++ if (done || phase !== 'stopping') return;++ var length = Math.min(opts.maxSeconds, (Date.now() - started) / 1000);++ var recorded = new Blob(chunks, { type: recorder.mimeType || type || 'video/webm' });++ if (!recorded.size) { phase = 'ready'; say('No video was recorded. Try again.'); return; }++ showReview(recorded, length);++ };++ recorder.start(200); started = Date.now(); phase = 'recording';++ $('.shutter').classList.add('recording');++ $('.shutter').style.setProperty('--progress', '0%');++ tick = win.setInterval(function () {++ var elapsed = Math.min(opts.maxSeconds, (Date.now() - started) / 1000);++ $('.clock').textContent = secondsText(elapsed);++ $('.shutter').style.setProperty('--progress', (100 * elapsed / opts.maxSeconds) + '%');++ }, 100);++ autoStop = win.setTimeout(stopRecord, opts.maxSeconds * 1000);++ }++ async function shutter() {++ if (phase === 'recording') { stopRecord(); return; }++ if (phase !== 'ready') return;++ if (!(await countdown())) { if (timer) return; }++ if (done || phase !== 'ready') return;++ try { if (opts.mode === 'photo') await capturePhoto(); else record(); }++ catch (err) { phase = 'ready'; say(err.message || 'Capture failed.'); }++ }++ function updateTrim() {++ var startInput = $('.trim-start'), endInput = $('.trim-end');++ trimStart = Math.min(Number(startInput.value), Math.max(0, Number(endInput.value) - 0.1));++ trimEnd = Math.max(Number(endInput.value), trimStart + 0.1);++ startInput.value = String(trimStart); endInput.value = String(trimEnd);++ $('.trim-label').textContent = secondsText(trimStart) + ' – ' + secondsText(trimEnd);++ }++ async function trimmedBlob() {++ if (trimStart <= 0 && trimEnd >= duration - 0.1) return blob;++ if (!reviewVideo.captureStream || !win.MediaRecorder) throw new Error('This browser cannot export a trimmed clip.');++ say('Preparing your trimmed clip…');++ reviewVideo.pause(); reviewVideo.currentTime = trimStart;++ await new Promise(function (resolveSeek, rejectSeek) {++ if (Math.abs(reviewVideo.currentTime - trimStart) < 0.05 && reviewVideo.readyState >= 2) return resolveSeek();++ reviewVideo.addEventListener('seeked', resolveSeek, { once: true });++ reviewVideo.addEventListener('error', rejectSeek, { once: true });++ });++ var captured = reviewVideo.captureStream();++ if (!captured.getVideoTracks().length) throw new Error('This browser cannot export a trimmed clip.');++ var pieces = [], type = mime(), cutter = new win.MediaRecorder(captured, type ? { mimeType: type } : undefined);++ var output = new Promise(function (resolveCut, rejectCut) {++ cutter.ondataavailable = function (event) { if (event.data && event.data.size) pieces.push(event.data); };++ cutter.onerror = function () { rejectCut(new Error('The trim could not be exported.')); };++ cutter.onstop = function () { var cut = new Blob(pieces, { type: cutter.mimeType || type || blob.type }); cut.size ? resolveCut(cut) : rejectCut(new Error('The trim was empty.')); };++ });++ cutter.start(200); await reviewVideo.play();++ await new Promise(function (resolvePlay) {++ var until = win.setInterval(function () { if (reviewVideo.currentTime >= trimEnd || reviewVideo.ended || done) { win.clearInterval(until); resolvePlay(); } }, 40);++ });++ cutter.stop(); reviewVideo.pause();++ return output;++ }++ function keydown(event) {++ if (event.key === 'Escape') { event.preventDefault(); close(); }++ if (event.code === 'Space' && event.target.tagName !== 'BUTTON' && event.target.tagName !== 'INPUT' && phase !== 'review') { event.preventDefault(); shutter(); }++ }++ $('.close').onclick = function () { close(); };++ $('.allow').onclick = start;++ $('.shutter').onclick = shutter;++ $('.flip').onclick = async function () {++ if (phase !== 'ready') return;++ facing = facing === 'user' ? 'environment' : 'user'; torch = false; await start();++ };++ $('.torch').onclick = async function () {++ var track = stream && stream.getVideoTracks()[0]; if (!track) return;++ try { torch = !torch; await track.applyConstraints({ advanced: [{ torch: torch }] }); updateTorch(); }++ catch { torch = false; say('Torch is unavailable on this camera.'); }++ };++ $('.timer').onclick = function () { timer = timer === 0 ? 3 : timer === 3 ? 10 : 0; $('.timer').textContent = timer + 's'; };++ $('.trim-start').oninput = updateTrim; $('.trim-end').oninput = updateTrim;++ $('.play').onclick = function () { if (reviewVideo.paused) reviewVideo.play(); else reviewVideo.pause(); };++ // The play button's own icon: a filled triangle, two bars while it plays (an SVG path, never a text glyph).++ var playPath = function (d, label) { var p = $('.play path'); if (p) p.setAttribute('d', d); $('.play').setAttribute('aria-label', label); };++ reviewVideo.onplay = function () { playPath('M7 5h3.5v14H7zM13.5 5H17v14h-3.5z', 'Pause clip'); };++ reviewVideo.onpause = function () { playPath('M8 5.5v13l11-6.5z', 'Play clip'); };++ $('.retake').onclick = function () { clearBlob(); review.hidden = true; $('.shutter').classList.remove('recording'); $('.clock').textContent = '0:00'; phase = 'permission'; $('.permission').hidden = false; start(); };++ $('.use').onclick = async function () {++ if (phase !== 'review') return;++ phase = 'exporting'; $('.use').disabled = true;++ try {++ var output = opts.mode === 'photo' ? blob : await trimmedBlob();++ var ext = opts.mode === 'photo' ? 'jpg' : output.type.indexOf('mp4') >= 0 ? 'mp4' : 'webm';++ var file = new File([output], 'openvibe-' + opts.mode + '-' + Date.now() + '.' + ext, { type: output.type });++ var result = { file: file, mode: opts.mode, mimeType: output.type, duration: opts.mode === 'photo' ? 0 : trimEnd - trimStart, width: opts.mode === 'photo' ? reviewImage.naturalWidth : reviewVideo.videoWidth, height: opts.mode === 'photo' ? reviewImage.naturalHeight : reviewVideo.videoHeight, maxSeconds: opts.maxSeconds };++ if (typeof opts.onDone === 'function') await opts.onDone(result);++ close(result);++ } catch (err) { phase = 'review'; $('.use').disabled = false; say(err.message || 'The file could not be prepared.'); }++ };++ win.document.addEventListener('keydown', keydown, true);++ });++ }++++ win.OpenVibeCam = Object.freeze({ open: open });++ if (typeof module === 'object' && module.exports) module.exports = { optionsFor: optionsFor, supported: supported, secondsText: secondsText };++})(typeof window !== 'undefined' ? window : null);@@@ -7,6 +7,15 @@var status = document.getElementById('cm-ov-demo-status');if (!demo || !shots) return;++ var kitDemo = document.getElementById('cm-kit-demo');++ if (kitDemo) kitDemo.addEventListener('click', async function () {++ var status = document.getElementById('cm-kit-status');++ try {++ var result = await window.OpenVibeCam.open({ mode: 'video', maxSeconds: 30 });++ if (status) status.textContent = result ? 'Ready: ' + result.file.name + ' (' + Math.round(result.file.size / 1024) + ' KB). It stayed on this page.' : 'Cancelled.';++ } catch (err) { if (status) status.textContent = err.message || 'The recorder could not open.'; }++ });++function say(text) { if (status) status.textContent = text; }document.addEventListener('ov-camera:capture', function (e) {@@@ -39,7 +39,7 @@ function createAgentPages({ config }) {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'],++ scripts: ['embed/cam-kit.js', 'js/developers.js'],body: raw(views.developers({ base: base() })),});}@@@ -105,7 +105,7 @@ async function createApp(opts = {}) {referrerPolicy: { policy: 'strict-origin-when-cross-origin' },}));// The camera app is same-origin only; there is no microphone in v1.-- app.use((_req, res, next) => { res.set('Permissions-Policy', 'camera=(self), microphone=(), geolocation=(), payment=()'); next(); });++ app.use((_req, res, next) => { res.set('Permissions-Policy', 'camera=(self), microphone=(self), display-capture=(self), geolocation=(), payment=()'); next(); });app.use(cookieParser());// ── Machine endpoints ───────────────────────────────────@@@ -142,7 +142,10 @@ async function createApp(opts = {}) {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');++ if (rel.startsWith('embed/')) {++ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');++ res.setHeader('Access-Control-Allow-Origin', '*');++ }},}));@@@ -198,7 +198,7 @@ function createPageRoutes(ctx) {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'],++ scripts: ['embed/cam-kit.js', 'js/developers.js'],body: raw(views.developers({ base: base() })),}));@@@ -17,6 +17,9 @@* notFound()*/const { esc, time } = require('../render/html');++const crypto = require('crypto');++const fs = require('fs');++const path = require('path');const kit = require('openvibe-shared/kit');const sc = require('openvibe-shared/showcase');@@@ -354,6 +357,15 @@ function signIn({ title, text, next }) {}function developers({ base = 'https://openvibe.cam' } = {}) {++ const sri = 'sha384-' + crypto.createHash('sha384').update(fs.readFileSync(path.join(__dirname, '../../public/embed/cam-kit.js'))).digest('base64');++ const kitSnippet = `<script src="${base}/embed/cam-kit.js" integrity="${sri}" crossorigin="anonymous" defer></script>++<button id="record">Record a clip</button>++<script>++ document.getElementById('record').addEventListener('click', async function () {++ const result = await OpenVibeCam.open({ mode: 'video', maxSeconds: 60 });++ if (result) { /* result.file is yours to upload or preview */ }++ });++</script>`;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>@@@ -371,6 +383,13 @@ function developers({ base = 'https://openvibe.cam' } = {}) {<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><ov-camera></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-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>++<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>++</section>++<section id="cm-embed"><h2>The <ov-camera> 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>nnew file mode 100644@@@ -0,0 +1,15 @@++'use strict';++const assert = require('assert');++const fs = require('fs');++const vm = require('vm');++const path = require('path');++++const source = fs.readFileSync(path.join(__dirname, '../public/embed/cam-kit.js'), 'utf8');++const window = { document: { currentScript: { src: 'https://openvibe.cam/embed/cam-kit.js' } }, navigator: { mediaDevices: {} }, URL };++vm.runInNewContext(source, { window, URL, Blob, File, Promise, Number, String, Date, setTimeout, clearTimeout, setInterval, clearInterval });++++assert.strictEqual(typeof window.OpenVibeCam.open, 'function');++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, '')));++console.log('cam-kit API shape and standalone script: ok');@@@ -39,7 +39,7 @@ function jsonLd(html) {assert.strictEqual(r.status, 200);assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');assert.strictEqual(r.headers.get('vary'), 'Cookie');-- assert.strictEqual(r.headers.get('permissions-policy'), 'camera=(self), microphone=(), geolocation=(), payment=()');++ assert.strictEqual(r.headers.get('permissions-policy'), 'camera=(self), microphone=(self), display-capture=(self), geolocation=(), payment=()');});await check('/app has the camera controls and the gallery', async () => {@@@ -113,7 +113,7 @@ function jsonLd(html) {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',-- '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);++ 'id="cm-kit"', 'OpenVibeCam.open', 'sha384-', 'id="cm-kit-demo"', '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');@@@ -144,6 +144,10 @@ function jsonLd(html) {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.strictEqual(embed.headers.get('access-control-allow-origin'), '*', 'a pinned cross-origin script needs CORS');++ const kit = await t.get('/embed/cam-kit.js');++ assert.strictEqual(kit.status, 200);++ assert.strictEqual(kit.headers.get('access-control-allow-origin'), '*');assert.ok(embed.text.includes("define('ov-camera'"), 'the embed registers the element');assert.ok(embed.text.includes('ov-camera:capture'), 'the embed emits the capture event');const plainEmbed = await t.get('/embed/ov-camera.js');
@@ -143,6 +143,25 @@ by address, so a burst of button presses can answer `429 cam.frame_busy`.
@@ -143,6 +143,25 @@ by address, so a burst of button presses can answer `429 cam.frame_busy`.
## The camera element
## The camera element
+### Recording kit
+
+`https://openvibe.cam/embed/cam-kit.js` opens a camera on the calling page. It uses a full-screen stage on phones and a centred stage on computers. The caller owns the result and any upload; Cam does not store video bytes.
+
+```html
+<script src="https://openvibe.cam/embed/cam-kit.js" defer></script>
+<button id="record">Record</button>
+<script>
+ document.getElementById('record').addEventListener('click', async () => {
+ const result = await OpenVibeCam.open({ mode: 'video', maxSeconds: 60 });
+ if (result) console.log(result.file, result.duration);
+ });
+</script>
+```
+
+The script can be pinned with the SHA-384 SRI hash shown on [/developers](https://openvibe.cam/developers). `OpenVibeCam.open({ mode: 'video' | 'photo' | 'screen', maxSeconds, timer, onDone })` resolves to `{ file, mode, mimeType, duration, width, height, maxSeconds }` after **Use this**, or `null` when closed. `onDone(result)` may return a promise. Video and screen modes use the browser's `MediaRecorder`; screen capture appears when available. The review has two trim handles and exports the selected clip when supported. Pass the returned file to the calling site's uploader, using ov-upload's resumable uploader when available.
+
+### Photo element
+
The camera app is also one framework-free custom element, at `https://openvibe.cam/embed/ov-camera.js` and documented
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
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.
`upload="off"` the picture never leaves the browser.
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
+:host { all: initial; }
+.camkit { --accent: #72e6d6; position: fixed; inset: 0; z-index: 2147483647; display: grid; place-items: center; background: rgba(5, 10, 16, .94); color: #fff; font: 500 16px/1.4 system-ui, sans-serif; box-sizing: border-box; }
+.camkit *, .camkit *::before, .camkit *::after { box-sizing: border-box; }
+.camkit button { font: inherit; cursor: pointer; color: inherit; border: 0; }
+.camkit button:focus-visible, .camkit input:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
+.camkit .stage { position: relative; width: min(100vw, 950px); height: min(100dvh, 760px); min-height: 380px; overflow: hidden; background: #10151a; border-radius: 22px; box-shadow: 0 24px 90px #0009; }
+.camkit .preview, .camkit .review video, .camkit .review img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #10151a; }
+.camkit .shade { pointer-events: none; position: absolute; inset: 0; background: linear-gradient(#071016a6, transparent 25%, transparent 62%, #071016dd); }
+.camkit header, .camkit footer { position: absolute; z-index: 2; left: 0; right: 0; }
+.camkit header { z-index: 4; }
+.camkit header { top: 0; display: flex; align-items: center; gap: 18px; padding: 18px 24px; }
+.camkit header .clock { margin-left: auto; font-variant-numeric: tabular-nums; }
+.camkit .brand { font-size: 15px; letter-spacing: .02em; }.camkit .brand strong { color: var(--accent); }
+.camkit .icon { display: grid; place-items: center; width: 48px; height: 48px; flex: 0 0 48px; background: #fff2; border-radius: 50%; font-size: 26px; backdrop-filter: blur(12px); }
+.camkit .icon:hover, .camkit .retake:hover { background: #fff4; }
+.camkit .close { font-size: 32px; line-height: 1; }
+.camkit footer { bottom: 0; padding: 24px 28px max(22px, env(safe-area-inset-bottom)); }
+.camkit .tools { display: flex; justify-content: center; gap: 26px; margin-bottom: 20px; }
+.camkit .tools .timer { font-size: 15px; font-weight: 700; }
+.camkit .shutter-row { display: grid; grid-template-columns: minmax(0, 1fr) 86px minmax(0, 1fr); align-items: center; gap: 12px; }
+.camkit .mode-label { justify-self: end; font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .14em; }
+.camkit .limit { justify-self: start; font-size: 13px; font-variant-numeric: tabular-nums; }
+.camkit .shutter { width: 86px; height: 86px; border: 4px solid #fff; padding: 7px; border-radius: 50%; background: conic-gradient(var(--accent) var(--progress, 0%), #ffffff50 0); box-shadow: 0 0 0 3px #10151a; transition: transform .15s; }
+.camkit .shutter:hover { transform: scale(1.05); }
+.camkit .shutter span { display: block; width: 100%; height: 100%; background: #fff; border-radius: 50%; transition: border-radius .2s, transform .2s, background .2s; }
+.camkit .shutter.recording span { transform: scale(.54); border-radius: 12px; background: #fb514d; }
+.camkit .hint { min-height: 20px; text-align: center; margin-top: 12px; color: #e4e9ed; font-size: 13px; }
+.camkit .permission { position: absolute; z-index: 3; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 32px; text-align: center; background: radial-gradient(circle at 50% 30%, #23434b, #10151a 70%); }
+.camkit .permission[hidden], .camkit .review[hidden], .camkit .countdown[hidden], .camkit [hidden] { display: none !important; }
+.camkit .permission-mark { display: grid; place-items: center; width: 88px; height: 88px; border: 2px solid var(--accent); border-radius: 50%; font-size: 58px; line-height: 1; color: var(--accent); }
+.camkit .permission h2 { font-size: clamp(26px, 5vw, 42px); margin: 22px 0 8px; }
+.camkit .permission p { max-width: 380px; color: #d6e0e4; margin: 0 0 26px; }
+.camkit .allow, .camkit .use { min-height: 48px; padding: 11px 28px; border-radius: 30px; background: var(--accent); color: #052227; font-weight: 800; }
+.camkit .allow:hover, .camkit .use:hover { background: #b1fff3; }
+.camkit .message { position: absolute; z-index: 5; top: 90px; left: 16px; right: 16px; text-align: center; color: #fff; text-shadow: 0 1px 5px #000; pointer-events: none; }
+.camkit .countdown { position: absolute; z-index: 4; inset: 0; display: grid; place-items: center; background: #0008; font-size: 100px; font-weight: 800; }
+.camkit .review { position: absolute; z-index: 3; inset: 0; background: #10151a; }
+.camkit .play { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); display: grid; place-items: center; width: 64px; height: 64px; border-radius: 50%; background: #071016b8; font-size: 27px; backdrop-filter: blur(12px); }
+.camkit .review-actions { position: absolute; left: 0; right: 0; bottom: max(24px, env(safe-area-inset-bottom)); display: flex; align-items: center; justify-content: center; gap: 16px; padding: 0 20px; }
+.camkit .retake { min-height: 48px; padding: 10px 25px; border-radius: 30px; background: #fff3; backdrop-filter: blur(12px); }
+.camkit .use:disabled { opacity: .55; cursor: wait; }
+.camkit .trim { position: absolute; left: 50%; bottom: 105px; transform: translateX(-50%); width: min(90%, 520px); padding: 15px 20px; border-radius: 18px; background: #091217dd; backdrop-filter: blur(12px); }
+.camkit .trim-head { display: flex; justify-content: space-between; gap: 10px; margin-bottom: 12px; font-size: 14px; }
+.camkit .trim label { display: flex; align-items: center; gap: 10px; font-size: 13px; }
+.camkit .trim input { width: 100%; min-height: 30px; accent-color: var(--accent); }
+@media (max-width: 700px) {
+ .camkit .stage { width: 100vw; height: 100dvh; min-height: 0; border-radius: 0; box-shadow: none; }
+ .camkit .preview, .camkit .review video, .camkit .review img { object-fit: cover; }
+ .camkit header { padding: max(14px, env(safe-area-inset-top)) 15px 12px; }
+ .camkit footer { padding: 18px 14px max(22px, env(safe-area-inset-bottom)); }
+ .camkit .tools { gap: 18px; }
+ .camkit .brand { font-size: 13px; }
+ .camkit .trim { bottom: 100px; }
+}
+
+/* Real stroke icons in the controls (11-09): sized by the button, white on the glass. */
+.camkit .icon svg { width: 22px; height: 22px; display: block; }
+.camkit .play svg { display: block; }
+.camkit .permission-mark svg { display: block; margin: 0 auto; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,260 @@
@@ -0,0 +1,260 @@
+/* OpenVibe.Cam's browser recorder. It runs on the calling page and gives that page a File. */
+(function (win) {
+ 'use strict';
+ if (!win || win.OpenVibeCam) return;
+
+ var script = win.document.currentScript;
+ var cssUrl = script && script.src ? new URL('cam-kit.css', script.src).href : 'https://openvibe.cam/embed/cam-kit.css';
+ var active = null;
+ var MODES = ['video', 'photo', 'screen'];
+
+ function optionsFor(input) {
+ var o = input || {};
+ if (MODES.indexOf(o.mode || 'video') < 0) throw new TypeError('mode must be video, photo or screen');
+ var seconds = Number(o.maxSeconds);
+ if (!Number.isFinite(seconds) || seconds <= 0) seconds = 60;
+ return { mode: o.mode || 'video', maxSeconds: Math.min(3600, Math.max(1, Math.round(seconds))), onDone: o.onDone, timer: [0, 3, 10].indexOf(Number(o.timer)) >= 0 ? Number(o.timer) : 0 };
+ }
+
+ function supported(mode, nav) {
+ if (mode === 'screen') return !!(nav.mediaDevices && nav.mediaDevices.getDisplayMedia && win.MediaRecorder);
+ return !!(nav.mediaDevices && nav.mediaDevices.getUserMedia && (mode === 'photo' || win.MediaRecorder));
+ }
+
+ function mime() {
+ if (!win.MediaRecorder) return '';
+ return ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4'].find(function (type) {
+ return win.MediaRecorder.isTypeSupported && win.MediaRecorder.isTypeSupported(type);
+ }) || '';
+ }
+
+ function stopTracks(stream) {
+ if (stream) stream.getTracks().forEach(function (track) { track.stop(); });
+ }
+
+ function secondsText(value) {
+ var s = Math.max(0, Math.floor(value));
+ return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
+ }
+
+ function open(input) {
+ var opts = optionsFor(input);
+ if (active) return Promise.reject(new Error('A camera is already open.'));
+ return new Promise(function (resolve, reject) {
+ var host = win.document.createElement('div');
+ host.id = 'openvibe-cam-kit';
+ var shadow = host.attachShadow({ mode: 'open' });
+ var link = win.document.createElement('link');
+ link.rel = 'stylesheet'; link.href = cssUrl;
+ shadow.appendChild(link);
+ var wrap = win.document.createElement('div');
+ wrap.className = 'camkit';
+ wrap.setAttribute('role', 'dialog');
+ wrap.setAttribute('aria-modal', 'true');
+ wrap.setAttribute('aria-label', 'OpenVibe camera');
+ wrap.innerHTML = '<div class="stage"><video class="preview" autoplay playsinline muted></video><div class="shade"></div>' +
+ '<header><button class="icon close" aria-label="Close camera"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg></button><span class="brand">OpenVibe <strong>Cam</strong></span><span class="clock">0:00</span></header>' +
+ '<div class="message" role="status"></div><div class="permission"><div class="permission-mark"><svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 8h3.5L9 5.5h6L16.5 8H20v11H4zM12 10.5a3 3 0 1 1 0 6 3 3 0 0 1 0-6z"/></svg></div><h2>Ready when you are</h2><p></p><button class="allow">Continue</button></div>' +
+ '<div class="countdown" hidden></div><div class="review" hidden><video playsinline></video><img alt="Photo preview" hidden><button class="play" aria-label="Play clip"><svg viewBox="0 0 24 24" width="30" height="30" fill="currentColor" aria-hidden="true"><path d="M8 5.5v13l11-6.5z"/></svg></button><div class="trim" hidden><div class="trim-head"><strong>Trim your clip</strong><span class="trim-label"></span></div><label>Start <input class="trim-start" type="range" min="0" step="0.1"></label><label>End <input class="trim-end" type="range" min="0" step="0.1"></label></div><div class="review-actions"><button class="retake">Retake</button><button class="use">Use this</button></div></div>' +
+ '<footer><div class="tools"><button class="icon flip" aria-label="Flip camera" title="Flip camera"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 8h3l1.5-2h7L17 8h3v11H4zM9.5 13.5a2.5 2.5 0 0 1 4.6-1.4M14.5 12.5a2.5 2.5 0 0 1-4.6 1.4M14.4 10.6v1.6h-1.6M9.6 15.4v-1.6h1.6"/></svg></button><button class="icon torch" aria-label="Toggle torch" title="Toggle torch"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13 3L6 13.5h5L10 21l8-11h-5.2z"/></svg></button><button class="icon timer" aria-label="Set timer" title="Set timer">0s</button></div><div class="shutter-row"><span class="mode-label"></span><button class="shutter" aria-label="Take photo or start recording"><span></span></button><span class="limit"></span></div><div class="hint"></div></footer></div>';
+ shadow.appendChild(wrap);
+ win.document.body.appendChild(host);
+ var $ = function (s) { return shadow.querySelector(s); };
+ var preview = $('.preview');
+ var review = $('.review');
+ var reviewVideo = $('.review video');
+ var reviewImage = $('.review img');
+ var stream = null, recorder = null, chunks = [], blob = null, url = null, started = 0, tick = null, autoStop = null;
+ var facing = 'user', torch = false, phase = 'permission', done = false, lastFocus = win.document.activeElement;
+ var duration = 0, trimStart = 0, trimEnd = 0, timer = opts.timer;
+ active = host;
+ $('.mode-label').textContent = opts.mode === 'photo' ? 'Photo' : opts.mode === 'screen' ? 'Screen' : 'Video';
+ $('.limit').textContent = opts.mode === 'photo' ? '' : secondsText(opts.maxSeconds) + ' max';
+ $('.permission p').textContent = opts.mode === 'screen' ? 'Choose the screen or window to record. Your browser will ask what to share.' : opts.mode === 'photo' ? 'Allow camera access to take a photo. Your photo stays here until you choose Use this.' : 'Allow camera and microphone access to record. Your clip stays here until you choose Use this.';
+ $('.hint').textContent = opts.mode === 'photo' ? 'Tap the shutter for a photo' : 'Tap to start and stop · Space to record';
+ $('.timer').textContent = timer + 's';
+ $('.flip').hidden = opts.mode === 'screen';
+ $('.torch').hidden = opts.mode === 'screen';
+ $('.timer').hidden = opts.mode === 'screen';
+ $('.allow').focus();
+
+ function say(message) { $('.message').textContent = message || ''; }
+ function cleanRecording() { win.clearInterval(tick); win.clearTimeout(autoStop); tick = null; autoStop = null; }
+ function clearBlob() { if (url) win.URL.revokeObjectURL(url); url = null; blob = null; reviewVideo.removeAttribute('src'); reviewImage.removeAttribute('src'); }
+ function close(result, error) {
+ if (done) return;
+ done = true; cleanRecording();
+ if (recorder && recorder.state !== 'inactive') recorder.stop();
+ stopTracks(stream); stream = null; clearBlob();
+ win.document.removeEventListener('keydown', keydown, true);
+ host.remove(); active = null;
+ if (lastFocus && lastFocus.focus) lastFocus.focus();
+ if (error) reject(error); else resolve(result || null);
+ }
+ function permissionError(err) {
+ var denied = err && (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError');
+ say(denied ? 'Permission was not granted. You can try again.' : (err && err.message) || 'This camera is unavailable.');
+ phase = 'permission'; $('.permission').hidden = false;
+ }
+ function updateTorch() {
+ var track = stream && stream.getVideoTracks()[0];
+ var caps = track && track.getCapabilities ? track.getCapabilities() : {};
+ $('.torch').hidden = !caps.torch;
+ if (caps.torch) $('.torch').setAttribute('aria-pressed', String(torch));
+ }
+ async function start() {
+ if (!supported(opts.mode, win.navigator)) { say('This browser cannot use the selected camera mode.'); return; }
+ try {
+ var next = opts.mode === 'screen' ? await win.navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }) : await win.navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: facing } }, audio: opts.mode === 'video' });
+ if (done) { stopTracks(next); return; }
+ stopTracks(stream); stream = next;
+ preview.srcObject = stream; await preview.play();
+ if (done) return;
+ var videoTrack = stream.getVideoTracks()[0];
+ if (videoTrack) videoTrack.addEventListener('ended', function () { if (phase === 'recording') stopRecord(); else if (!done) say('Sharing ended.'); });
+ $('.permission').hidden = true; phase = 'ready'; say(''); updateTorch();
+ } catch (err) { permissionError(err); }
+ }
+ function countdown() {
+ if (!timer) return Promise.resolve();
+ phase = 'countdown';
+ return new Promise(function (resolveCountdown) {
+ var left = timer, el = $('.countdown'); el.hidden = false; el.textContent = left;
+ var id = win.setInterval(function () {
+ if (done || phase !== 'countdown') { win.clearInterval(id); el.hidden = true; resolveCountdown(false); return; }
+ left -= 1;
+ if (!left) { win.clearInterval(id); el.hidden = true; phase = 'ready'; resolveCountdown(true); }
+ else el.textContent = left;
+ }, 1000);
+ });
+ }
+ function showReview(media, length) {
+ blob = media; duration = length || 0; trimStart = 0; trimEnd = duration;
+ stopTracks(stream); stream = null; preview.srcObject = null;
+ url = win.URL.createObjectURL(blob);
+ var photo = opts.mode === 'photo';
+ reviewImage.hidden = !photo; reviewVideo.hidden = photo; $('.play').hidden = photo;
+ if (photo) reviewImage.src = url; else reviewVideo.src = url;
+ $('.trim').hidden = photo || duration < 0.2;
+ $('.trim-start').max = String(duration); $('.trim-end').max = String(duration);
+ $('.trim-start').value = '0'; $('.trim-end').value = String(duration);
+ updateTrim(); review.hidden = false; phase = 'review';
+ $('.use').focus();
+ }
+ async function capturePhoto() {
+ var canvas = win.document.createElement('canvas');
+ canvas.width = preview.videoWidth || 1280; canvas.height = preview.videoHeight || 720;
+ canvas.getContext('2d').drawImage(preview, 0, 0, canvas.width, canvas.height);
+ var image = await new Promise(function (resolveImage) { canvas.toBlob(resolveImage, 'image/jpeg', 0.92); });
+ if (!image) throw new Error('The photo could not be encoded.');
+ showReview(image, 0);
+ }
+ function stopRecord() {
+ if (phase !== 'recording' || !recorder) return;
+ phase = 'stopping'; cleanRecording(); recorder.stop();
+ }
+ function record() {
+ chunks = [];
+ var type = mime(); recorder = new win.MediaRecorder(stream, type ? { mimeType: type } : undefined);
+ recorder.ondataavailable = function (event) { if (event.data && event.data.size) chunks.push(event.data); };
+ recorder.onerror = function () { say('Recording failed. Please retake.'); };
+ recorder.onstop = function () {
+ if (done || phase !== 'stopping') return;
+ var length = Math.min(opts.maxSeconds, (Date.now() - started) / 1000);
+ var recorded = new Blob(chunks, { type: recorder.mimeType || type || 'video/webm' });
+ if (!recorded.size) { phase = 'ready'; say('No video was recorded. Try again.'); return; }
+ showReview(recorded, length);
+ };
+ recorder.start(200); started = Date.now(); phase = 'recording';
+ $('.shutter').classList.add('recording');
+ $('.shutter').style.setProperty('--progress', '0%');
+ tick = win.setInterval(function () {
+ var elapsed = Math.min(opts.maxSeconds, (Date.now() - started) / 1000);
+ $('.clock').textContent = secondsText(elapsed);
+ $('.shutter').style.setProperty('--progress', (100 * elapsed / opts.maxSeconds) + '%');
+ }, 100);
+ autoStop = win.setTimeout(stopRecord, opts.maxSeconds * 1000);
+ }
+ async function shutter() {
+ if (phase === 'recording') { stopRecord(); return; }
+ if (phase !== 'ready') return;
+ if (!(await countdown())) { if (timer) return; }
+ if (done || phase !== 'ready') return;
+ try { if (opts.mode === 'photo') await capturePhoto(); else record(); }
+ catch (err) { phase = 'ready'; say(err.message || 'Capture failed.'); }
+ }
+ function updateTrim() {
+ var startInput = $('.trim-start'), endInput = $('.trim-end');
+ trimStart = Math.min(Number(startInput.value), Math.max(0, Number(endInput.value) - 0.1));
+ trimEnd = Math.max(Number(endInput.value), trimStart + 0.1);
+ startInput.value = String(trimStart); endInput.value = String(trimEnd);
+ $('.trim-label').textContent = secondsText(trimStart) + ' – ' + secondsText(trimEnd);
+ }
+ async function trimmedBlob() {
+ if (trimStart <= 0 && trimEnd >= duration - 0.1) return blob;
+ if (!reviewVideo.captureStream || !win.MediaRecorder) throw new Error('This browser cannot export a trimmed clip.');
+ say('Preparing your trimmed clip…');
+ reviewVideo.pause(); reviewVideo.currentTime = trimStart;
+ await new Promise(function (resolveSeek, rejectSeek) {
+ if (Math.abs(reviewVideo.currentTime - trimStart) < 0.05 && reviewVideo.readyState >= 2) return resolveSeek();
+ reviewVideo.addEventListener('seeked', resolveSeek, { once: true });
+ reviewVideo.addEventListener('error', rejectSeek, { once: true });
+ });
+ var captured = reviewVideo.captureStream();
+ if (!captured.getVideoTracks().length) throw new Error('This browser cannot export a trimmed clip.');
+ var pieces = [], type = mime(), cutter = new win.MediaRecorder(captured, type ? { mimeType: type } : undefined);
+ var output = new Promise(function (resolveCut, rejectCut) {
+ cutter.ondataavailable = function (event) { if (event.data && event.data.size) pieces.push(event.data); };
+ cutter.onerror = function () { rejectCut(new Error('The trim could not be exported.')); };
+ cutter.onstop = function () { var cut = new Blob(pieces, { type: cutter.mimeType || type || blob.type }); cut.size ? resolveCut(cut) : rejectCut(new Error('The trim was empty.')); };
+ });
+ cutter.start(200); await reviewVideo.play();
+ await new Promise(function (resolvePlay) {
+ var until = win.setInterval(function () { if (reviewVideo.currentTime >= trimEnd || reviewVideo.ended || done) { win.clearInterval(until); resolvePlay(); } }, 40);
+ });
+ cutter.stop(); reviewVideo.pause();
+ return output;
+ }
+ function keydown(event) {
+ if (event.key === 'Escape') { event.preventDefault(); close(); }
+ if (event.code === 'Space' && event.target.tagName !== 'BUTTON' && event.target.tagName !== 'INPUT' && phase !== 'review') { event.preventDefault(); shutter(); }
+ }
+ $('.close').onclick = function () { close(); };
+ $('.allow').onclick = start;
+ $('.shutter').onclick = shutter;
+ $('.flip').onclick = async function () {
+ if (phase !== 'ready') return;
+ facing = facing === 'user' ? 'environment' : 'user'; torch = false; await start();
+ };
+ $('.torch').onclick = async function () {
+ var track = stream && stream.getVideoTracks()[0]; if (!track) return;
+ try { torch = !torch; await track.applyConstraints({ advanced: [{ torch: torch }] }); updateTorch(); }
+ catch { torch = false; say('Torch is unavailable on this camera.'); }
+ };
+ $('.timer').onclick = function () { timer = timer === 0 ? 3 : timer === 3 ? 10 : 0; $('.timer').textContent = timer + 's'; };
+ $('.trim-start').oninput = updateTrim; $('.trim-end').oninput = updateTrim;
+ $('.play').onclick = function () { if (reviewVideo.paused) reviewVideo.play(); else reviewVideo.pause(); };
+ // The play button's own icon: a filled triangle, two bars while it plays (an SVG path, never a text glyph).
+ var playPath = function (d, label) { var p = $('.play path'); if (p) p.setAttribute('d', d); $('.play').setAttribute('aria-label', label); };
+ reviewVideo.onplay = function () { playPath('M7 5h3.5v14H7zM13.5 5H17v14h-3.5z', 'Pause clip'); };
+ reviewVideo.onpause = function () { playPath('M8 5.5v13l11-6.5z', 'Play clip'); };
+ $('.retake').onclick = function () { clearBlob(); review.hidden = true; $('.shutter').classList.remove('recording'); $('.clock').textContent = '0:00'; phase = 'permission'; $('.permission').hidden = false; start(); };
+ $('.use').onclick = async function () {
+ if (phase !== 'review') return;
+ phase = 'exporting'; $('.use').disabled = true;
+ try {
+ var output = opts.mode === 'photo' ? blob : await trimmedBlob();
+ var ext = opts.mode === 'photo' ? 'jpg' : output.type.indexOf('mp4') >= 0 ? 'mp4' : 'webm';
+ var file = new File([output], 'openvibe-' + opts.mode + '-' + Date.now() + '.' + ext, { type: output.type });
+ var result = { file: file, mode: opts.mode, mimeType: output.type, duration: opts.mode === 'photo' ? 0 : trimEnd - trimStart, width: opts.mode === 'photo' ? reviewImage.naturalWidth : reviewVideo.videoWidth, height: opts.mode === 'photo' ? reviewImage.naturalHeight : reviewVideo.videoHeight, maxSeconds: opts.maxSeconds };
+ if (typeof opts.onDone === 'function') await opts.onDone(result);
+ close(result);
+ } catch (err) { phase = 'review'; $('.use').disabled = false; say(err.message || 'The file could not be prepared.'); }
+ };
+ win.document.addEventListener('keydown', keydown, true);
+ });
+ }
+
+ win.OpenVibeCam = Object.freeze({ open: open });
+ if (typeof module === 'object' && module.exports) module.exports = { optionsFor: optionsFor, supported: supported, secondsText: secondsText };
+})(typeof window !== 'undefined' ? window : null);
@@ -7,6 +7,15 @@
@@ -7,6 +7,15 @@
var status = document.getElementById('cm-ov-demo-status');
var status = document.getElementById('cm-ov-demo-status');
if (!demo || !shots) return;
if (!demo || !shots) return;
+ var kitDemo = document.getElementById('cm-kit-demo');
+ if (kitDemo) kitDemo.addEventListener('click', async function () {
+ var status = document.getElementById('cm-kit-status');
+ try {
+ var result = await window.OpenVibeCam.open({ mode: 'video', maxSeconds: 30 });
+ if (status) status.textContent = result ? 'Ready: ' + result.file.name + ' (' + Math.round(result.file.size / 1024) + ' KB). It stayed on this page.' : 'Cancelled.';
+ } catch (err) { if (status) status.textContent = err.message || 'The recorder could not open.'; }
+ });
+
function say(text) { if (status) status.textContent = text; }
function say(text) { if (status) status.textContent = text; }
document.addEventListener('ov-camera:capture', function (e) {
document.addEventListener('ov-camera:capture', function (e) {
@@ -39,7 +39,7 @@ function createAgentPages({ config }) {
@@ -39,7 +39,7 @@ function createAgentPages({ config }) {
name: 'The OpenVibe.Cam API and camera element', url: `${base()}/developers`,
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(),
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'],
+ scripts: ['embed/cam-kit.js', 'js/developers.js'],
body: raw(views.developers({ base: base() })),
body: raw(views.developers({ base: base() })),
});
});
}
}
@@ -105,7 +105,7 @@ async function createApp(opts = {}) {
@@ -105,7 +105,7 @@ async function createApp(opts = {}) {
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
}));
// The camera app is same-origin only; there is no microphone in v1.
// The camera app is same-origin only; there is no microphone in v1.
- app.use((_req, res, next) => { res.set('Permissions-Policy', 'camera=(self), microphone=(), geolocation=(), payment=()'); next(); });
+ app.use((_req, res, next) => { res.set('Permissions-Policy', 'camera=(self), microphone=(self), display-capture=(self), geolocation=(), payment=()'); next(); });
app.use(cookieParser());
app.use(cookieParser());
// ── Machine endpoints ───────────────────────────────────
// ── Machine endpoints ───────────────────────────────────
@@ -142,7 +142,10 @@ async function createApp(opts = {}) {
@@ -142,7 +142,10 @@ async function createApp(opts = {}) {
res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));
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
// 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.
// 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');
+ if (rel.startsWith('embed/')) {
+ res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ }
},
},
}));
}));
@@ -198,7 +198,7 @@ function createPageRoutes(ctx) {
@@ -198,7 +198,7 @@ function createPageRoutes(ctx) {
index: true, cache: PUBLIC_CACHE, title: 'The OpenVibe.Cam API and camera element',
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.',
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() })],
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'],
+ scripts: ['embed/cam-kit.js', 'js/developers.js'],
body: raw(views.developers({ base: base() })),
body: raw(views.developers({ base: base() })),
}));
}));
@@ -17,6 +17,9 @@
@@ -17,6 +17,9 @@
* notFound()
* notFound()
*/
*/
const { esc, time } = require('../render/html');
const { esc, time } = require('../render/html');
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
const sc = require('openvibe-shared/showcase');
const sc = require('openvibe-shared/showcase');
@@ -354,6 +357,15 @@ function signIn({ title, text, next }) {
@@ -354,6 +357,15 @@ function signIn({ title, text, next }) {
}
}
function developers({ base = 'https://openvibe.cam' } = {}) {
function developers({ base = 'https://openvibe.cam' } = {}) {
+ const sri = 'sha384-' + crypto.createHash('sha384').update(fs.readFileSync(path.join(__dirname, '../../public/embed/cam-kit.js'))).digest('base64');
+ const kitSnippet = `<script src="${base}/embed/cam-kit.js" integrity="${sri}" crossorigin="anonymous" defer></script>
+<button id="record">Record a clip</button>
+<script>
+ document.getElementById('record').addEventListener('click', async function () {
+ const result = await OpenVibeCam.open({ mode: 'video', maxSeconds: 60 });
+ if (result) { /* result.file is yours to upload or preview */ }
+ });
+</script>`;
const ep = (m, p, d) => `<div><dt><code>${esc(m)} ${esc(p)}</code></dt><dd>${d}</dd></div>`;
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 row = (cells) => `<tr>${cells.map((c) => `<td>${c}</td>`).join('')}</tr>`;
const snippet = `<script src="${base}/embed/ov-camera.js" async></script>
const snippet = `<script src="${base}/embed/ov-camera.js" async></script>
@@ -371,6 +383,13 @@ function developers({ base = 'https://openvibe.cam' } = {}) {
@@ -371,6 +383,13 @@ function developers({ base = 'https://openvibe.cam' } = {}) {
<header class="cm-page-h"><p class="cm-kicker">Developers</p><h1>The Cam API and the camera element</h1>
<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><ov-camera></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>
<p class="cm-lede">The camera app is also one framework-free element: <code><ov-camera></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-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>
+<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>
+</section>
+
<section id="cm-embed"><h2>The <ov-camera> element</h2>
<section id="cm-embed"><h2>The <ov-camera> 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>
<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>
<pre class="cm-code"><code>${esc(snippet)}</code></pre>
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const vm = require('vm');
+const path = require('path');
+
+const source = fs.readFileSync(path.join(__dirname, '../public/embed/cam-kit.js'), 'utf8');
+const window = { document: { currentScript: { src: 'https://openvibe.cam/embed/cam-kit.js' } }, navigator: { mediaDevices: {} }, URL };
+vm.runInNewContext(source, { window, URL, Blob, File, Promise, Number, String, Date, setTimeout, clearTimeout, setInterval, clearInterval });
+
+assert.strictEqual(typeof window.OpenVibeCam.open, 'function');
+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, '')));
+console.log('cam-kit API shape and standalone script: ok');
@@ -39,7 +39,7 @@ function jsonLd(html) {
@@ -39,7 +39,7 @@ function jsonLd(html) {
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
assert.strictEqual(r.headers.get('vary'), 'Cookie');
assert.strictEqual(r.headers.get('vary'), 'Cookie');
- assert.strictEqual(r.headers.get('permissions-policy'), 'camera=(self), microphone=(), geolocation=(), payment=()');
+ assert.strictEqual(r.headers.get('permissions-policy'), 'camera=(self), microphone=(self), display-capture=(self), geolocation=(), payment=()');
});
});
await check('/app has the camera controls and the gallery', async () => {
await check('/app has the camera controls and the gallery', async () => {
@@ -113,7 +113,7 @@ function jsonLd(html) {
@@ -113,7 +113,7 @@ function jsonLd(html) {
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
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',
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);
+ 'id="cm-kit"', 'OpenVibeCam.open', 'sha384-', 'id="cm-kit-demo"', '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 embedScript = r.text.indexOf('/embed/ov-camera.js?v=');
const camScript = r.text.indexOf('/js/cam.js?v=');
const camScript = r.text.indexOf('/js/cam.js?v=');
assert.ok(embedScript > 0 && camScript > embedScript, 'the element loads before the camera app');
assert.ok(embedScript > 0 && camScript > embedScript, 'the element loads before the camera app');
@@ -144,6 +144,10 @@ function jsonLd(html) {
@@ -144,6 +144,10 @@ function jsonLd(html) {
assert.match(embed.headers.get('content-type'), /javascript/);
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('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.strictEqual(embed.headers.get('cross-origin-resource-policy'), 'cross-origin', 'another site may load the element');
+ assert.strictEqual(embed.headers.get('access-control-allow-origin'), '*', 'a pinned cross-origin script needs CORS');
+ const kit = await t.get('/embed/cam-kit.js');
+ assert.strictEqual(kit.status, 200);
+ assert.strictEqual(kit.headers.get('access-control-allow-origin'), '*');
assert.ok(embed.text.includes("define('ov-camera'"), 'the embed registers the element');
assert.ok(embed.text.includes("define('ov-camera'"), 'the embed registers the element');
assert.ok(embed.text.includes('ov-camera:capture'), 'the embed emits the capture event');
assert.ok(embed.text.includes('ov-camera:capture'), 'the embed emits the capture event');
const plainEmbed = await t.get('/embed/ov-camera.js');
const plainEmbed = await t.get('/embed/ov-camera.js');