openvibers/openvibe.mediahub Commits
Video: hand finished recordings to Studio's editor (#366)
Changes
@@@ -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@@@ -238,6 +238,17 @@ pages read Live's public JSON API server-side, signed out (Live already appliesreviews, 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 isprivate 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 videothrough Media's signed URL (private ones too), runs locally (whisper.cpp, the local model) and never uses a paid@@@ -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); });}@@@ -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);@@@ -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),@@@ -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.@@@ -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;nnew file mode 100644@@@ -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 };@@@ -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>@@@ -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',]);nnew file mode 100644@@@ -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}&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}&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); });@@@ -113,6 +113,7 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = truconst 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 () => {
@@ -61,6 +61,8 @@ MEDIAHUB_MEDIA_APP=media-hub
@@ -61,6 +61,8 @@ MEDIAHUB_MEDIA_APP=media-hub
# are the host-internal addresses (production sets them to the loopback address); links people click
# are the host-internal addresses (production sets them to the loopback address); links people click
# use livePublicUrl.
# use livePublicUrl.
MEDIAHUB_VIDEO_URL=https://openvibe.video
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.
# 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=off
#VIDEO_CUTS_PER_DAY=5
#VIDEO_CUTS_PER_DAY=5
@@ -238,6 +238,17 @@ pages read Live's public JSON API server-side, signed out (Live already applies
@@ -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.
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
- 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.
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`):
- **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
- 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
through Media's signed URL (private ones too), runs locally (whisper.cpp, the local model) and never uses a paid
@@ -152,6 +152,12 @@
@@ -152,6 +152,12 @@
if (state.saved) stored(null); else snapshot();
if (state.saved) stored(null); else snapshot();
meter.update({ state: 'done', done: file.size, total: file.size, message: 'Your video is processing.' });
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.');
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);
if (state.saved) window.location.assign(state.complete.video.url);
}).catch(function (err) { state.sending = false; fail(err); });
}).catch(function (err) { state.sending = false; fail(err); });
}
}
@@ -87,11 +87,12 @@ async function createApp(opts = {}) {
@@ -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));
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 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 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 syncKeys = createSyncKeys({ s, config });
const drops = createDrops({ s, media, config, uploads, log });
const drops = createDrops({ s, media, config, uploads, log });
const videoSearch = require('./video/search-index').createVideoSearchIndex({ s, config, videoAi, fetchImpl, 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(); });
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.
// IndexNow for openvibe.video: off without a key; a malformed key warns and stays off rather than stopping the site.
ctx.indexnow = opts.indexnow || (() => {
ctx.indexnow = opts.indexnow || (() => {
const warn = (...a) => (log.warn || console.warn)(...a);
const warn = (...a) => (log.warn || console.warn)(...a);
@@ -63,6 +63,8 @@ function load(env = process.env) {
@@ -63,6 +63,8 @@ function load(env = process.env) {
liveUrl: trim(env.MEDIAHUB_LIVE_URL || 'https://openvibe.live'),
liveUrl: trim(env.MEDIAHUB_LIVE_URL || 'https://openvibe.live'),
livePublicUrl: trim(env.MEDIAHUB_LIVE_PUBLIC_URL || 'https://openvibe.live'),
livePublicUrl: trim(env.MEDIAHUB_LIVE_PUBLIC_URL || 'https://openvibe.live'),
chatUrl: trim(env.MEDIAHUB_CHAT_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.
// Playlists (step C): the per-person caps the pages and the API enforce together.
playlistsMax: size(env.MEDIAHUB_VIDEO_PLAYLISTS_MAX, 100),
playlistsMax: size(env.MEDIAHUB_VIDEO_PLAYLISTS_MAX, 100),
playlistItemsMax: size(env.MEDIAHUB_VIDEO_PLAYLIST_ITEMS_MAX, 500),
playlistItemsMax: size(env.MEDIAHUB_VIDEO_PLAYLIST_ITEMS_MAX, 500),
@@ -54,6 +54,8 @@ const BUDGETS = {
@@ -54,6 +54,8 @@ const BUDGETS = {
'media-hub.share.create': { minute: 20, hour: 200 },
'media-hub.share.create': { minute: 20, hour: 200 },
'media-hub.playlist.create': { minute: 30, hour: 300 },
'media-hub.playlist.create': { minute: 30, hour: 300 },
'media-hub.playlist.write': { minute: 120, hour: 1_200 },
'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.
// 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 },
'pics.image.upload': { minute: 30, hour: 300 },
// Resize and convert (server/pics/variants.js): each new variant is decoded and encoded here.
// Resize and convert (server/pics/variants.js): each new variant is decoded and encoded here.
@@ -45,6 +45,58 @@ function createVideoApiRoutes(ctx) {
@@ -45,6 +45,58 @@ function createVideoApiRoutes(ctx) {
return (p && (p.kind === 'user' || p.kind === 'app') && p.requester) ? p.requester : null;
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) {
function refused(code) {
if (code === 'playlist.not_found' || code === 'playlist.item_not_found') return 404;
if (code === 'playlist.not_found' || code === 'playlist.item_not_found') return 404;
if (code === 'playlist.item_duplicate') return 409;
if (code === 'playlist.item_duplicate') return 409;
new file mode 100644
new file mode 100644
@@ -0,0 +1,199 @@
@@ -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 };
@@ -39,6 +39,8 @@ function problem(res, err) {
@@ -39,6 +39,8 @@ function problem(res, err) {
/** The JSON part API (mounted before the site's urlencoded parser: parts are raw bytes). */
/** The JSON part API (mounted before the site's urlencoded parser: parts are raw bytes). */
function createUploadApi({ ctx, config }) {
function createUploadApi({ ctx, config }) {
const up = ctx.videoUploads;
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();
const r = express.Router();
r.use((req, res, next) => { res.set('Cache-Control', cache.htmlHeaders({ private: true })); next(); });
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
const who = (req) => (req.viewer && req.viewer.kind === 'user' && req.viewer.subject
@@ -124,7 +126,7 @@ function createUploadApi({ ctx, config }) {
@@ -124,7 +126,7 @@ function createUploadApi({ ctx, config }) {
if (!row) return undefined;
if (!row) return undefined;
try {
try {
const out = await up.complete(row);
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); }
} catch (err) { return problem(res, err); }
});
});
@@ -171,6 +173,10 @@ function mountUploadPages(r, h) {
@@ -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).
// and the shared progress meter when the upload stopped part-way (Resume opens /upload, which reads IndexedDB).
const uploadState = (row) => (row.state === 'hidden' ? 'hidden'
const uploadState = (row) => (row.state === 'hidden' ? 'hidden'
: row.status === 'uploading' ? 'uploading' : row.status === 'processing' ? 'processing' : row.status === 'failed' ? 'failed' : 'ready');
: 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) {
function myVideoRow(row) {
const state = uploadState(row);
const state = uploadState(row);
const watch = row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`;
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
@@ -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>` : ''}
${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">
<div class="ovv-myactions">
${state === 'uploading' ? html`<a class="ovv-btn ovv-btn--small ovv-btn--primary" href="/upload">Resume upload</a>` : ''}
${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>` : ''}
${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>
<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>
</div>
@@ -436,7 +442,7 @@ ${what}
@@ -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>` : ''}
${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 class="ovv-byline-text">${channel ? html`<a class="ovv-channel-name" href="/@${channel}">@${channel}</a>` : ''}
</div>
</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>
</div>
${row.description ? html`<section class="ovv-panel ovv-description"><p>${row.description}</p></section>` : ''}
${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>
${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>
@@ -23,7 +23,7 @@ const READ = 'media-hub.api.read';
@@ -23,7 +23,7 @@ const READ = 'media-hub.api.read';
await check('BUDGETS names the product\'s own expensive routes, and nothing else', () => {
await check('BUDGETS names the product\'s own expensive routes, and nothing else', () => {
assert.deepStrictEqual(Object.keys(BUDGETS).sort(), [
assert.deepStrictEqual(Object.keys(BUDGETS).sort(), [
'media-hub.drop.start', 'media-hub.folder.create', 'media-hub.playlist.create', 'media-hub.playlist.write',
'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',
'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',
'pics.album.create', 'pics.image.describe', 'pics.image.upload', 'pics.image.variant', 'pics.image.write', 'pics.report.create',
]);
]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,133 @@
@@ -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}&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}&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); });
@@ -113,6 +113,7 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru
@@ -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 });
const done1 = await vreq(t.base, `/api/v1/videos/${id}/complete`, { method: 'POST', cookie: kimCookie });
assert.strictEqual(done1.status, 202);
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.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 () => {
await check('the watch page plays it from Media, and it is on the channel, the home page and the sitemap', async () => {