openvibers/openvibe.cam Commits
Cam: hand a saved photo to Studio's editor (#133)
Changes
@@@ -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.studioCAM_STILL_MAX_BYTES=8388608CAM_STILL_WIDTH=640CAM_MAX_CAMERAS=20@@@ -16,6 +16,10 @@ private to its owner, and a viewer sees it only while the grant the owner gave isoftness. The filter runs on the canvas in the person's browser; the server never applies an effect. The pictureis posted to Cam, which forwards it to OpenVibe.Pics with the person's own Network token. There is no server-sideimage 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, Camreads it from OpenVibe.Live's public API and refuses a stream on somebody else's channel; the slot defaults to thestream's managed slot. Its wall is private: `/w/<id>` answers its owner and an active grantee, and 404 to@@@ -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;});})@@@ -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,@@@ -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 })),});});@@@ -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>`;}@@@ -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', {
@@ -50,6 +50,8 @@ CAM_UPSTREAM_TIMEOUT_MS=8000
@@ -50,6 +50,8 @@ CAM_UPSTREAM_TIMEOUT_MS=8000
# ── Photos, stills, cameras and grants ──
# ── Photos, stills, cameras and grants ──
CAM_PHOTO_MAX_BYTES=12582912
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_MAX_BYTES=8388608
CAM_STILL_WIDTH=640
CAM_STILL_WIDTH=640
CAM_MAX_CAMERAS=20
CAM_MAX_CAMERAS=20
@@ -16,6 +16,10 @@ private to its owner, and a viewer sees it only while the grant the owner gave i
@@ -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
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
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.
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
- **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
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
stream's managed slot. Its wall is private: `/w/<id>` answers its owner and an active grantee, and 404 to
@@ -20,6 +20,10 @@
@@ -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.
// 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;
var stopCamera = null;
function off() { if (stopCamera) { stopCamera(); 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);
window.addEventListener('pagehide', off);
document.addEventListener('ov:boost:before', off);
document.addEventListener('ov:boost:before', off);
@@ -129,6 +133,14 @@
@@ -129,6 +133,14 @@
img.loading = 'lazy';
img.loading = 'lazy';
a.appendChild(img);
a.appendChild(img);
li.appendChild(a);
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);
list.insertBefore(li, list.firstChild);
// The thumb shows the newest photo, with a small pop.
// The thumb shows the newest photo, with a small pop.
if (thumb) {
if (thumb) {
@@ -151,6 +163,12 @@
@@ -151,6 +163,12 @@
if (!res.ok) { say((body && body.detail) || 'The photo was not saved.'); return null; }
if (!res.ok) { say((body && body.detail) || 'The photo was not saved.'); return null; }
say('Saved to OpenVibe.Pics.');
say('Saved to OpenVibe.Pics.');
addToGallery(body);
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;
return body;
});
});
})
})
@@ -20,6 +20,8 @@ function load(env = process.env) {
@@ -20,6 +20,8 @@ function load(env = process.env) {
return {
return {
service: 'cam',
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,
port,
host: env.HOST || '127.0.0.1',
host: env.HOST || '127.0.0.1',
nodeEnv,
nodeEnv,
@@ -57,7 +57,7 @@ function createPageRoutes(ctx) {
@@ -57,7 +57,7 @@ function createPageRoutes(ctx) {
return page(req, res, {
return page(req, res, {
index: true, cache: PUBLIC_CACHE, title: 'The camera app',
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.',
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) {
@@ -69,7 +69,7 @@ function createPageRoutes(ctx) {
return page(req, res, {
return page(req, res, {
title: 'My cameras',
title: 'My cameras',
description: 'Pair a camera you own, grant one viewer for a week, and revoke it at once.',
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);
}, status);
}
}
@@ -101,7 +101,7 @@ function createPageRoutes(ctx) {
@@ -101,7 +101,7 @@ function createPageRoutes(ctx) {
description: `Manage ${camera.name}: its slot, its viewers and its wall.`,
description: `Manage ${camera.name}: its slot, its viewers and its wall.`,
body: raw(views.camera({
body: raw(views.camera({
camera, grants: grants.ok ? grants.grants : [], photos: wall ? wall.photos : [],
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);
}, status);
}
}
@@ -170,7 +170,7 @@ function createPageRoutes(ctx) {
@@ -170,7 +170,7 @@ function createPageRoutes(ctx) {
return page(req, res, {
return page(req, res, {
title: camera.name,
title: camera.name,
description: 'A private camera wall on OpenVibe.Cam.',
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 })),
});
});
});
});
@@ -92,10 +92,13 @@ function shutterMotif() {
@@ -92,10 +92,13 @@ function shutterMotif() {
</div>`;
</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)}">
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>
<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>`;
</li>`;
}
}
@@ -190,13 +193,13 @@ const ICONS = {
@@ -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)' };
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 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 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 last = photos[0];
const thumb = last && last.pics_direct ? `<img src="${esc(last.pics_direct)}" alt="" loading="lazy">` : ICONS.photos;
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">
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">
<div class="cm-stage">
<video data-cm-preview playsinline muted aria-label="Camera preview"></video>
<video data-cm-preview playsinline muted aria-label="Camera preview"></video>
<canvas data-cm-canvas hidden></canvas>
<canvas data-cm-canvas hidden></canvas>
@@ -244,12 +247,12 @@ ${signedIn ? '' : '<p class="cm-off-note"><a href="/auth/login?next=%2Fapp">Sign
@@ -244,12 +247,12 @@ ${signedIn ? '' : '<p class="cm-off-note"><a href="/auth/login?next=%2Fapp">Sign
</section>
</section>
<section class="cm-section" id="cm-gallery" aria-labelledby="cm-gallery-h">
<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>
<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>
</section>
</div>`;
</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">
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>
<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>
<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('')}</
@@ -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">
<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>
<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>
<section class="cm-section" id="cm-stills" aria-labelledby="cm-stills-h">
<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>`
@@ -285,7 +288,7 @@ ${stills.length ? `<ul class="cm-stills">${stills.map(stillCard).join('')}</ul>`
</div>`;
</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);
const active = grants.filter((g) => g.active);
return `<div class="cm-wrap" id="cm-camera-owner" data-camera="${esc(c.id)}">
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>
<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
@@ -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">
<section class="cm-section" id="cm-camera-photos" aria-labelledby="cm-camera-photos-h">
<h2 id="cm-camera-photos-h">On the wall</h2>
<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>
</section>
</div>`;
</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;
const owner = access && access.owner;
return `<div class="cm-wrap" id="cm-wall" data-camera="${esc(c.id)}">
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>
<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"
@@ -337,7 +340,7 @@ ${frame ? `<img src="${esc(frame)}" alt="The camera's current frame" width="640"
</section>
</section>
<section class="cm-section" id="cm-wall-photos" aria-labelledby="cm-wall-photos-h">
<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>
<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>
</section>
</div>`;
</div>`;
}
}
@@ -54,6 +54,16 @@ const { JPEG } = require('./helpers/upstreams');
@@ -54,6 +54,16 @@ const { JPEG } = require('./helpers/upstreams');
assert.strictEqual((await t.get('/api/v1/photos', { as: bob })).json().count, 0);
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 () => {
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();
camera = (await t.get('/api/v1/cameras', { as: alice, json: { name: 'Front door', slot: 'whip' } })).json();
const r = await t.get('/api/v1/photos', {
const r = await t.get('/api/v1/photos', {