openvibers/openvibe.cam Commits

Reusable in-page camera recorder and review flow (#111)

OP OpenVibers committed 4 days ago · 002c7834af · 10 files, +394 −6

* Add reusable in-page camera recorder and review flow

* Cam kit: real stroke icons for close, flip, torch, play and the permission mark (no text glyphs)

Changes

ddiff --git a/README.md b/README.md
iindex fab9124..672dc91 100644
---- a/README.md
++++ b/README.md
@@@ -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 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.
ddiff --git a/public/embed/cam-kit.css b/public/embed/cam-kit.css
nnew file mode 100644
iindex 0000000..4e217c8
---- /dev/null
++++ b/public/embed/cam-kit.css
@@@ -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; }
ddiff --git a/public/embed/cam-kit.js b/public/embed/cam-kit.js
nnew file mode 100644
iindex 0000000..a0d68bc
---- /dev/null
++++ b/public/embed/cam-kit.js
@@@ -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);
ddiff --git a/public/js/developers.js b/public/js/developers.js
iindex 597c5bb..740744f 100644
---- a/public/js/developers.js
++++ b/public/js/developers.js
@@@ -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) {
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
iindex 7cc2128..c9b1f58 100644
---- a/server/agent-pages.js
++++ b/server/agent-pages.js
@@@ -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() })),
});
}
ddiff --git a/server/app.js b/server/app.js
iindex 5403650..39e6c09 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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', '*');
++ }
},
}));
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 9414447..c06ec19 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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() })),
}));
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 20047a1..3794b96 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -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>&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-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 &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>
ddiff --git a/test/cam-kit.test.js b/test/cam-kit.test.js
nnew file mode 100644
iindex 0000000..e965e05
---- /dev/null
++++ b/test/cam-kit.test.js
@@@ -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');
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex 80cd679..3a69b8d 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -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');