openvibers/openvibe.cam Commits

Cam: hand a saved photo to Studio's editor (#133)

OP OpenVibers committed 3 days ago · b2eb6fcca7 · 7 files, +54 −15

Adds Edit in Studio to each of your photo cards and to the capture result after a photo is saved. The link opens openvibe.studio/edit?pics=<slug>&from=cam; Studio asks OpenVibe.Pics with the person's own token whether they may read it. CAM_STUDIO_URL moves the link for a drill.

Changes

ddiff --git a/.env.example b/.env.example
iindex b3f5d46..771be74 100644
---- a/.env.example
++++ b/.env.example
@@@ -50,6 +50,8 @@ CAM_UPSTREAM_TIMEOUT_MS=8000
# ── Photos, stills, cameras and grants ──
CAM_PHOTO_MAX_BYTES=12582912
++# Where a saved photo's "Edit in Studio" goes.
++CAM_STUDIO_URL=https://openvibe.studio
CAM_STILL_MAX_BYTES=8388608
CAM_STILL_WIDTH=640
CAM_MAX_CAMERAS=20
ddiff --git a/README.md b/README.md
iindex a68931c..0776d1d 100644
---- a/README.md
++++ b/README.md
@@@ -16,6 +16,10 @@ private to its owner, and a viewer sees it only while the grant the owner gave i
softness. The filter runs on the canvas in the person's browser; the server never applies an effect. The picture
is posted to Cam, which forwards it to OpenVibe.Pics with the person's own Network token. There is no server-side
image analysis and no face recognition.
++- **Edit in Studio.** A photo you saved opens in Studio with `GET /edit?pics=<Pics slug>&from=cam` on
++ `openvibe.studio`; Studio asks OpenVibe.Pics, with your own token, whether you may read it, then loads it into
++ Photo with `From OpenVibe.Cam` beside it. The link is on each of your photo cards and on the capture result; a
++ photo of someone else's answers 404. `CAM_STUDIO_URL` moves the link for a drill.
- **My cameras.** Pair an old phone, a browser, a Pi or an IP camera you own. If a Live stream id is given, Cam
reads it from OpenVibe.Live's public API and refuses a stream on somebody else's channel; the slot defaults to the
stream's managed slot. Its wall is private: `/w/<id>` answers its owner and an active grantee, and 404 to
ddiff --git a/public/js/cam.js b/public/js/cam.js
iindex f41250c..91e1f03 100644
---- a/public/js/cam.js
++++ b/public/js/cam.js
@@@ -20,6 +20,10 @@
// The camera on the page now. Leaving the page, for good or for another one swapped in place (boost), turns it off.
var stopCamera = null;
function off() { if (stopCamera) { stopCamera(); stopCamera = null; } }
++ function studioBase() {
++ var camera = document.getElementById('cm-camera');
++ return (camera && camera.getAttribute('data-cm-studio-edit')) || '';
++ }
window.addEventListener('pagehide', off);
document.addEventListener('ov:boost:before', off);
@@@ -129,6 +133,14 @@
img.loading = 'lazy';
a.appendChild(img);
li.appendChild(a);
++ var studioBase = root.getAttribute('data-cm-studio-edit') || '';
++ if (studioBase && p.pics_slug) {
++ var edit = document.createElement('a');
++ edit.className = 'cm-edit-studio';
++ edit.href = studioBase + encodeURIComponent(p.pics_slug) + '&from=cam';
++ edit.textContent = 'Edit in Studio';
++ li.appendChild(edit);
++ }
list.insertBefore(li, list.firstChild);
// The thumb shows the newest photo, with a small pop.
if (thumb) {
@@@ -151,6 +163,12 @@
if (!res.ok) { say((body && body.detail) || 'The photo was not saved.'); return null; }
say('Saved to OpenVibe.Pics.');
addToGallery(body);
++ if (statusEl && studioBase() && body && body.pics_slug) {
++ var edit = document.createElement('a');
++ edit.href = studioBase() + encodeURIComponent(body.pics_slug) + '&from=cam';
++ edit.textContent = ' Edit in Studio';
++ statusEl.appendChild(edit);
++ }
return body;
});
})
ddiff --git a/server/config.js b/server/config.js
iindex e9a6c3a..ccc21be 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -20,6 +20,8 @@ function load(env = process.env) {
return {
service: 'cam',
++ // Where "Edit in Studio" goes for a saved photo (the hand-off route).
++ studioUrl: trim(env.CAM_STUDIO_URL || 'https://openvibe.studio'),
port,
host: env.HOST || '127.0.0.1',
nodeEnv,
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex c06ec19..50217d4 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -57,7 +57,7 @@ function createPageRoutes(ctx) {
return page(req, res, {
index: true, cache: PUBLIC_CACHE, title: 'The camera app',
description: 'Take a photo with on-device colour, light and blur, a timer, a burst and a grid; save it to OpenVibe.Pics.',
-- body: raw(views.app({ signedIn: Boolean(signedIn(req)), photos })),
++ body: raw(views.app({ signedIn: Boolean(signedIn(req)), photos, studioUrl: config.studioUrl })),
});
});
@@@ -69,7 +69,7 @@ function createPageRoutes(ctx) {
return page(req, res, {
title: 'My cameras',
description: 'Pair a camera you own, grant one viewer for a week, and revoke it at once.',
-- body: raw(views.my({ viewer: req.viewer, cameras: list, photos, stills, counts, problem })),
++ body: raw(views.my({ viewer: req.viewer, cameras: list, photos, stills, counts, problem, studioUrl: config.studioUrl })),
}, status);
}
@@@ -101,7 +101,7 @@ function createPageRoutes(ctx) {
description: `Manage ${camera.name}: its slot, its viewers and its wall.`,
body: raw(views.camera({
camera, grants: grants.ok ? grants.grants : [], photos: wall ? wall.photos : [],
-- frameUrl: views.frameUrl(config, camera), problem,
++ frameUrl: views.frameUrl(config, camera), problem, studioUrl: config.studioUrl,
})),
}, status);
}
@@@ -170,7 +170,7 @@ function createPageRoutes(ctx) {
return page(req, res, {
title: camera.name,
description: 'A private camera wall on OpenVibe.Cam.',
-- body: raw(views.wall({ camera, access: { owner: wall.owner, grant: wall.grant }, photos: wall.photos, frameUrl: views.frameUrl(config, camera) })),
++ body: raw(views.wall({ camera, access: { owner: wall.owner, grant: wall.grant }, photos: wall.photos, frameUrl: views.frameUrl(config, camera), studioUrl: config.studioUrl })),
});
});
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 6496fc2..34ae56a 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -92,10 +92,13 @@ function shutterMotif() {
</div>`;
}
--function photoCard(p, { owner = false } = {}) {
++function photoCard(p, { owner = false, studioUrl = null } = {}) {
++ const studio = owner && studioUrl && p.pics_slug
++ ? `<a class="cm-edit-studio" href="${esc(String(studioUrl).replace(/\/+$/, ''))}/edit?pics=${encodeURIComponent(p.pics_slug)}&from=cam">Edit in Studio</a>`
++ : '';
return `<li class="cm-photo" data-photo="${esc(p.id)}">
<a class="cm-photo-link" href="${esc(p.pics_url)}" rel="noopener"><img src="${esc(p.pics_direct)}" alt="${esc(p.caption || 'A photo on OpenVibe.Cam')}" loading="lazy" width="320" height="240"></a>
--<span class="cm-photo-meta">${esc(FILTER_LABEL[p.filter] || p.filter || 'None')}${p.caption ? ` · ${esc(p.caption)}` : ''}${owner ? ` · ${day(p.created_at)}` : ''}</span>
++<span class="cm-photo-meta">${esc(FILTER_LABEL[p.filter] || p.filter || 'None')}${p.caption ? ` · ${esc(p.caption)}` : ''}${owner ? ` · ${day(p.created_at)}` : ''}</span>${studio}
</li>`;
}
@@@ -190,13 +193,13 @@ const ICONS = {
};
const FILTER_CSS = { none: 'none', mono: 'grayscale(1) contrast(1.08)', warm: 'sepia(.35) saturate(1.15)', cool: 'hue-rotate(-10deg) saturate(1.1)', fade: 'saturate(.8) contrast(.92) brightness(1.06)', vivid: 'saturate(1.45) contrast(1.06)', blur: 'blur(1.5px)' };
--function app({ signedIn = false, photos = [] } = {}) {
++function app({ signedIn = false, photos = [], studioUrl = 'https://openvibe.studio' } = {}) {
const options = Object.entries(FILTER_LABEL).map(([v, label]) => `<option value="${esc(v)}">${esc(label)}</option>`).join('');
const swatches = Object.entries(FILTER_LABEL).map(([v, label], i) => `<button class="cm-swatch" type="button" role="radio" aria-checked="${i === 0 ? 'true' : 'false'}" data-cm-filter-pick="${esc(v)}"><span class="cm-swatch-chip" style="filter:${esc(FILTER_CSS[v] || 'none')}" aria-hidden="true"></span><span>${esc(label)}</span></button>`).join('');
const last = photos[0];
const thumb = last && last.pics_direct ? `<img src="${esc(last.pics_direct)}" alt="" loading="lazy">` : ICONS.photos;
return `<div class="cm-wrap cm-cam-app" id="cm-app">
--<section class="cm-camera" id="cm-camera" data-cm-camera data-state="off" aria-label="Camera">
++<section class="cm-camera" id="cm-camera" data-cm-camera data-state="off" aria-label="Camera" data-cm-studio-edit="${esc(String(studioUrl).replace(/\/+$/, ''))}/edit?pics=">
<div class="cm-stage">
<video data-cm-preview playsinline muted aria-label="Camera preview"></video>
<canvas data-cm-canvas hidden></canvas>
@@@ -244,12 +247,12 @@ ${signedIn ? '' : '<p class="cm-off-note"><a href="/auth/login?next=%2Fapp">Sign
</section>
<section class="cm-section" id="cm-gallery" aria-labelledby="cm-gallery-h">
<div class="cm-head-row"><h2 id="cm-gallery-h">Your photos</h2>${signedIn ? `<span class="cm-count-label">${photos.length} recent</span>` : ''}</div>
--${signedIn ? (photos.length ? `<ul class="cm-photos">${photos.map((p) => photoCard(p, { owner: true })).join('')}</ul>` : empty('No photo yet. Take one above; it will appear here once Pics has it.', '/app', 'Take a photo')) : empty('Sign in to see the photos you saved to Pics.', '/auth/login?next=%2Fapp', 'Sign in with OpenVibe')}
++${signedIn ? (photos.length ? `<ul class="cm-photos">${photos.map((p) => photoCard(p, { owner: true, studioUrl })).join('')}</ul>` : empty('No photo yet. Take one above; it will appear here once Pics has it.', '/app', 'Take a photo')) : empty('Sign in to see the photos you saved to Pics.', '/auth/login?next=%2Fapp', 'Sign in with OpenVibe')}
</section>
</div>`;
}
--function my({ viewer, cameras = [], photos = [], stills = [], counts = {}, problem = null } = {}) {
++function my({ viewer, cameras = [], photos = [], stills = [], counts = {}, problem = null, studioUrl = null } = {}) {
return `<div class="cm-wrap" id="cm-my">
<header class="cm-page-h"><p class="cm-kicker">My cameras</p><h1>Your cameras, your viewers</h1>
<p class="cm-lede">Pair a camera you own. Its wall is private; a viewer sees it only while the grant you gave is active. ${Number(counts.cameras) || cameras.length} ${Number(counts.cameras) === 1 ? 'camera' : 'cameras'}, ${Number(counts.photos) || photos.length} ${Number(counts.photos) === 1 ? 'photo' : 'photos'}, ${Number(counts.stills) || stills.length} ${Number(counts.stills) === 1 ? 'still' : 'stills'}.</p></header>
@@@ -275,7 +278,7 @@ ${cameras.length ? `<ul class="cm-cameras">${cameras.map(cameraCard).join('')}</
<section class="cm-section" id="cm-photos" aria-labelledby="cm-photos-h">
<div class="cm-head-row"><h2 id="cm-photos-h">Your photos</h2><a class="cm-more" href="/app">Take one${kit.icon('chevron')}</a></div>
--${photos.length ? `<ul class="cm-photos">${photos.map((p) => photoCard(p, { owner: true })).join('')}</ul>` : '<p class="cm-muted">No photo yet.</p>'}
++${photos.length ? `<ul class="cm-photos">${photos.map((p) => photoCard(p, { owner: true, studioUrl })).join('')}</ul>` : '<p class="cm-muted">No photo yet.</p>'}
</section>
<section class="cm-section" id="cm-stills" aria-labelledby="cm-stills-h">
@@@ -285,7 +288,7 @@ ${stills.length ? `<ul class="cm-stills">${stills.map(stillCard).join('')}</ul>`
</div>`;
}
--function camera({ camera: c, grants = [], photos = [], frameUrl: frame = null, problem = null } = {}) {
++function camera({ camera: c, grants = [], photos = [], frameUrl: frame = null, problem = null, studioUrl = null } = {}) {
const active = grants.filter((g) => g.active);
return `<div class="cm-wrap" id="cm-camera-owner" data-camera="${esc(c.id)}">
<header class="cm-page-h"><p class="cm-kicker">${sourceLabel(c.source)} · ${statusBadge(c.status)}</p><h1>${esc(c.name)}</h1>
@@@ -322,12 +325,12 @@ ${grants.length ? `<div class="cm-table-wrap"><table class="cm-grants"><thead><t
<section class="cm-section" id="cm-camera-photos" aria-labelledby="cm-camera-photos-h">
<h2 id="cm-camera-photos-h">On the wall</h2>
--${photos.length ? `<ul class="cm-photos">${photos.map((p) => photoCard(p, { owner: true })).join('')}</ul>` : '<p class="cm-muted">A photo taken with this camera chosen appears here and on the wall for an active viewer.</p>'}
++${photos.length ? `<ul class="cm-photos">${photos.map((p) => photoCard(p, { owner: true, studioUrl })).join('')}</ul>` : '<p class="cm-muted">A photo taken with this camera chosen appears here and on the wall for an active viewer.</p>'}
</section>
</div>`;
}
--function wall({ camera: c, access, photos = [], frameUrl: frame = null } = {}) {
++function wall({ camera: c, access, photos = [], frameUrl: frame = null, studioUrl = null } = {}) {
const owner = access && access.owner;
return `<div class="cm-wrap" id="cm-wall" data-camera="${esc(c.id)}">
<header class="cm-page-h"><p class="cm-kicker">${owner ? 'Your private wall' : `Shared by @${esc(c.owner_username || 'its owner')}`}</p><h1>${esc(c.name)}</h1>
@@@ -337,7 +340,7 @@ ${frame ? `<img src="${esc(frame)}" alt="The camera's current frame" width="640"
</section>
<section class="cm-section" id="cm-wall-photos" aria-labelledby="cm-wall-photos-h">
<h2 id="cm-wall-photos-h">Photos on this wall</h2>
--${photos.length ? `<ul class="cm-photos">${photos.map((p) => photoCard(p, { owner })).join('')}</ul>` : '<p class="cm-muted">The owner has not put a photo on this wall.</p>'}
++${photos.length ? `<ul class="cm-photos">${photos.map((p) => photoCard(p, { owner, studioUrl })).join('')}</ul>` : '<p class="cm-muted">The owner has not put a photo on this wall.</p>'}
</section>
</div>`;
}
ddiff --git a/test/photos.test.js b/test/photos.test.js
iindex ae63583..a7eed6b 100644
---- a/test/photos.test.js
++++ b/test/photos.test.js
@@@ -54,6 +54,16 @@ const { JPEG } = require('./helpers/upstreams');
assert.strictEqual((await t.get('/api/v1/photos', { as: bob })).json().count, 0);
});
++ await check('the capture result and the gallery link the photo to Studio', async () => {
++ const app = await t.get('/app', { as: alice });
++ assert.strictEqual(app.status, 200);
++ assert.ok(app.text.includes(`https://openvibe.studio/edit?pics=${photo.pics_slug}&from=cam`), 'the gallery card links to Studio');
++ assert.ok(app.text.includes('Edit in Studio'), 'the action has its name');
++ assert.ok(app.text.includes(`data-cm-studio-edit="https://openvibe.studio/edit?pics="`), 'the capture result knows where Studio is');
++ const mine = await t.get('/my', { as: alice });
++ assert.ok(mine.text.includes(`https://openvibe.studio/edit?pics=${photo.pics_slug}&from=cam`), 'Your photos links too');
++ });
++
await check('a photo attached to a camera is unlisted and appears on the wall for an active grantee', async () => {
camera = (await t.get('/api/v1/cameras', { as: alice, json: { name: 'Front door', slot: 'whip' } })).json();
const r = await t.get('/api/v1/photos', {