openvibers/openvibe.mediahub Commits

Video: hand finished recordings to Studio's editor (#366)

OP OpenVibers committed 3 days ago · 92c93d1ecb · 12 files, +420 −5

* Video: hand finished recordings to Studio's editor

Adds the permission-checked Studio hand-off API (metadata, Range bytes and a private copy published back on Video), the Edit in Studio action on a finished upload and Your videos, and the id the record page hands over. A private video of someone else answers 404 like a missing id, and the routes accept the person's Network token only, never this site's cookie.

* Limits: list the Studio publish budget in the test

Changes

ddiff --git a/.env.example b/.env.example
iindex be94991..74c94ad 100644
---- a/.env.example
++++ b/.env.example
@@@ -61,6 +61,8 @@ MEDIAHUB_MEDIA_APP=media-hub
# are the host-internal addresses (production sets them to the loopback address); links people click
# use livePublicUrl.
MEDIAHUB_VIDEO_URL=https://openvibe.video
++# Where "Edit in Studio" goes for a finished upload (the hand-off route).
++MEDIAHUB_STUDIO_URL=https://openvibe.studio
# The AI cut of an upload (server/video/cut.js): edited copies made here with ffmpeg, one at a time under nice.
#VIDEO_CUTS=off
#VIDEO_CUTS_PER_DAY=5
ddiff --git a/README.md b/README.md
iindex 4fee593..9017e88 100644
---- a/README.md
++++ b/README.md
@@@ -238,6 +238,17 @@ pages read Live's public JSON API server-side, signed out (Live already applies
reviews, trims and chooses Use this. Nothing uploads until that choice.
- The returned `File` goes through the ordinary multipart upload form, with progress and retries. A recording is
private by default. The older `/api/v1/videos/recordings` chunk API remains available to its existing callers.
++- **Edit in Studio** (`server/video/studio.js`): the finished-recording watch page and each ready row on
++ `/me/videos` link to `https://openvibe.studio/edit?media=<vup id>&from=video` (`MEDIAHUB_STUDIO_URL`).
++ - Studio is another origin and has no Video cookie, so it calls these routes server-to-server with the person's
++ own Network token as a Bearer: `GET /api/v1/video/studio/media/:id` (metadata and where it came from),
++ `GET …/media/:id/bytes` (Range reads for the editor preview) and `POST …/studio/publish`.
++ - `:id` may be the upload id (`vup_…`), the `/watch?v=` id or Media's VOD id. The read rule is the watch page's
++ (owner, staff, or a ready public/unlisted item); a private item of someone else answers 404 exactly like a
++ missing id. Only the owner may publish, and a cookie from this site cannot call these routes.
++ - Publishing makes a **new private copy** through the upload path (title and visibility from Studio), so Media
++ probes it and gives it its own AI captions and chapters; the original is unchanged and the copy names it in
++ `video_uploads.edited_from`. `MEDIAHUB_STUDIO_URL` points the links at a drill or another Studio.
- **AI edit** (`server/video/ai-edit.js`, migration `0009_video_ai.sql`):
- Every few minutes, OpenVibe.AI's `media.analyze` runs on each finished upload or recording. It reads the video
through Media's signed URL (private ones too), runs locally (whisper.cpp, the local model) and never uses a paid
ddiff --git a/public/js/video-upload.js b/public/js/video-upload.js
iindex 84ff94f..9f65088 100644
---- a/public/js/video-upload.js
++++ b/public/js/video-upload.js
@@@ -152,6 +152,12 @@
if (state.saved) stored(null); else snapshot();
meter.update({ state: 'done', done: file.size, total: file.size, message: 'Your video is processing.' });
say(state.saved ? 'Opening your video…' : 'Upload complete. Save details to continue.');
++ if (!state.saved && status && state.complete.video && state.complete.video.edit_url) {
++ var edit = document.createElement('a');
++ edit.href = state.complete.video.edit_url;
++ edit.textContent = ' Edit in Studio';
++ status.appendChild(edit);
++ }
if (state.saved) window.location.assign(state.complete.video.url);
}).catch(function (err) { state.sending = false; fail(err); });
}
ddiff --git a/server/app.js b/server/app.js
iindex 3c5a4df..085cc12 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -87,11 +87,12 @@ async function createApp(opts = {}) {
if (media.enabled) videoUploads.backfillWatchIds({ limit: 200, log }).catch((err) => log.warn('[MediaHub] watch id backfill:', err.message));
const videoAi = opts.videoAi || require('./video/ai-edit').createAiEdit({ s, media, config, fetchImpl, log });
const videoCuts = require('./video/cut').createCuts({ s, media, uploads: videoUploads, videoAi, config, log, spawnImpl: opts.spawnImpl });
++ const videoStudio = require('./video/studio').createStudio({ s, media, uploads: videoUploads, config, log, fetchImpl });
const syncKeys = createSyncKeys({ s, config });
const drops = createDrops({ s, media, config, uploads, log });
const videoSearch = require('./video/search-index').createVideoSearchIndex({ s, config, videoAi, fetchImpl, log });
if (sharing.onChanged) sharing.onChanged((ref) => { if (ref.kind === 'video' && videoSearch) videoSearch.kick(); });
-- const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, playlists, pics, picsDescribe, eventsOutbox, videoUploads, videoAi, videoCuts, syncKeys, drops, videoSearch, fetchImpl, log, sharing };
++ const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, playlists, pics, picsDescribe, eventsOutbox, videoUploads, videoAi, videoCuts, videoStudio, syncKeys, drops, videoSearch, fetchImpl, log, sharing };
// IndexNow for openvibe.video: off without a key; a malformed key warns and stays off rather than stopping the site.
ctx.indexnow = opts.indexnow || (() => {
const warn = (...a) => (log.warn || console.warn)(...a);
ddiff --git a/server/config.js b/server/config.js
iindex 090f1b4..6abc2dc 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -63,6 +63,8 @@ function load(env = process.env) {
liveUrl: trim(env.MEDIAHUB_LIVE_URL || 'https://openvibe.live'),
livePublicUrl: trim(env.MEDIAHUB_LIVE_PUBLIC_URL || 'https://openvibe.live'),
chatUrl: trim(env.MEDIAHUB_CHAT_URL || 'https://openvibe.live'),
++ // Where "Edit in Studio" goes (the hand-off route; see server/video/studio.js).
++ studioUrl: trim(env.MEDIAHUB_STUDIO_URL || 'https://openvibe.studio'),
// Playlists (step C): the per-person caps the pages and the API enforce together.
playlistsMax: size(env.MEDIAHUB_VIDEO_PLAYLISTS_MAX, 100),
playlistItemsMax: size(env.MEDIAHUB_VIDEO_PLAYLIST_ITEMS_MAX, 500),
ddiff --git a/server/http/caller-limits.js b/server/http/caller-limits.js
iindex 914de1d..02859d9 100644
---- a/server/http/caller-limits.js
++++ b/server/http/caller-limits.js
@@@ -54,6 +54,8 @@ const BUDGETS = {
'media-hub.share.create': { minute: 20, hour: 200 },
'media-hub.playlist.create': { minute: 30, hour: 300 },
'media-hub.playlist.write': { minute: 120, hour: 1_200 },
++ // Studio's "Publish on Video": each one copies a finished video into a new upload, so it is a slow, heavy write.
++ 'media-hub.studio.publish': { minute: 6, hour: 30 },
// OpenVibe.Pics (server/pics): a burst of pictures dropped at once, but not a script emptying a disk into it.
'pics.image.upload': { minute: 30, hour: 300 },
// Resize and convert (server/pics/variants.js): each new variant is decoded and encoded here.
ddiff --git a/server/video/api.js b/server/video/api.js
iindex ad437ad..c756221 100644
---- a/server/video/api.js
++++ b/server/video/api.js
@@@ -45,6 +45,58 @@ function createVideoApiRoutes(ctx) {
return (p && (p.kind === 'user' || p.kind === 'app') && p.requester) ? p.requester : null;
};
++ // ── Studio's hand-off (server/video/studio.js) ─────────
++ // The person's own Network token, sent by openvibe.studio server-to-server. Not a session cookie:
++ // these routes exist for another origin, so a browser's cookie must never make them act here.
++ const studioProblem = (req, res, err) => problem(req, res, Number(err && err.status) || 502, (err && err.code) || 'studio.upstream_error', (err && (err.detail || err.message)) || 'The Studio hand-off failed.');
++ const person = (req, res) => {
++ if (req.principal && req.principal.kind === 'user') return req.principal;
++ studioProblem(req, res, { status: 401, code: 'studio.auth_required', detail: 'Sign in with OpenVibe to open this video in Studio.' });
++ return null;
++ };
++ const studio = (req, res) => {
++ if (!ctx.videoStudio) { studioProblem(req, res, { status: 503, code: 'studio.off', detail: 'The Studio hand-off is not available here.' }); return null; }
++ const p = person(req, res);
++ if (!p) return null;
++ if (p.viaSession) { studioProblem(req, res, { status: 403, code: 'studio.bearer_required', detail: 'Studio calls this route with the person\'s Network token.' }); return null; }
++ return p;
++ };
++
++ r.get('/studio/media/:id', limits.reads('media-hub.studio.read'), async (req, res) => {
++ const p = studio(req, res);
++ if (!p) return undefined;
++ const found = await ctx.videoStudio.resolve(String(req.params.id || ''), p);
++ if (!found) return studioProblem(req, res, ctx.videoStudio.notFound());
++ return res.json(ctx.videoStudio.wire(found.row, { canEdit: found.canEdit }));
++ });
++
++ r.get('/studio/media/:id/bytes', async (req, res) => {
++ const p = studio(req, res);
++ if (!p) return undefined;
++ const found = await ctx.videoStudio.resolve(String(req.params.id || ''), p);
++ if (!found) return studioProblem(req, res, ctx.videoStudio.notFound());
++ try {
++ const out = await ctx.videoStudio.bytes(found.row, req.get('range'));
++ res.status(out.status);
++ res.set('Cache-Control', 'private, no-store');
++ for (const [k, v] of Object.entries(out.headers)) res.set(k, v);
++ ctx.videoStudio.sendBody(out.body).pipe(res);
++ } catch (err) { return studioProblem(req, res, err); }
++ return undefined;
++ });
++
++ r.post('/studio/publish', limits.budget('media-hub.studio.publish'), async (req, res) => {
++ const p = studio(req, res);
++ if (!p) return undefined;
++ const found = await ctx.videoStudio.resolve(String((req.body || {}).media || ''), p);
++ if (!found || found.row.owner !== p.requester) return studioProblem(req, res, ctx.videoStudio.notFound());
++ try {
++ const b = req.body || {};
++ const out = await ctx.videoStudio.publish(found.row, p, { title: b.title, visibility: b.visibility });
++ return res.status(202).json(out);
++ } catch (err) { return studioProblem(req, res, err); }
++ });
++
function refused(code) {
if (code === 'playlist.not_found' || code === 'playlist.item_not_found') return 404;
if (code === 'playlist.item_duplicate') return 409;
ddiff --git a/server/video/studio.js b/server/video/studio.js
nnew file mode 100644
iindex 0000000..84f1b30
---- /dev/null
++++ b/server/video/studio.js
@@@ -0,0 +1,199 @@
++'use strict';
++
++/**
++ * The OpenVibe.Video side of Studio's "Edit in Studio" hand-off.
++ *
++ * A person records or uploads a video on openvibe.video, then opens it in Studio at
++ * https://openvibe.studio/edit?media=<the video's id>&from=video. Studio is a different
++ * origin, so the person's Studio session is not Video's cookie: Studio calls these routes
++ * server-to-server with the person's own Network token as a Bearer (the same token its SSO
++ * issues), and MediaHub decides what that person may read or change.
++ *
++ * GET /api/v1/video/studio/media/:id the video (metadata, and where it came from)
++ * GET /api/v1/video/studio/media/:id/bytes its bytes, Range reads included (the preview)
++ * POST /api/v1/video/studio/publish a private copy published back on Video
++ *
++ * :id is one of the ids the person already has for the video: the upload id (vup_…), the
++ * /watch?v= id, or Media's VOD id (media_vod_id). A private video of someone else answers
++ * exactly like a missing id, so an id cannot be used to learn that it exists. Reading needs
++ * what the watch page needs; PUBLISHING needs the owner. The copies are made by the same
++ * upload path as a browser recording, so Media probes them, makes the thumbnail and HLS,
++ * and the AI edit gets captions and chapters.
++ */
++const { Readable } = require('node:stream');
++const { UploadError } = require('./uploads');
++
++const PIECE = 8 * 1024 * 1024;
++const VUP_RE = /^vup_[0-9A-HJKMNP-TV-Z]{26}$/;
++const WATCH_RE = /^[A-Za-z0-9_-]{11}$/;
++const VOD_RE = /^[1-9]\d{0,15}$/;
++
++/** `id` as a video_uploads row, or null. The three id spaces the site itself shows. */
++function findRow(uploads, id) {
++ const s = String(id || '');
++ if (VUP_RE.test(s)) return uploads.byId(s);
++ if (WATCH_RE.test(s)) return uploads.byWatchId(s);
++ if (VOD_RE.test(s)) return uploads.byMediaVod(Number(s));
++ return null;
++}
++
++function watchPath(row) {
++ return row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`;
++}
++
++/**
++ * The video as Studio is allowed to see it. `source` names where it came from, so Studio can
++ * say "From OpenVibe.Video" rather than pretending the video appeared there.
++ */
++function wire(row, { canEdit = false, baseUrl = 'https://openvibe.video' } = {}) {
++ const path = watchPath(row);
++ return {
++ media: {
++ id: row.id,
++ media_id: row.media_vod_id == null ? null : Number(row.media_vod_id),
++ kind: 'video',
++ title: row.title,
++ description: row.description || '',
++ status: row.status,
++ visibility: row.visibility,
++ mime_type: row.content_type,
++ size_bytes: Number(row.size_bytes) || 0,
++ duration_seconds: row.duration_seconds == null ? null : Number(row.duration_seconds),
++ thumbnail_url: row.thumbnail_url || null,
++ watch_url: `${String(baseUrl).replace(/\/+$/, '')}${path}`,
++ page_path: path,
++ can_edit: canEdit === true,
++ edited_from: row.edited_from || null,
++ },
++ source: {
++ id: 'video',
++ name: 'OpenVibe.Video',
++ url: `${String(baseUrl).replace(/\/+$/, '')}${path}`,
++ },
++ };
++}
++
++function createStudio({ s, media, uploads, config, log = console, fetchImpl = globalThis.fetch }) {
++ const mediaBase = String((config.media && config.media.url) || '').replace(/\/+$/, '');
++ const videoBase = String((config.video && config.video.url) || 'https://openvibe.video').replace(/\/+$/, '');
++
++ function notFound() {
++ // The same answer for "no such video" and "someone else's private video".
++ return new UploadError(404, 'studio.media_not_found', 'No such video, or it is not yours to open.');
++ }
++
++ /**
++ * The row and whether this person may edit it. Reading is the watch page's rule
++ * (uploads.canSee): the owner, staff, or a ready active video that is not private.
++ */
++ async function resolve(id, principal) {
++ if (!principal || principal.kind !== 'user' || !principal.requester) return null;
++ const row = await findRow(uploads, id);
++ const staff = ['admin', 'global_mod'].includes(String(principal.role || ''));
++ if (!uploads.canSee(row, { requester: principal.requester, staff })) return null;
++ return { row, canEdit: row.owner === principal.requester || staff };
++ }
++
++ /** A Media-signed URL for the VOD, moved to the one Media address this service may call. */
++ async function sourceUrl(row) {
++ const signed = await media.vodSignedUrl(row.media_vod_id, 600);
++ if (!signed.ok || !signed.data || !signed.data.url) throw new UploadError(502, 'studio.media_unavailable', 'OpenVibe.Media did not give a readable address for this video.');
++ const u = new URL(signed.data.url);
++ return mediaBase ? `${mediaBase}${u.pathname}${u.search}` : signed.data.url;
++ }
++
++ /** The video's bytes, read from Media, with the Range the caller asked for. */
++ async function bytes(row, range) {
++ const url = await sourceUrl(row);
++ let res;
++ try {
++ res = await fetchImpl(url, { headers: range ? { range: String(range) } : {}, redirect: 'error', signal: AbortSignal.timeout(2 * 3600_000) });
++ } catch (err) {
++ log.warn('[MediaHub] Studio could not read a video:', (err && err.message) || '');
++ throw new UploadError(502, 'studio.media_unavailable', 'OpenVibe.Media did not answer.');
++ }
++ if (!res.ok && res.status !== 206) {
++ if (res.body) res.body.cancel().catch(() => {});
++ throw new UploadError(res.status === 416 ? 416 : 502, 'studio.media_unavailable', 'OpenVibe.Media could not serve this video.');
++ }
++ return {
++ status: res.status,
++ headers: {
++ 'content-type': res.headers.get('content-type') || row.content_type || 'application/octet-stream',
++ 'accept-ranges': res.headers.get('accept-ranges') || 'bytes',
++ ...(res.headers.get('content-range') ? { 'content-range': res.headers.get('content-range') } : {}),
++ ...(res.headers.get('content-length') ? { 'content-length': res.headers.get('content-length') } : {}),
++ },
++ body: res.body,
++ };
++ }
++
++ /**
++ * Publish a private copy of the owner's video back on Video, with the title and visibility
++ * Studio asked for. The bytes go from Media to Media through this service, never through the
++ * browser; the copy takes the same path as a browser recording, so it is probed and made
++ * playable like any other upload. The original is never changed.
++ */
++ async function publish(row, principal, { title, visibility } = {}) {
++ if (!row || row.owner !== principal.requester) throw notFound();
++ if (row.status !== 'ready' || row.state !== 'active') throw new UploadError(409, 'studio.media_not_ready', 'The video has to be ready (and not hidden) before it can be published from Studio.');
++ const total = Number(row.size_bytes);
++ if (!Number.isSafeInteger(total) || total < 1) throw new UploadError(409, 'studio.media_size', 'OpenVibe.Media has not reported this video\'s size yet.');
++ const wantedTitle = String(title == null ? '' : title).replace(/\s+/g, ' ').trim().slice(0, 120) || `${row.title.slice(0, 100)} (Studio)`;
++ const wantedVisibility = ['private', 'unlisted', 'public'].includes(visibility) ? visibility : 'private';
++ const source = await sourceUrl(row);
++ let res;
++ try {
++ res = await fetchImpl(source, { redirect: 'error', signal: AbortSignal.timeout(6 * 3600_000) });
++ } catch (err) {
++ log.warn('[MediaHub] Studio publish could not read the video:', (err && err.message) || '');
++ throw new UploadError(502, 'studio.media_unavailable', 'OpenVibe.Media did not answer.');
++ }
++ if (!res.ok) {
++ if (res.body) res.body.cancel().catch(() => {});
++ throw new UploadError(502, 'studio.media_unavailable', 'OpenVibe.Media could not serve this video.');
++ }
++ let copy = null;
++ try {
++ copy = await uploads.start({ requester: row.owner, username: row.owner_username }, {
++ title: wantedTitle, description: row.description || '', visibility: wantedVisibility,
++ size: total, contentType: row.content_type, name: row.title,
++ });
++ let pending = Buffer.alloc(0);
++ let n = 0;
++ let read = 0;
++ for await (const chunk of res.body) {
++ const part = Buffer.from(chunk);
++ read += part.length;
++ pending = pending.length ? Buffer.concat([pending, part]) : part;
++ while (pending.length >= PIECE) {
++ await uploads.part(copy, ++n, pending.subarray(0, PIECE));
++ pending = pending.subarray(PIECE);
++ }
++ }
++ if (pending.length) await uploads.part(copy, ++n, pending);
++ if (read !== total || n !== Number(copy.parts_expected)) throw new UploadError(502, 'studio.media_size', 'OpenVibe.Media gave a different number of bytes than it reported.');
++ const done = await uploads.complete(await uploads.byId(copy.id));
++ // The copy names its original, as an AI-cut copy does, so its page says where it came from.
++ await s.db.query('UPDATE video_uploads SET edited_from = $2 WHERE id = $1', [copy.id, row.id]);
++ const path = watchPath(done);
++ return { video: { id: done.id, status: done.status, title: done.title, url: path, watch_url: `${videoBase}${path}` } };
++ } catch (err) {
++ if (copy) await uploads.remove(copy).catch(() => {});
++ throw err;
++ }
++ }
++
++ return {
++ resolve,
++ wire: (row, options = {}) => wire(row, { ...options, baseUrl: videoBase }),
++ bytes,
++ publish,
++ notFound,
++ findRow: (id) => findRow(uploads, id),
++ /** A stream as an Express response can take it (kept here so the route needs no node:stream import). */
++ sendBody: (body) => Readable.fromWeb(body),
++ };
++}
++
++module.exports = { createStudio, findRow, wire, watchPath };
ddiff --git a/server/video/upload-routes.js b/server/video/upload-routes.js
iindex 394f9ba..9aa26ec 100644
---- a/server/video/upload-routes.js
++++ b/server/video/upload-routes.js
@@@ -39,6 +39,8 @@ function problem(res, err) {
/** The JSON part API (mounted before the site's urlencoded parser: parts are raw bytes). */
function createUploadApi({ ctx, config }) {
const up = ctx.videoUploads;
++ const studioUrl = String((config.video && config.video.studioUrl) || 'https://openvibe.studio').replace(/\/+$/, '');
++ const studioEditUrl = (row) => `${studioUrl}/edit?media=${encodeURIComponent(row.id)}&from=video`;
const r = express.Router();
r.use((req, res, next) => { res.set('Cache-Control', cache.htmlHeaders({ private: true })); next(); });
const who = (req) => (req.viewer && req.viewer.kind === 'user' && req.viewer.subject
@@@ -124,7 +126,7 @@ function createUploadApi({ ctx, config }) {
if (!row) return undefined;
try {
const out = await up.complete(row);
-- return res.status(202).json({ video: { id: out.id, status: out.status, url: out.watch_id ? `/watch?v=${out.watch_id}` : `/u/${out.id}` } });
++ return res.status(202).json({ video: { id: out.id, status: out.status, url: out.watch_id ? `/watch?v=${out.watch_id}` : `/u/${out.id}`, edit_url: studioEditUrl(out) } });
} catch (err) { return problem(res, err); }
});
@@@ -171,6 +173,10 @@ function mountUploadPages(r, h) {
// and the shared progress meter when the upload stopped part-way (Resume opens /upload, which reads IndexedDB).
const uploadState = (row) => (row.state === 'hidden' ? 'hidden'
: row.status === 'uploading' ? 'uploading' : row.status === 'processing' ? 'processing' : row.status === 'failed' ? 'failed' : 'ready');
++ /** Where "Edit in Studio" goes (the hand-off; see server/video/studio.js). */
++ const studioUrl = String((ctx.config.video && ctx.config.video.studioUrl) || 'https://openvibe.studio').replace(/\/+$/, '');
++ const studioEditUrl = (row) => `${studioUrl}/edit?media=${encodeURIComponent(row.id)}&from=video`;
++
function myVideoRow(row) {
const state = uploadState(row);
const watch = row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`;
@@@ -189,7 +195,7 @@ ${state === 'uploading' ? html`<div class="ovv-myprogress" data-ov-upload-progre
${state === 'processing' ? html`<p class="ovv-myprocess"><span class="ovv-pulse" aria-hidden="true"></span> Making it playable. You can close this page.</p>` : ''}
<div class="ovv-myactions">
${state === 'uploading' ? html`<a class="ovv-btn ovv-btn--small ovv-btn--primary" href="/upload">Resume upload</a>` : ''}
--${state === 'ready' ? html`<a class="ovv-btn ovv-btn--small" href="${watch}">Watch</a> <a class="ovv-btn ovv-btn--small" href="${watch}#h-owner">Edit</a>` : ''}
++${state === 'ready' ? html`<a class="ovv-btn ovv-btn--small" href="${watch}">Watch</a> <a class="ovv-btn ovv-btn--small" href="${watch}#h-owner">Edit</a> <a class="ovv-btn ovv-btn--small" href="${studioEditUrl(row)}">Edit in Studio</a>` : ''}
${state === 'processing' || state === 'hidden' || state === 'failed' ? html`<a class="ovv-btn ovv-btn--small" href="${watch}">Open</a>` : ''}
<button class="ovv-btn ovv-btn--small ovv-btn--danger" type="button" data-delete-video="${row.id}" data-delete-title="${row.title}">Delete</button>
</div>
@@@ -436,7 +442,7 @@ ${what}
${channel ? html`<a class="ovv-avatar" href="/@${channel}" aria-hidden="true" tabindex="-1">${channel.slice(0, 1).toUpperCase()}${/^[A-Za-z0-9_]{1,32}$/.test(channel) ? html`<img src="${ctx.config.networkUrl}/avatar/${channel}" alt="" loading="lazy">` : ''}</a>` : ''}
<div class="ovv-byline-text">${channel ? html`<a class="ovv-channel-name" href="/@${channel}">@${channel}</a>` : ''}
</div>
--<div class="ovv-byline-actions">${saveForm}${ready && vod && fileOf(vod) ? html`<a class="ovv-btn" href="https://openvibe.party/new?media=${encodeURIComponent(fileOf(vod))}&source_url=${encodeURIComponent(`${String(ctx.config.video.url).replace(/\/+$/, '')}${watchPath}`)}&title=${encodeURIComponent(String(row.title || 'Watch party'))}"><i class="fa-solid fa-users" aria-hidden="true"></i> Watch together</a>` : ''}${raw(shareButton({ cls: 'ovv-btn', url: `${ctx.config.video.url}${watchPath}`, title: row.title, text: about, image: thumb || '', timeFrom: ready && vod ? '.ovv-player' : null }))}${raw(askAgent({ text: `About the video “${row.title}” on OpenVibe.Video (${ctx.config.video.url}${watchPath}): ` }))}</div>
++<div class="ovv-byline-actions">${saveForm}${ready && vod && fileOf(vod) ? html`<a class="ovv-btn" href="https://openvibe.party/new?media=${encodeURIComponent(fileOf(vod))}&source_url=${encodeURIComponent(`${String(ctx.config.video.url).replace(/\/+$/, '')}${watchPath}`)}&title=${encodeURIComponent(String(row.title || 'Watch party'))}"><i class="fa-solid fa-users" aria-hidden="true"></i> Watch together</a>` : ''}${own && ready && row.state === 'active' ? html`<a class="ovk-btn ovk-btn--quiet" href="${studioUrl}/edit?media=${encodeURIComponent(row.id)}&from=video">Edit in Studio</a>` : ''}${raw(shareButton({ cls: 'ovv-btn', url: `${ctx.config.video.url}${watchPath}`, title: row.title, text: about, image: thumb || '', timeFrom: ready && vod ? '.ovv-player' : null }))}${raw(askAgent({ text: `About the video “${row.title}” on OpenVibe.Video (${ctx.config.video.url}${watchPath}): ` }))}</div>
</div>
${row.description ? html`<section class="ovv-panel ovv-description"><p>${row.description}</p></section>` : ''}
${aiDone && ai.overview ? html`<section class="ovv-panel" aria-labelledby="h-ai"><h2 id="h-ai">About this video</h2><p>${ai.overview}</p><p class="ovv-fine">Written by AI from the video's sound and pictures (OpenVibe.AI, run on OpenVibe's own servers).</p>
ddiff --git a/test/caller-limits.test.js b/test/caller-limits.test.js
iindex 73c863c..e809700 100644
---- a/test/caller-limits.test.js
++++ b/test/caller-limits.test.js
@@@ -23,7 +23,7 @@ const READ = 'media-hub.api.read';
await check('BUDGETS names the product\'s own expensive routes, and nothing else', () => {
assert.deepStrictEqual(Object.keys(BUDGETS).sort(), [
'media-hub.drop.start', 'media-hub.folder.create', 'media-hub.playlist.create', 'media-hub.playlist.write',
-- 'media-hub.share.create', 'media-hub.sync.upload.complete', 'media-hub.sync.upload.start', 'media-hub.sync_key.create',
++ 'media-hub.share.create', 'media-hub.studio.publish', 'media-hub.sync.upload.complete', 'media-hub.sync.upload.start', 'media-hub.sync_key.create',
'media-hub.upload.complete', 'media-hub.upload.part', 'media-hub.upload.start',
'pics.album.create', 'pics.image.describe', 'pics.image.upload', 'pics.image.variant', 'pics.image.write', 'pics.report.create',
]);
ddiff --git a/test/video-studio.test.js b/test/video-studio.test.js
nnew file mode 100644
iindex 0000000..8a11432
---- /dev/null
++++ b/test/video-studio.test.js
@@@ -0,0 +1,133 @@
++'use strict';
++/**
++ * Studio's "Edit in Studio" hand-off on OpenVibe.Video (server/video/studio.js):
++ * - a person opens metadata and bytes with their own Network token as a Bearer;
++ * - a private video of someone else answers exactly like a missing id (404), with no title leak;
++ * - a public video can be read but only its owner can publish it;
++ * - publishing makes a new private copy on Video (the original is unchanged);
++ * - the owner's watch page and Your videos link to Studio with the video's id.
++ */
++const assert = require('assert');
++const http = require('http');
++const { boot, check, done } = require('./helpers/boot');
++
++const VIDEO = 'https://openvibe.video';
++
++function areq(base, path, { method = 'GET', bearer, json, range, cookie } = {}) {
++ return new Promise((resolve, reject) => {
++ const u = new URL(base + path);
++ const headers = { Host: new URL(VIDEO).host };
++ if (bearer) headers.authorization = `Bearer ${bearer}`;
++ if (cookie) headers.cookie = cookie;
++ if (range) headers.range = range;
++ let body;
++ if (json !== undefined) { body = Buffer.from(JSON.stringify(json)); headers['content-type'] = 'application/json'; headers['content-length'] = body.length; }
++ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method, headers }, (res) => {
++ const chunks = [];
++ res.on('data', (c) => chunks.push(c));
++ res.on('end', () => {
++ const text = Buffer.concat(chunks).toString('utf8');
++ resolve({ status: res.statusCode, headers: res.headers, text, json: () => JSON.parse(text) });
++ });
++ });
++ req.on('error', reject);
++ if (body) req.write(body);
++ req.end();
++ });
++}
++
++(async () => {
++ const t = await boot({ env: { MEDIAHUB_VIDEO_URL: VIDEO, MEDIAHUB_LIVE_URL: 'http://127.0.0.1:1', MEDIAHUB_LIVE_PUBLIC_URL: 'https://openvibe.live', MEDIAHUB_CHAT_URL: 'http://127.0.0.1:1', MEDIAHUB_STUDIO_URL: 'https://openvibe.studio' } });
++ const kim = t.network.addUser('kim');
++ const sam = t.network.addUser('sam');
++ const kimToken = t.network.userToken(kim);
++ const samToken = t.network.userToken(sam);
++ const body = Buffer.from('a finished recording, byte for byte');
++
++ /** A ready row of kim's, with the bytes in Media's stand-in. */
++ async function ready({ visibility, title, id }) {
++ const row = await t.ctx.videoUploads.startRecording({ requester: `user:${kim.subject}`, username: 'kim' }, { title, contentType: 'video/webm', visibility });
++ const vod = t.media.vods.get(Number(row.media_vod_id));
++ vod.bytes = Buffer.from(body);
++ vod.status = 'ready';
++ await t.ctx.s.db.query("UPDATE video_uploads SET status = 'ready', duration_seconds = 12, size_bytes = $2, thumbnail_url = 'https://openvibe.media/t/x.jpg' WHERE id = $1", [row.id, body.length]);
++ return await t.ctx.videoUploads.byId(row.id);
++ }
++
++ const priv = await ready({ visibility: 'private', title: 'Private rehearsal' });
++ const pub = await ready({ visibility: 'public', title: 'Public talk' });
++
++ await check('a person reads their own private video with their own token', async () => {
++ const r = await areq(t.base, `/api/v1/video/studio/media/${priv.id}`, { bearer: kimToken });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ const out = r.json();
++ assert.strictEqual(out.media.id, priv.id);
++ assert.strictEqual(out.media.can_edit, true);
++ assert.strictEqual(out.source.id, 'video');
++ assert.strictEqual(out.source.name, 'OpenVibe.Video');
++ assert.strictEqual(out.media.watch_url, `${VIDEO}/watch?v=${priv.watch_id}`);
++ });
++
++ await check('someone else guessing a private id gets 404, never the title', async () => {
++ const r = await areq(t.base, `/api/v1/video/studio/media/${priv.id}`, { bearer: samToken });
++ assert.strictEqual(r.status, 404);
++ assert.ok(!r.text.includes('Private rehearsal'), 'the title does not leak');
++ assert.strictEqual((await areq(t.base, `/api/v1/video/studio/media/${priv.id}`)).status, 401);
++ const bytes = await areq(t.base, `/api/v1/video/studio/media/${priv.id}/bytes`, { bearer: samToken });
++ assert.strictEqual(bytes.status, 404);
++ });
++
++ await check('a public video is readable by anyone signed in, but only its owner may publish it', async () => {
++ const r = await areq(t.base, `/api/v1/video/studio/media/${pub.id}`, { bearer: samToken });
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.strictEqual(r.json().media.can_edit, false);
++ const publish = await areq(t.base, '/api/v1/video/studio/publish', { method: 'POST', bearer: samToken, json: { media: pub.id } });
++ assert.strictEqual(publish.status, 404);
++ });
++
++ await check('the bytes come through with Range reads, and only when the person may read them', async () => {
++ const r = await areq(t.base, `/api/v1/video/studio/media/${priv.id}/bytes`, { bearer: kimToken, range: 'bytes=2-9' });
++ assert.strictEqual(r.status, 206, r.text.slice(0, 200));
++ assert.strictEqual(r.headers['content-range'], `bytes 2-9/${body.length}`);
++ assert.strictEqual(r.text, body.subarray(2, 10).toString('utf8'));
++ assert.strictEqual((await areq(t.base, `/api/v1/video/studio/media/${priv.id}/bytes`)).status, 401);
++ });
++
++ await check('publishing makes a new private copy on Video; the original is unchanged', async () => {
++ const before = await t.ctx.s.db.value('SELECT count(*)::int FROM video_uploads');
++ const r = await areq(t.base, '/api/v1/video/studio/publish', { method: 'POST', bearer: kimToken, json: { media: priv.id, title: 'Rehearsal, from Studio', visibility: 'private' } });
++ assert.strictEqual(r.status, 202, r.text.slice(0, 400));
++ const out = r.json();
++ assert.match(out.video.id, /^vup_[0-9A-Z]{26}$/);
++ assert.strictEqual(out.video.title, 'Rehearsal, from Studio');
++ assert.ok(out.video.watch_url.startsWith(`${VIDEO}/watch?v=`) || out.video.watch_url.startsWith(`${VIDEO}/u/`), out.video.watch_url);
++ const after = await t.ctx.s.db.many('SELECT * FROM video_uploads ORDER BY created_at');
++ assert.strictEqual(after.length, before + 1);
++ const copy = after.find((x) => x.id === out.video.id);
++ assert.strictEqual(copy.owner, `user:${kim.subject}`);
++ assert.strictEqual(copy.visibility, 'private');
++ assert.strictEqual(copy.edited_from, priv.id);
++ assert.strictEqual((await t.ctx.videoUploads.byId(priv.id)).title, 'Private rehearsal', 'the original is unchanged');
++ });
++
++ await check('a cookie from this site cannot call the Studio hand-off; it is Bearer only', async () => {
++ const r = await areq(t.base, '/api/v1/video/studio/publish', { method: 'POST', cookie: t.signIn(kim), json: { media: priv.id } });
++ assert.strictEqual(r.status, 403);
++ assert.match(r.json().code, /bearer/);
++ });
++
++ await check('Your videos and the finished recording have the Edit in Studio link; a stranger\'s page does not', async () => {
++ const mine = await areq(t.base, '/me/videos', { cookie: t.signIn(kim) });
++ assert.strictEqual(mine.status, 200);
++ assert.ok(mine.text.includes(`https://openvibe.studio/edit?media=${priv.id}&amp;from=video`) || mine.text.includes(`https://openvibe.studio/edit?media=${priv.id}&from=video`), 'the row links to Studio');
++ const watch = await areq(t.base, `/watch?v=${priv.watch_id}`, { cookie: t.signIn(kim) });
++ assert.strictEqual(watch.status, 200);
++ assert.ok(watch.text.includes(`https://openvibe.studio/edit?media=${priv.id}&amp;from=video`) || watch.text.includes(`https://openvibe.studio/edit?media=${priv.id}&from=video`), 'the finished recording links to Studio');
++ const other = await areq(t.base, `/watch?v=${pub.watch_id}`, { cookie: t.signIn(sam) });
++ assert.strictEqual(other.status, 200);
++ assert.ok(!other.text.includes('Edit in Studio'), 'only the owner gets the action');
++ });
++
++ await t.close();
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/video-uploads.test.js b/test/video-uploads.test.js
iindex 500d1cf..8b14374 100644
---- a/test/video-uploads.test.js
++++ b/test/video-uploads.test.js
@@@ -113,6 +113,7 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru
const done1 = await vreq(t.base, `/api/v1/videos/${id}/complete`, { method: 'POST', cookie: kimCookie });
assert.strictEqual(done1.status, 202);
assert.strictEqual(done1.json().video.url, `/watch?v=${[...t.media.vods.values()][0].watch_id}`);
++ assert.strictEqual(done1.json().video.edit_url, `https://openvibe.studio/edit?media=${id}&from=video`, 'the finished recording names Studio');
});
await check('the watch page plays it from Media, and it is on the channel, the home page and the sitemap', async () => {