openvibers/openvibe.mediahub Commits
Video: trim and cover for Studio's editor (#369)
Changes
@@@ -35,6 +35,8 @@ MEDIAHUB_EVENTS_URL=# Grant [media-hub, media.object.upload, openvibe.media] and [media-hub, media.object.read, openvibe.media]# on namespace media-hub. Media's `media-hub` tenant holds the bytes.MEDIAHUB_MEDIA_URL=http://127.0.0.1:4100++# The public origin Media's /t/ cover URLs are made absolute on.++MEDIAHUB_MEDIA_PUBLIC_URL=https://openvibe.mediaMEDIAHUB_MEDIA_APP=media-hub#MEDIAHUB_MEDIA_TIMEOUT_MS=30000@@@ -63,8 +65,8 @@ MEDIAHUB_MEDIA_APP=media-hubMEDIAHUB_VIDEO_URL=https://openvibe.video# Where "Edit in Studio" goes for a finished upload (the hand-off route).MEDIAHUB_STUDIO_URL=https://openvibe.studio--# Off until Studio has real video tools: the hand-off API stays, the Edit action is not shown.--# MEDIAHUB_STUDIO_VIDEO_EDIT=1++# On by default. Set off to hide the Edit in Studio action while leaving the hand-off API.++# MEDIAHUB_STUDIO_VIDEO_EDIT=off# 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@@@ -241,20 +241,26 @@ 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`): with `MEDIAHUB_STUDIO_VIDEO_EDIT=1` (**off** by default), 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`); the record completion response-- carries the same `edit_url`. With the flag off, no page or response offers the action, but the hand-off API below-- stays available.++- **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`), and the++ record completion response carries the same `edit_url`. `MEDIAHUB_STUDIO_VIDEO_EDIT=off` (the switch is **on** by default) hides those three++ actions while leaving the hand-off API below available.- Studio is another origin and has no Video cookie, so it calls these routes server-to-server with the person'sown 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`.++ `GET …/media/:id/bytes` (Range reads for the editor preview), `POST …/studio/publish` and++ `GET …/studio/cuts/:id` (how the trim is going).- `: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 amissing 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.++ - Publishing sends a `start`/`end` range, an optional `cover` second, title and visibility. `videoCuts.request`++ queues a **trim** through the existing cuts worker (`server/video/cut.js`, migration `0029_video_trim.sql`):++ ffmpeg reads the original through Media's signed URL and re-encodes exactly that range as a new upload of the++ owner's. The copy names its original in `video_uploads.edited_from`; the original is unchanged. After Media++ finishes the copy, MediaHub takes the chosen frame with ffmpeg and stores it through Media's own custom-thumbnail++ route, then records the cover URL on the copy. A range under one second or outside the video is refused with a++ plain sentence.++ - The worker is one cut at a time; the Studio page polls the cut until it is done. A copy that fails loses its++ partial upload and reports the plain reason.- **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 paidnnew file mode 100644@@@ -0,0 +1,8 @@++-- phase: expand++-- Studio's trim: a cut of an exact [start, end] range, with an optional cover frame taken from the++-- original. The copy is made by the existing cuts worker; the original is never changed.++ALTER TABLE video_cuts DROP CONSTRAINT IF EXISTS video_cuts_mode_check;++ALTER TABLE video_cuts ADD CONSTRAINT video_cuts_mode_check CHECK (mode IN ('pauses', 'tight', 'highlights', 'trim'));++ALTER TABLE video_cuts ADD COLUMN IF NOT EXISTS cover_seconds double precision;++ALTER TABLE video_cuts ADD COLUMN IF NOT EXISTS result_title text;++ALTER TABLE video_cuts ADD COLUMN IF NOT EXISTS result_visibility text;@@@ -87,7 +87,7 @@ 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 videoStudio = require('./video/studio').createStudio({ s, media, uploads: videoUploads, videoCuts, 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 });@@@ -49,6 +49,7 @@ function load(env = process.env) {// host-internal address (127.0.0.1:4100 in production), never the public origin.media: {url: trim(env.MEDIAHUB_MEDIA_URL || 'http://127.0.0.1:4100'),++ publicUrl: trim(env.MEDIAHUB_MEDIA_PUBLIC_URL || 'https://openvibe.media'),app: env.MEDIAHUB_MEDIA_APP || 'media-hub',timeoutMs: int(env.MEDIAHUB_MEDIA_TIMEOUT_MS, 30_000),},@@@ -67,7 +68,7 @@ function load(env = process.env) {// shown only when MEDIAHUB_STUDIO_VIDEO_EDIT=1: until Studio has real video tools, an// "Edit" action that only previews a video is a promise the page does not keep.studioUrl: trim(env.MEDIAHUB_STUDIO_URL || 'https://openvibe.studio'),-- studioVideoEdit: env.MEDIAHUB_STUDIO_VIDEO_EDIT === '1',++ studioVideoEdit: env.MEDIAHUB_STUDIO_VIDEO_EDIT !== 'off',// 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),@@@ -24,6 +24,7 @@* vodChunk(vodId, buffer, contentType) POST …/vods/:id/chunks (appended in order)* vodComplete(vodId) POST …/vods/:id/complete (probe, thumbnail; minutes for a big file)* vodGet(vodId) / vodUpdate(vodId, { title, visibility }) / vodDelete(vodId)++ * vodThumbnail(vodId, jpegBytes) POST …/thumbnails/vod/:id (custom cover)** Two details worth knowing. Media answers a private object's download with a signed URL on its *public* origin;* this client rewrites that URL onto the configured Media host before fetching it, so the bytes are read from the@@@ -90,6 +91,7 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = consoleconst vods = () => `${media.url}/api/v1/${encodeURIComponent(media.app)}/vods`;const watch = () => `${media.url}/api/v1/${encodeURIComponent(media.app)}/watch`;++ const thumbnails = () => `${media.url}/api/v1/${encodeURIComponent(media.app)}/thumbnails`;const VOD_EXT = { 'video/mp4': 'mp4', 'video/webm': 'webm', 'video/x-matroska': 'mkv' };return {@@@ -123,6 +125,12 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = consolevodSignedUrl: (vodId, ttlS = 3600) => call(`/${Number(vodId)}/signed-url?ttl=${Number(ttlS)}`, { root: vods() }),vodUpdate: (vodId, patch) => call(`/${Number(vodId)}`, { root: vods(), method: 'PUT', json: patch }),vodDelete: (vodId) => call(`/${Number(vodId)}`, { root: vods(), method: 'DELETE' }),++ /** A custom JPEG cover for a VOD, through Media's own thumbnail route (the app credential, owner checked here). */++ vodThumbnail(vodId, jpeg) {++ const form = new FormData();++ form.append('thumbnail', new Blob([jpeg], { type: 'image/jpeg' }), 'cover.jpg');++ return call(`/vod/${Number(vodId)}`, { root: thumbnails(), method: 'POST', body: form, timeoutMs: 60_000 });++ },/** Start an object. Media gives it back `uploading` until its bytes arrive and it is completed. */async init({ size, contentType, name, ownerSubject, multipart = false, partSize = null, visibility = 'private', ownerless = false }) {@@@ -92,11 +92,21 @@ function createVideoApiRoutes(ctx) {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 });++ const out = await ctx.videoStudio.publish(found.row, p, {++ start: b.start, end: b.end, cover: b.cover, title: b.title, visibility: b.visibility,++ });return res.status(202).json(out);} catch (err) { return studioProblem(req, res, err); }});++ r.get('/studio/cuts/:id', limits.reads('media-hub.studio.read'), async (req, res) => {++ const p = studio(req, res);++ if (!p) return undefined;++ try {++ return res.json(await ctx.videoStudio.cutStatus(String(req.params.id || ''), p));++ } 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;@@@ -6,6 +6,7 @@* pauses every pause of 1.5 seconds or more becomes half a second (lead-in and tail silence go)* tight every pause of 0.7 seconds or more becomes 0.3 seconds* highlights a reel of the best moments the AI edit found (server/video/ai-edit.js), in time order, at most 3 minutes++ * trim an exact [start, end] range from Studio's editor, with an optional cover frame** The copy is a new private upload of the same person (video_uploads.edited_from), made through the same path as a* browser recording (uploads.startRecording → chunk → complete), so Media gives it a probe, thumbnail and HLS, the AI@@@ -19,7 +20,7 @@* A copy is re-encoded rather than cut on keyframes because a browser recording (WebM) may have one keyframe in minutes.** plan…(…) the pure parts: pauses → kept ranges, highlights → reel-- * request(row, who, mode) queue a cut (one waiting or running per person, VIDEO_CUTS_PER_DAY a day)++ * request(row, who, mode, opts) queue a cut (one waiting or running per person, VIDEO_CUTS_PER_DAY a day)* forUpload(row) the cuts of an upload, newest first* sweep() the worker: one queued cut at a time; a cut left mid-way by a restart is retried once*/@@@ -30,6 +31,7 @@ const MODES = Object.freeze({pauses: { label: 'pauses cut', minPause: 1.5, keep: 0.25 },tight: { label: 'tight cut', minPause: 0.7, keep: 0.15 },highlights: { label: 'highlights', reelMax: 180, picks: 6 },++ trim: { label: 'trimmed' },});const PIECE = 8 * 1024 * 1024;const MAX_RANGES = 400;@@@ -174,15 +176,37 @@ function createCuts({ s, media, uploads, videoAi = null, config, log = console,return mediaBase ? `${mediaBase}${u.pathname}${u.search}` : signed.data.url;}-- async function request(row, who, mode) {-- if (!enabled) throw new UploadError(503, 'cut.off', 'The AI cut is not available here.');-- if (!MODES[mode]) throw new UploadError(422, 'cut.mode', 'Choose pauses, tight or highlights.');++ async function request(row, who, mode, opts = {}) {++ if (!enabled) throw new UploadError(503, 'cut.off', 'The video editor is not available here.');++ if (!MODES[mode]) throw new UploadError(422, 'cut.mode', 'Choose a trim, pauses, tight or highlights.');if (!who || who.requester !== row.owner) throw new UploadError(403, 'cut.not_yours', 'Only the person who uploaded a video can cut it.');if (row.status !== 'ready' || row.state !== 'active') throw new UploadError(409, 'cut.not_ready', 'The video has to be ready (and not hidden) first.');if (row.edited_from) throw new UploadError(409, 'cut.is_copy', 'This is already an edited copy: cut the original instead.');-- if (Number(row.duration_seconds) > cfg.maxSourceSeconds) throw new UploadError(413, 'cut.too_long', `The AI cut takes videos up to ${Math.round(cfg.maxSourceSeconds / 3600)} hours.`);++ if (Number(row.duration_seconds) > cfg.maxSourceSeconds) throw new UploadError(413, 'cut.too_long', `The cut takes videos up to ${Math.round(cfg.maxSourceSeconds / 3600)} hours.`);let plan = null;-- if (mode === 'highlights') {++ let cover = null;++ let resultTitle = null;++ let resultVisibility = null;++ if (mode === 'trim') {++ const duration = Number(row.duration_seconds) || 0;++ const start = Number(opts.start);++ const end = Number(opts.end);++ if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end > duration + 0.05 || end <= start) {++ throw new UploadError(422, 'cut.range', 'Choose a start and an end inside the video; the end has to be after the start.');++ }++ if (end - start < 1) throw new UploadError(422, 'cut.range', 'Keep at least one second of the video.');++ plan = { ranges: [[r2(start), r2(end)]], kept_seconds: r2(end - start), removed_count: null };++ if (opts.cover !== undefined && opts.cover !== null && opts.cover !== '') {++ cover = Number(opts.cover);++ if (!Number.isFinite(cover) || cover < 0 || cover > duration) throw new UploadError(422, 'cut.cover', 'Choose a cover frame inside the video.');++ cover = r2(cover);++ }++ resultTitle = String(opts.title == null ? '' : opts.title).replace(/\s+/g, ' ').trim().slice(0, 120) || null;++ if (opts.visibility !== undefined && opts.visibility !== null && opts.visibility !== '') {++ if (!['private', 'unlisted', 'public'].includes(opts.visibility)) throw new UploadError(422, 'cut.visibility', 'Choose private, unlisted or public.');++ resultVisibility = opts.visibility;++ }++ } else if (mode === 'highlights') {plan = planHighlights(videoAi ? await videoAi.forUpload(row) : null, Number(row.duration_seconds));if (!plan) throw new UploadError(409, 'cut.no_highlights', 'The AI edit found no highlights to make a reel from.');}@@@ -192,9 +216,9 @@ function createCuts({ s, media, uploads, videoAi = null, config, log = console,if (today >= cfg.perDay) throw new UploadError(429, 'cut.daily_limit', `That is ${cfg.perDay} cuts today. Try again tomorrow.`);const id = s.newId('vcut');const now = s.iso();-- await s.db.query(`INSERT INTO video_cuts (id, upload_id, owner, mode, ranges, source_seconds, kept_seconds, created_at, updated_at)-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)`,-- [id, row.id, row.owner, mode, plan ? JSON.stringify(plan.ranges) : null, Number(row.duration_seconds) || null, plan ? plan.kept_seconds : null, now]);++ await s.db.query(`INSERT INTO video_cuts (id, upload_id, owner, mode, ranges, source_seconds, kept_seconds, cover_seconds, result_title, result_visibility, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11)`,++ [id, row.id, row.owner, mode, plan ? JSON.stringify(plan.ranges) : null, Number(row.duration_seconds) || null, plan ? plan.kept_seconds : null, cover, resultTitle, resultVisibility, now]);kick();return await byId(id);}@@@ -259,6 +283,40 @@ function createCuts({ s, media, uploads, videoAi = null, config, log = console,return copy;}++ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));++++ /** The copy's Media VOD is ready after the background completion; a cover upload must wait for it. */++ async function waitReady(uploadId, timeoutMs = 15 * 60 * 1000) {++ const until = Date.now() + timeoutMs;++ while (Date.now() < until) {++ const row = await uploads.byId(uploadId);++ if (!row || row.status === 'ready' || row.status === 'failed') return row;++ await sleep(2000);++ }++ return await uploads.byId(uploadId);++ }++++ /** One JPEG at `seconds` from the original, as Media's custom-thumbnail route takes it. */++ async function extractCover(url, seconds) {++ const chunks = [];++ const r = await run(cfg.ffmpeg, ['-hide_banner', '-nostdin', '-loglevel', 'error', '-ss', String(Math.max(0, Number(seconds) || 0)),++ '-i', url, '-vframes', '1', '-vf', 'scale=1280:-2', '-f', 'image2pipe', '-vcodec', 'mjpeg', 'pipe:1'], {++ onStdout: (stdout) => stdout.on('data', (d) => chunks.push(d)), timeoutMs: 120_000,++ });++ if (r.code !== 0 || !chunks.length) throw new Error('ffmpeg could not take that frame');++ return Buffer.concat(chunks);++ }++++ /** A chosen frame becomes the copy's cover through Media's own custom-thumbnail route, after finalize. */++ async function applyCover(cut, copy, url) {++ if (cut.cover_seconds == null) return;++ const frame = await extractCover(url, cut.cover_seconds);++ const out = await media.vodThumbnail(copy.media_vod_id, frame);++ if (!out.ok || !out.data || !out.data.url) throw new Error(`Media refused the cover (${out.code || out.status})`);++ const absolute = /^https?:/i.test(out.data.url) ? out.data.url : `${String(config.media.publicUrl || 'https://openvibe.media').replace(/\/+$/, '')}${out.data.url}`;++ await s.db.query('UPDATE video_uploads SET thumbnail_url = $2 WHERE id = $1', [copy.id, absolute]);++ }++async function work(cut) {const original = await uploads.byId(cut.upload_id);if (!original || original.state !== 'active' || original.status !== 'ready') return save(cut.id, { status: 'failed', error: 'The original is no longer available.', finished_at: s.iso() });@@@ -278,12 +336,20 @@ function createCuts({ s, media, uploads, videoAi = null, config, log = console,await save(cut.id, { status: 'rendering', ranges: plan.ranges, kept_seconds: plan.kept_seconds, removed_count: plan.removed_count, source_seconds: duration });const label = MODES[cut.mode].label;copy = await uploads.startRecording({ requester: original.owner, username: original.owner_username }, {-- title: `${original.title.slice(0, 100)} (${label})`, description: original.description, visibility: 'private', contentType: 'video/mp4',++ title: cut.result_title || `${original.title.slice(0, 100)} (${label})`, description: original.description,++ visibility: cut.result_visibility || 'private', contentType: 'video/mp4',});await s.db.query('UPDATE video_uploads SET edited_from = $2 WHERE id = $1', [copy.id, original.id]);await save(cut.id, { result_upload_id: copy.id });copy = await render(cut, url, plan.ranges, plan.kept_seconds, info, copy);await uploads.complete(copy);++ if (cut.mode === 'trim' && cut.cover_seconds != null) {++ // The cover is written after Media's finalize, which makes its own thumbnail: wait for it.++ const finished = await waitReady(copy.id);++ if (!finished || finished.status !== 'ready') throw new UploadError(502, 'cut.copy', 'The copy could not be finished.');++ try { await applyCover(cut, copy, url); }++ catch (err) { log.warn(`[OpenVibe.Video] cut ${cut.id}: cover not set:`, err.message); }++ }return save(cut.id, { status: 'done', progress: 1, finished_at: s.iso() });} catch (err) {const known = err instanceof UploadError;@@@ -11,19 +11,20 @@** 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++ * POST /api/v1/video/studio/publish trim [start,end], cover and title → a cut on Video++ * GET /api/v1/video/studio/cuts/:id how that cut is going, and the copy's address** :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.++ * and the AI edit gets captions and chapters. The trim itself is made by the existing cuts worker++ * (server/video/cut.js); Studio never re-encodes a video in the browser.*/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}$/;@@@ -73,7 +74,7 @@ function wire(row, { canEdit = false, baseUrl = 'https://openvibe.video' } = {})};}--function createStudio({ s, media, uploads, config, log = console, fetchImpl = globalThis.fetch }) {++function createStudio({ s, media, uploads, videoCuts, 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(/\/+$/, '');@@@ -128,60 +129,45 @@ function createStudio({ s, media, uploads, config, log = console, fetchImpl = gl};}++ /** How a cut is told to Studio: no internals, and the result's own address once there is one. */++ function cutWire(cut, result) {++ const path = result ? watchPath(result) : null;++ return {++ id: cut.id,++ status: cut.status,++ mode: cut.mode,++ progress: Number(cut.progress) || 0,++ error: cut.error || null,++ source_seconds: cut.source_seconds == null ? null : Number(cut.source_seconds),++ kept_seconds: cut.kept_seconds == null ? null : Number(cut.kept_seconds),++ cover_seconds: cut.cover_seconds == null ? null : Number(cut.cover_seconds),++ result: result ? {++ id: result.id, status: result.status, title: result.title,++ url: path, watch_url: `${videoBase}${path}`,++ } : null,++ };++ }++/**-- * 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.++ * Publish Studio's trim as a new copy on Video, through the cuts service the site already++ * has. It queues one cut and answers at once; Studio polls /studio/cuts/:id for the copy.*/-- async function publish(row, principal, { title, visibility } = {}) {++ async function publish(row, principal, { start, end, cover, 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;++ const cut = await videoCuts.request(row, { requester: principal.requester, username: principal.username || null }, 'trim', {++ start, end, cover, title, visibility,++ });++ return { cut: cutWire(cut, null) };++ }++++ /** The cut's progress and, once made, the copy on Video. Only its owner may ask. */++ async function cutStatus(id, principal) {++ const cut = await videoCuts.byId(String(id || ''));++ if (!cut || cut.owner !== principal.requester) {++ throw new UploadError(404, 'studio.cut_not_found', 'No such edit, or it is not yours.');}++ const result = cut.result_upload_id ? await uploads.byId(cut.result_upload_id) : null;++ return { cut: cutWire(cut, result) };}return {@@@ -189,6 +175,7 @@ function createStudio({ s, media, uploads, config, log = console, fetchImpl = glwire: (row, options = {}) => wire(row, { ...options, baseUrl: videoBase }),bytes,publish,++ cutStatus,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). */@@@ -206,7 +206,7 @@ ${state === 'processing' || state === 'hidden' || state === 'failed' ? html`<a c}const CUT_STATUS = { queued: 'Waiting to start', measuring: 'Finding the pauses', rendering: 'Making the copy' };-- const CUT_LABEL = { pauses: 'Pauses cut', tight: 'Tight cut', highlights: 'Highlights reel' };++ const CUT_LABEL = { pauses: 'Pauses cut', tight: 'Tight cut', highlights: 'Highlights reel', trim: 'Trimmed' };function cutLine(c) {const what = html`<b>${CUT_LABEL[c.mode] || c.mode}</b>`;if (c.status === 'done') {@@@ -124,13 +124,26 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {const vod = [...vods.values()].find((v) => !v.deleted && v.watch_id === wanted);return vod ? json(res, 200, { kind: 'vod', id: vod.id }) : problem(res, 404, 'media.watch.not_found', 'No such watch id');}++ if (parts[3] === 'thumbnails') {++ // POST /api/v1/:app/thumbnails/vod/:id: the custom cover route (multipart 'thumbnail').++ const v = vods.get(Number(parts[5]));++ if (!v || v.deleted) return problem(res, 404, 'vod.not_found', 'VOD not found');++ if (parts[4] !== 'vod' || req.method !== 'POST') return problem(res, 404, 'route.not_found', 'no such route');++ const boundary = /boundary=([^;]+)/.exec(String(req.headers['content-type'] || ''));++ const piece = boundary ? raw.toString('latin1').split(`--${boundary[1]}`).find((x) => /name="thumbnail"/.test(x)) : null;++ if (!piece) return problem(res, 400, 'thumbnail.invalid', 'No image data provided');++ const at = piece.indexOf('\r\n\r\n');++ v.coverBytes = Buffer.from(piece.slice(at + 4, piece.length - 2), 'latin1');++ v.thumbnail_url = `/t/vod-${v.id}-cover.jpg`;++ return json(res, 200, { url: v.thumbnail_url });++ }if (parts[3] !== 'vods') return problem(res, 404, 'route.not_found', 'no such route');const vodPublicOf = (v) => ({id: v.id, watch_id: v.watch_id, title: v.title, visibility: v.visibility, user_id: v.user_id, is_recording: v.status !== 'ready',duration_seconds: v.status === 'ready' ? vodState.duration : 0, file_size: v.bytes.length,file_url: v.status === 'ready' ? `https://openvibe.media/v/vod-${v.id}.mp4` : null,hls_url: v.status === 'ready' ? `https://openvibe.media/o/med_vod${v.id}/master.m3u8` : null,-- thumbnail_url: v.status === 'ready' ? `https://openvibe.media/t/vod-${v.id}.jpg` : null,++ thumbnail_url: v.status === 'ready' ? (v.thumbnail_url ? `https://openvibe.media${v.thumbnail_url}` : `https://openvibe.media/t/vod-${v.id}.jpg`) : null,meta: v.meta || {},});if (parts.length === 4 && req.method === 'POST') {@@@ -38,7 +38,7 @@ function areq(base, path, { method = 'GET', bearer, json, range, cookie } = {})}(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', MEDIAHUB_STUDIO_VIDEO_EDIT: '1' } });++ 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);@@@ -94,21 +94,23 @@ function areq(base, path, { method = 'GET', bearer, json, range, cookie } = {})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' } });++ await check('publishing queues a trim cut through the existing cuts service', async () => {++ const r = await areq(t.base, '/api/v1/video/studio/publish', { method: 'POST', bearer: kimToken, json: { media: priv.id, start: 1, end: 3, cover: 2, 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.match(out.cut.id, /^vcut_[0-9A-Z]{26}$/);++ assert.strictEqual(out.cut.status, 'queued');++ assert.strictEqual(out.cut.mode, 'trim');++ const row = await t.ctx.s.db.maybe('SELECT * FROM video_cuts WHERE id = $1', [out.cut.id]);++ assert.deepStrictEqual([row.upload_id, row.owner, row.mode, row.cover_seconds, row.result_title, row.result_visibility], [priv.id, `user:${kim.subject}`, 'trim', 2, 'Rehearsal, from Studio', 'private']);++ assert.deepStrictEqual(row.ranges, [[1, 3]]);++ assert.strictEqual(row.kept_seconds, 2);++ const status = await areq(t.base, `/api/v1/video/studio/cuts/${out.cut.id}`, { bearer: kimToken });++ assert.strictEqual(status.status, 200, status.text.slice(0, 300));++ assert.strictEqual(status.json().cut.status, 'queued');++ assert.strictEqual((await areq(t.base, `/api/v1/video/studio/cuts/${out.cut.id}`, { bearer: samToken })).status, 404, 'not sam\'s edit');assert.strictEqual((await t.ctx.videoUploads.byId(priv.id)).title, 'Private rehearsal', 'the original is unchanged');++ await t.ctx.s.db.query("UPDATE video_cuts SET status = 'failed' WHERE id = $1", [out.cut.id]);});await check('a cookie from this site cannot call the Studio hand-off; it is Bearer only', async () => {@@@ -134,20 +136,20 @@ function areq(base, path, { method = 'GET', bearer, json, range, cookie } = {})await t.close();-- // The default: the hand-off API stays, but no page offers an Edit action until the editor is real.-- const off = 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' } });++ // The switch off: the hand-off API stays, but no page offers an Edit action.++ const off = 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', MEDIAHUB_STUDIO_VIDEO_EDIT: 'off' } });try {const kim2 = off.network.addUser('kim');const row = await off.ctx.videoUploads.startRecording({ requester: `user:${kim2.subject}`, username: 'kim' }, { title: 'Default off', contentType: 'video/webm', visibility: 'public' });await off.ctx.s.db.query("UPDATE video_uploads SET status = 'ready', duration_seconds = 2 WHERE id = $1", [row.id]);const page = await areq(off.base, `/watch?v=${row.watch_id}`, { cookie: off.signIn(kim2) });assert.strictEqual(page.status, 200);-- assert.ok(!page.text.includes('Edit in Studio'), 'no Edit action by default');++ assert.ok(!page.text.includes('Edit in Studio'), 'no Edit action with the switch off');const mine = await areq(off.base, '/me/videos', { cookie: off.signIn(kim2) });-- assert.ok(!mine.text.includes('Edit in Studio'), 'no link on Your videos by default');++ assert.ok(!mine.text.includes('Edit in Studio'), 'no link on Your videos with the switch off');const complete = await areq(off.base, `/api/v1/videos/${row.id}/complete`, { method: 'POST', cookie: off.signIn(kim2) });assert.strictEqual(complete.status, 202);-- assert.ok(!('edit_url' in complete.json().video), 'the record completion omits the link by default');++ assert.ok(!('edit_url' in complete.json().video), 'the record completion omits the link with the switch off');const api = await areq(off.base, `/api/v1/video/studio/media/${row.id}`, { bearer: off.network.userToken(kim2) });assert.strictEqual(api.status, 200, 'the hand-off API stays available');} finally { await off.close(); }nnew file mode 100644@@@ -0,0 +1,115 @@++'use strict';++/**++ * Studio's trim, end to end: a real 6-second MP4 becomes a cut of 1.00–4.00 s through the existing++ * cuts worker and ffmpeg, and the chosen frame becomes the copy's cover through Media's thumbnail++ * route. The copy's duration is checked against the range (within one second), and a range under++ * one second or outside the video is refused with a plain sentence.++ */++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const os = require('os');++const path = require('path');++const { spawnSync } = require('child_process');++const { boot, check, done } = require('./helpers/boot');++++const HAVE_FFMPEG = spawnSync('ffmpeg', ['-version']).status === 0 && spawnSync('ffprobe', ['-version']).status === 0;++++function makeVideo(file) {++ const r = spawnSync('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y',++ '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=15:duration=6',++ '-f', 'lavfi', '-i', 'sine=frequency=440:duration=6',++ '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', file]);++ if (r.status !== 0) throw new Error(`ffmpeg: ${r.stderr}`);++ return fs.readFileSync(file);++}++++function probe(buf) {++ const file = path.join(os.tmpdir(), `ov-trim-probe-${process.pid}-${Date.now()}.mp4`);++ fs.writeFileSync(file, buf);++ try {++ const r = spawnSync('ffprobe', ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', file]);++ const j = JSON.parse(String(r.stdout));++ return { duration: Number(j.format.duration), video: j.streams.some((x) => x.codec_type === 'video'), audio: j.streams.some((x) => x.codec_type === 'audio') };++ } finally { fs.rmSync(file, { force: true }); }++}++++function vreq(base, p, { method = 'GET', bearer, json } = {}) {++ return new Promise((resolve, reject) => {++ const u = new URL(base + p);++ const headers = { Host: 'openvibe.video' };++ if (bearer) headers.authorization = `Bearer ${bearer}`;++ if (method !== 'GET') headers['sec-fetch-site'] = 'same-origin';++ 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 () => {++ if (!HAVE_FFMPEG) {++ process.stdout.write(' - ffmpeg is not installed here: the end-to-end trim is skipped\n');++ return done();++ }++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-trim-'));++ const t = await boot({ env: { MEDIAHUB_VIDEO_URL: 'https://openvibe.video', VIDEO_CUTS_NICE: '0', MEDIAHUB_MEDIA_PUBLIC_URL: 'https://openvibe.media' } });++ const kim = t.network.addUser('kim');++ const token = t.network.userToken(kim);++ const owner = `user:${kim.subject}`;++ try {++ const bytes = makeVideo(path.join(dir, 'source.mp4'));++ const duration = probe(bytes).duration;++ t.media.vods.set(950, { id: 950, title: 'Six seconds', visibility: 'private', bytes, types: ['video/mp4'], status: 'ready', watch_id: 'sixsecs0001' });++ await t.ctx.s.db.query(`INSERT INTO video_uploads (id, owner, owner_username, title, description, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, parts_received, status, state, created_at, updated_at, ready_at, duration_seconds, watch_id)++ VALUES ($1, $2, 'kim', 'Six seconds', 'For the trim.', 'private', 950, 'video/mp4', $3, 1000, 1, 1, 'ready', 'active', $4, $4, $4, $5, 'sixsecs0001')`,++ ['vup_01J0000000000000000000000A', owner, bytes.length, t.ctx.s.iso(), duration]);++++ await check('the range is checked before a cut is queued', async () => {++ const short = await vreq(t.base, '/api/v1/video/studio/publish', { method: 'POST', bearer: token, json: { media: 'vup_01J0000000000000000000000A', start: 1, end: 1.4 } });++ assert.strictEqual(short.status, 422, short.text);++ assert.match(short.json().detail, /at least one second/i);++ const outside = await vreq(t.base, '/api/v1/video/studio/publish', { method: 'POST', bearer: token, json: { media: 'vup_01J0000000000000000000000A', start: 2, end: 99 } });++ assert.strictEqual(outside.status, 422, outside.text);++ assert.match(outside.json().detail, /inside the video/i);++ const cover = await vreq(t.base, '/api/v1/video/studio/publish', { method: 'POST', bearer: token, json: { media: 'vup_01J0000000000000000000000A', start: 1, end: 4, cover: 99 } });++ assert.strictEqual(cover.status, 422, cover.text);++ assert.match(cover.json().detail, /cover frame/i);++ assert.strictEqual(Number(await t.ctx.s.db.value("SELECT count(*) FROM video_cuts WHERE upload_id = 'vup_01J0000000000000000000000A'")), 0);++ });++++ await check('a real trim becomes a copy of the chosen duration, with the chosen cover', async () => {++ const start = 1;++ const end = 4;++ const publish = await vreq(t.base, '/api/v1/video/studio/publish', { method: 'POST', bearer: token, json: {++ media: 'vup_01J0000000000000000000000A', start, end, cover: 2.5, title: 'Studio trim', visibility: 'unlisted',++ } });++ assert.strictEqual(publish.status, 202, publish.text);++ const cutId = publish.json().cut.id;++ await t.ctx.videoCuts.sweep();++ const status = await vreq(t.base, `/api/v1/video/studio/cuts/${cutId}`, { bearer: token });++ assert.strictEqual(status.status, 200, status.text);++ const cut = status.json().cut;++ assert.strictEqual(cut.status, 'done', JSON.stringify(cut));++ assert.ok(cut.result && /^vup_/.test(cut.result.id));++ const copy = await t.ctx.videoUploads.byId(cut.result.id);++ assert.deepStrictEqual([copy.title, copy.visibility, copy.edited_from], ['Studio trim', 'unlisted', 'vup_01J0000000000000000000000A']);++ const vod = t.media.vods.get(Number(copy.media_vod_id));++ const out = probe(vod.bytes);++ assert.ok(out.video && out.audio, 'picture and sound');++ assert.ok(Math.abs(out.duration - (end - start)) <= 1, `the copy is ${out.duration} s for a ${end - start} s range`);++ assert.match(copy.thumbnail_url || '', /^https:\/\/openvibe\.media\/t\//, 'the chosen frame is the cover');++ assert.ok(vod.coverBytes && vod.coverBytes.length > 100 && vod.coverBytes[0] === 0xff && vod.coverBytes[1] === 0xd8, 'Media received a JPEG cover');++ });++ } finally {++ await t.close();++ fs.rmSync(dir, { recursive: true, force: true });++ }++ done();++})().catch((err) => { console.error(err); process.exit(1); });@@@ -52,7 +52,7 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru(async () => {const live = await startLive();-- const t = await boot({ env: { MEDIAHUB_VIDEO_URL: 'https://openvibe.video', MEDIAHUB_LIVE_URL: live.url, MEDIAHUB_LIVE_PUBLIC_URL: 'https://openvibe.live', MEDIAHUB_CHAT_URL: live.url, VIDEO_UPLOADS_PER_DAY: '4' } });++ const t = await boot({ env: { MEDIAHUB_VIDEO_URL: 'https://openvibe.video', MEDIAHUB_LIVE_URL: live.url, MEDIAHUB_LIVE_PUBLIC_URL: 'https://openvibe.live', MEDIAHUB_CHAT_URL: live.url, VIDEO_UPLOADS_PER_DAY: '4', MEDIAHUB_STUDIO_VIDEO_EDIT: 'off' } });const kim = t.network.addUser('kim');const sam = t.network.addUser('sam');const kimCookie = t.signIn(kim);
@@ -35,6 +35,8 @@ MEDIAHUB_EVENTS_URL=
@@ -35,6 +35,8 @@ MEDIAHUB_EVENTS_URL=
# Grant [media-hub, media.object.upload, openvibe.media] and [media-hub, media.object.read, openvibe.media]
# Grant [media-hub, media.object.upload, openvibe.media] and [media-hub, media.object.read, openvibe.media]
# on namespace media-hub. Media's `media-hub` tenant holds the bytes.
# on namespace media-hub. Media's `media-hub` tenant holds the bytes.
MEDIAHUB_MEDIA_URL=http://127.0.0.1:4100
MEDIAHUB_MEDIA_URL=http://127.0.0.1:4100
+# The public origin Media's /t/ cover URLs are made absolute on.
+MEDIAHUB_MEDIA_PUBLIC_URL=https://openvibe.media
MEDIAHUB_MEDIA_APP=media-hub
MEDIAHUB_MEDIA_APP=media-hub
#MEDIAHUB_MEDIA_TIMEOUT_MS=30000
#MEDIAHUB_MEDIA_TIMEOUT_MS=30000
@@ -63,8 +65,8 @@ MEDIAHUB_MEDIA_APP=media-hub
@@ -63,8 +65,8 @@ MEDIAHUB_MEDIA_APP=media-hub
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).
# Where "Edit in Studio" goes for a finished upload (the hand-off route).
MEDIAHUB_STUDIO_URL=https://openvibe.studio
MEDIAHUB_STUDIO_URL=https://openvibe.studio
-# Off until Studio has real video tools: the hand-off API stays, the Edit action is not shown.
-# MEDIAHUB_STUDIO_VIDEO_EDIT=1
+# On by default. Set off to hide the Edit in Studio action while leaving the hand-off API.
+# MEDIAHUB_STUDIO_VIDEO_EDIT=off
# 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
@@ -241,20 +241,26 @@ pages read Live's public JSON API server-side, signed out (Live already applies
@@ -241,20 +241,26 @@ 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`): with `MEDIAHUB_STUDIO_VIDEO_EDIT=1` (**off** by default), 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`); the record completion response
- carries the same `edit_url`. With the flag off, no page or response offers the action, but the hand-off API below
- stays available.
+- **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`), and the
+ record completion response carries the same `edit_url`. `MEDIAHUB_STUDIO_VIDEO_EDIT=off` (the switch is **on** by default) hides those three
+ actions while leaving the hand-off API below available.
- Studio is another origin and has no Video cookie, so it calls these routes server-to-server with the person's
- 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),
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`.
+ `GET …/media/:id/bytes` (Range reads for the editor preview), `POST …/studio/publish` and
+ `GET …/studio/cuts/:id` (how the trim is going).
- `:id` may be the upload id (`vup_…`), the `/watch?v=` id or Media's VOD id. The read rule is the watch page's
- `: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
(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.
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.
+ - Publishing sends a `start`/`end` range, an optional `cover` second, title and visibility. `videoCuts.request`
+ queues a **trim** through the existing cuts worker (`server/video/cut.js`, migration `0029_video_trim.sql`):
+ ffmpeg reads the original through Media's signed URL and re-encodes exactly that range as a new upload of the
+ owner's. The copy names its original in `video_uploads.edited_from`; the original is unchanged. After Media
+ finishes the copy, MediaHub takes the chosen frame with ffmpeg and stores it through Media's own custom-thumbnail
+ route, then records the cover URL on the copy. A range under one second or outside the video is refused with a
+ plain sentence.
+ - The worker is one cut at a time; the Studio page polls the cut until it is done. A copy that fails loses its
+ partial upload and reports the plain reason.
- **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
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+-- phase: expand
+-- Studio's trim: a cut of an exact [start, end] range, with an optional cover frame taken from the
+-- original. The copy is made by the existing cuts worker; the original is never changed.
+ALTER TABLE video_cuts DROP CONSTRAINT IF EXISTS video_cuts_mode_check;
+ALTER TABLE video_cuts ADD CONSTRAINT video_cuts_mode_check CHECK (mode IN ('pauses', 'tight', 'highlights', 'trim'));
+ALTER TABLE video_cuts ADD COLUMN IF NOT EXISTS cover_seconds double precision;
+ALTER TABLE video_cuts ADD COLUMN IF NOT EXISTS result_title text;
+ALTER TABLE video_cuts ADD COLUMN IF NOT EXISTS result_visibility text;
@@ -87,7 +87,7 @@ async function createApp(opts = {}) {
@@ -87,7 +87,7 @@ 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 videoStudio = require('./video/studio').createStudio({ s, media, uploads: videoUploads, videoCuts, 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 });
@@ -49,6 +49,7 @@ function load(env = process.env) {
@@ -49,6 +49,7 @@ function load(env = process.env) {
// host-internal address (127.0.0.1:4100 in production), never the public origin.
// host-internal address (127.0.0.1:4100 in production), never the public origin.
media: {
media: {
url: trim(env.MEDIAHUB_MEDIA_URL || 'http://127.0.0.1:4100'),
url: trim(env.MEDIAHUB_MEDIA_URL || 'http://127.0.0.1:4100'),
+ publicUrl: trim(env.MEDIAHUB_MEDIA_PUBLIC_URL || 'https://openvibe.media'),
app: env.MEDIAHUB_MEDIA_APP || 'media-hub',
app: env.MEDIAHUB_MEDIA_APP || 'media-hub',
timeoutMs: int(env.MEDIAHUB_MEDIA_TIMEOUT_MS, 30_000),
timeoutMs: int(env.MEDIAHUB_MEDIA_TIMEOUT_MS, 30_000),
},
},
@@ -67,7 +68,7 @@ function load(env = process.env) {
@@ -67,7 +68,7 @@ function load(env = process.env) {
// shown only when MEDIAHUB_STUDIO_VIDEO_EDIT=1: until Studio has real video tools, an
// shown only when MEDIAHUB_STUDIO_VIDEO_EDIT=1: until Studio has real video tools, an
// "Edit" action that only previews a video is a promise the page does not keep.
// "Edit" action that only previews a video is a promise the page does not keep.
studioUrl: trim(env.MEDIAHUB_STUDIO_URL || 'https://openvibe.studio'),
studioUrl: trim(env.MEDIAHUB_STUDIO_URL || 'https://openvibe.studio'),
- studioVideoEdit: env.MEDIAHUB_STUDIO_VIDEO_EDIT === '1',
+ studioVideoEdit: env.MEDIAHUB_STUDIO_VIDEO_EDIT !== 'off',
// 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),
@@ -24,6 +24,7 @@
@@ -24,6 +24,7 @@
* vodChunk(vodId, buffer, contentType) POST …/vods/:id/chunks (appended in order)
* vodChunk(vodId, buffer, contentType) POST …/vods/:id/chunks (appended in order)
* vodComplete(vodId) POST …/vods/:id/complete (probe, thumbnail; minutes for a big file)
* vodComplete(vodId) POST …/vods/:id/complete (probe, thumbnail; minutes for a big file)
* vodGet(vodId) / vodUpdate(vodId, { title, visibility }) / vodDelete(vodId)
* vodGet(vodId) / vodUpdate(vodId, { title, visibility }) / vodDelete(vodId)
+ * vodThumbnail(vodId, jpegBytes) POST …/thumbnails/vod/:id (custom cover)
*
*
* Two details worth knowing. Media answers a private object's download with a signed URL on its *public* origin;
* Two details worth knowing. Media answers a private object's download with a signed URL on its *public* origin;
* this client rewrites that URL onto the configured Media host before fetching it, so the bytes are read from the
* this client rewrites that URL onto the configured Media host before fetching it, so the bytes are read from the
@@ -90,6 +91,7 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -90,6 +91,7 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
const vods = () => `${media.url}/api/v1/${encodeURIComponent(media.app)}/vods`;
const vods = () => `${media.url}/api/v1/${encodeURIComponent(media.app)}/vods`;
const watch = () => `${media.url}/api/v1/${encodeURIComponent(media.app)}/watch`;
const watch = () => `${media.url}/api/v1/${encodeURIComponent(media.app)}/watch`;
+ const thumbnails = () => `${media.url}/api/v1/${encodeURIComponent(media.app)}/thumbnails`;
const VOD_EXT = { 'video/mp4': 'mp4', 'video/webm': 'webm', 'video/x-matroska': 'mkv' };
const VOD_EXT = { 'video/mp4': 'mp4', 'video/webm': 'webm', 'video/x-matroska': 'mkv' };
return {
return {
@@ -123,6 +125,12 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -123,6 +125,12 @@ function createMediaClient({ config, fetchImpl = globalThis.fetch, log = console
vodSignedUrl: (vodId, ttlS = 3600) => call(`/${Number(vodId)}/signed-url?ttl=${Number(ttlS)}`, { root: vods() }),
vodSignedUrl: (vodId, ttlS = 3600) => call(`/${Number(vodId)}/signed-url?ttl=${Number(ttlS)}`, { root: vods() }),
vodUpdate: (vodId, patch) => call(`/${Number(vodId)}`, { root: vods(), method: 'PUT', json: patch }),
vodUpdate: (vodId, patch) => call(`/${Number(vodId)}`, { root: vods(), method: 'PUT', json: patch }),
vodDelete: (vodId) => call(`/${Number(vodId)}`, { root: vods(), method: 'DELETE' }),
vodDelete: (vodId) => call(`/${Number(vodId)}`, { root: vods(), method: 'DELETE' }),
+ /** A custom JPEG cover for a VOD, through Media's own thumbnail route (the app credential, owner checked here). */
+ vodThumbnail(vodId, jpeg) {
+ const form = new FormData();
+ form.append('thumbnail', new Blob([jpeg], { type: 'image/jpeg' }), 'cover.jpg');
+ return call(`/vod/${Number(vodId)}`, { root: thumbnails(), method: 'POST', body: form, timeoutMs: 60_000 });
+ },
/** Start an object. Media gives it back `uploading` until its bytes arrive and it is completed. */
/** Start an object. Media gives it back `uploading` until its bytes arrive and it is completed. */
async init({ size, contentType, name, ownerSubject, multipart = false, partSize = null, visibility = 'private', ownerless = false }) {
async init({ size, contentType, name, ownerSubject, multipart = false, partSize = null, visibility = 'private', ownerless = false }) {
@@ -92,11 +92,21 @@ function createVideoApiRoutes(ctx) {
@@ -92,11 +92,21 @@ function createVideoApiRoutes(ctx) {
if (!found || found.row.owner !== p.requester) return studioProblem(req, res, ctx.videoStudio.notFound());
if (!found || found.row.owner !== p.requester) return studioProblem(req, res, ctx.videoStudio.notFound());
try {
try {
const b = req.body || {};
const b = req.body || {};
- const out = await ctx.videoStudio.publish(found.row, p, { title: b.title, visibility: b.visibility });
+ const out = await ctx.videoStudio.publish(found.row, p, {
+ start: b.start, end: b.end, cover: b.cover, title: b.title, visibility: b.visibility,
+ });
return res.status(202).json(out);
return res.status(202).json(out);
} catch (err) { return studioProblem(req, res, err); }
} catch (err) { return studioProblem(req, res, err); }
});
});
+ r.get('/studio/cuts/:id', limits.reads('media-hub.studio.read'), async (req, res) => {
+ const p = studio(req, res);
+ if (!p) return undefined;
+ try {
+ return res.json(await ctx.videoStudio.cutStatus(String(req.params.id || ''), p));
+ } 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;
@@ -6,6 +6,7 @@
@@ -6,6 +6,7 @@
* pauses every pause of 1.5 seconds or more becomes half a second (lead-in and tail silence go)
* pauses every pause of 1.5 seconds or more becomes half a second (lead-in and tail silence go)
* tight every pause of 0.7 seconds or more becomes 0.3 seconds
* tight every pause of 0.7 seconds or more becomes 0.3 seconds
* highlights a reel of the best moments the AI edit found (server/video/ai-edit.js), in time order, at most 3 minutes
* highlights a reel of the best moments the AI edit found (server/video/ai-edit.js), in time order, at most 3 minutes
+ * trim an exact [start, end] range from Studio's editor, with an optional cover frame
*
*
* The copy is a new private upload of the same person (video_uploads.edited_from), made through the same path as a
* The copy is a new private upload of the same person (video_uploads.edited_from), made through the same path as a
* browser recording (uploads.startRecording → chunk → complete), so Media gives it a probe, thumbnail and HLS, the AI
* browser recording (uploads.startRecording → chunk → complete), so Media gives it a probe, thumbnail and HLS, the AI
@@ -19,7 +20,7 @@
@@ -19,7 +20,7 @@
* A copy is re-encoded rather than cut on keyframes because a browser recording (WebM) may have one keyframe in minutes.
* A copy is re-encoded rather than cut on keyframes because a browser recording (WebM) may have one keyframe in minutes.
*
*
* plan…(…) the pure parts: pauses → kept ranges, highlights → reel
* plan…(…) the pure parts: pauses → kept ranges, highlights → reel
- * request(row, who, mode) queue a cut (one waiting or running per person, VIDEO_CUTS_PER_DAY a day)
+ * request(row, who, mode, opts) queue a cut (one waiting or running per person, VIDEO_CUTS_PER_DAY a day)
* forUpload(row) the cuts of an upload, newest first
* forUpload(row) the cuts of an upload, newest first
* sweep() the worker: one queued cut at a time; a cut left mid-way by a restart is retried once
* sweep() the worker: one queued cut at a time; a cut left mid-way by a restart is retried once
*/
*/
@@ -30,6 +31,7 @@ const MODES = Object.freeze({
@@ -30,6 +31,7 @@ const MODES = Object.freeze({
pauses: { label: 'pauses cut', minPause: 1.5, keep: 0.25 },
pauses: { label: 'pauses cut', minPause: 1.5, keep: 0.25 },
tight: { label: 'tight cut', minPause: 0.7, keep: 0.15 },
tight: { label: 'tight cut', minPause: 0.7, keep: 0.15 },
highlights: { label: 'highlights', reelMax: 180, picks: 6 },
highlights: { label: 'highlights', reelMax: 180, picks: 6 },
+ trim: { label: 'trimmed' },
});
});
const PIECE = 8 * 1024 * 1024;
const PIECE = 8 * 1024 * 1024;
const MAX_RANGES = 400;
const MAX_RANGES = 400;
@@ -174,15 +176,37 @@ function createCuts({ s, media, uploads, videoAi = null, config, log = console,
@@ -174,15 +176,37 @@ function createCuts({ s, media, uploads, videoAi = null, config, log = console,
return mediaBase ? `${mediaBase}${u.pathname}${u.search}` : signed.data.url;
return mediaBase ? `${mediaBase}${u.pathname}${u.search}` : signed.data.url;
}
}
- async function request(row, who, mode) {
- if (!enabled) throw new UploadError(503, 'cut.off', 'The AI cut is not available here.');
- if (!MODES[mode]) throw new UploadError(422, 'cut.mode', 'Choose pauses, tight or highlights.');
+ async function request(row, who, mode, opts = {}) {
+ if (!enabled) throw new UploadError(503, 'cut.off', 'The video editor is not available here.');
+ if (!MODES[mode]) throw new UploadError(422, 'cut.mode', 'Choose a trim, pauses, tight or highlights.');
if (!who || who.requester !== row.owner) throw new UploadError(403, 'cut.not_yours', 'Only the person who uploaded a video can cut it.');
if (!who || who.requester !== row.owner) throw new UploadError(403, 'cut.not_yours', 'Only the person who uploaded a video can cut it.');
if (row.status !== 'ready' || row.state !== 'active') throw new UploadError(409, 'cut.not_ready', 'The video has to be ready (and not hidden) first.');
if (row.status !== 'ready' || row.state !== 'active') throw new UploadError(409, 'cut.not_ready', 'The video has to be ready (and not hidden) first.');
if (row.edited_from) throw new UploadError(409, 'cut.is_copy', 'This is already an edited copy: cut the original instead.');
if (row.edited_from) throw new UploadError(409, 'cut.is_copy', 'This is already an edited copy: cut the original instead.');
- if (Number(row.duration_seconds) > cfg.maxSourceSeconds) throw new UploadError(413, 'cut.too_long', `The AI cut takes videos up to ${Math.round(cfg.maxSourceSeconds / 3600)} hours.`);
+ if (Number(row.duration_seconds) > cfg.maxSourceSeconds) throw new UploadError(413, 'cut.too_long', `The cut takes videos up to ${Math.round(cfg.maxSourceSeconds / 3600)} hours.`);
let plan = null;
let plan = null;
- if (mode === 'highlights') {
+ let cover = null;
+ let resultTitle = null;
+ let resultVisibility = null;
+ if (mode === 'trim') {
+ const duration = Number(row.duration_seconds) || 0;
+ const start = Number(opts.start);
+ const end = Number(opts.end);
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end > duration + 0.05 || end <= start) {
+ throw new UploadError(422, 'cut.range', 'Choose a start and an end inside the video; the end has to be after the start.');
+ }
+ if (end - start < 1) throw new UploadError(422, 'cut.range', 'Keep at least one second of the video.');
+ plan = { ranges: [[r2(start), r2(end)]], kept_seconds: r2(end - start), removed_count: null };
+ if (opts.cover !== undefined && opts.cover !== null && opts.cover !== '') {
+ cover = Number(opts.cover);
+ if (!Number.isFinite(cover) || cover < 0 || cover > duration) throw new UploadError(422, 'cut.cover', 'Choose a cover frame inside the video.');
+ cover = r2(cover);
+ }
+ resultTitle = String(opts.title == null ? '' : opts.title).replace(/\s+/g, ' ').trim().slice(0, 120) || null;
+ if (opts.visibility !== undefined && opts.visibility !== null && opts.visibility !== '') {
+ if (!['private', 'unlisted', 'public'].includes(opts.visibility)) throw new UploadError(422, 'cut.visibility', 'Choose private, unlisted or public.');
+ resultVisibility = opts.visibility;
+ }
+ } else if (mode === 'highlights') {
plan = planHighlights(videoAi ? await videoAi.forUpload(row) : null, Number(row.duration_seconds));
plan = planHighlights(videoAi ? await videoAi.forUpload(row) : null, Number(row.duration_seconds));
if (!plan) throw new UploadError(409, 'cut.no_highlights', 'The AI edit found no highlights to make a reel from.');
if (!plan) throw new UploadError(409, 'cut.no_highlights', 'The AI edit found no highlights to make a reel from.');
}
}
@@ -192,9 +216,9 @@ function createCuts({ s, media, uploads, videoAi = null, config, log = console,
@@ -192,9 +216,9 @@ function createCuts({ s, media, uploads, videoAi = null, config, log = console,
if (today >= cfg.perDay) throw new UploadError(429, 'cut.daily_limit', `That is ${cfg.perDay} cuts today. Try again tomorrow.`);
if (today >= cfg.perDay) throw new UploadError(429, 'cut.daily_limit', `That is ${cfg.perDay} cuts today. Try again tomorrow.`);
const id = s.newId('vcut');
const id = s.newId('vcut');
const now = s.iso();
const now = s.iso();
- await s.db.query(`INSERT INTO video_cuts (id, upload_id, owner, mode, ranges, source_seconds, kept_seconds, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)`,
- [id, row.id, row.owner, mode, plan ? JSON.stringify(plan.ranges) : null, Number(row.duration_seconds) || null, plan ? plan.kept_seconds : null, now]);
+ await s.db.query(`INSERT INTO video_cuts (id, upload_id, owner, mode, ranges, source_seconds, kept_seconds, cover_seconds, result_title, result_visibility, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11)`,
+ [id, row.id, row.owner, mode, plan ? JSON.stringify(plan.ranges) : null, Number(row.duration_seconds) || null, plan ? plan.kept_seconds : null, cover, resultTitle, resultVisibility, now]);
kick();
kick();
return await byId(id);
return await byId(id);
}
}
@@ -259,6 +283,40 @@ function createCuts({ s, media, uploads, videoAi = null, config, log = console,
@@ -259,6 +283,40 @@ function createCuts({ s, media, uploads, videoAi = null, config, log = console,
return copy;
return copy;
}
}
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+ /** The copy's Media VOD is ready after the background completion; a cover upload must wait for it. */
+ async function waitReady(uploadId, timeoutMs = 15 * 60 * 1000) {
+ const until = Date.now() + timeoutMs;
+ while (Date.now() < until) {
+ const row = await uploads.byId(uploadId);
+ if (!row || row.status === 'ready' || row.status === 'failed') return row;
+ await sleep(2000);
+ }
+ return await uploads.byId(uploadId);
+ }
+
+ /** One JPEG at `seconds` from the original, as Media's custom-thumbnail route takes it. */
+ async function extractCover(url, seconds) {
+ const chunks = [];
+ const r = await run(cfg.ffmpeg, ['-hide_banner', '-nostdin', '-loglevel', 'error', '-ss', String(Math.max(0, Number(seconds) || 0)),
+ '-i', url, '-vframes', '1', '-vf', 'scale=1280:-2', '-f', 'image2pipe', '-vcodec', 'mjpeg', 'pipe:1'], {
+ onStdout: (stdout) => stdout.on('data', (d) => chunks.push(d)), timeoutMs: 120_000,
+ });
+ if (r.code !== 0 || !chunks.length) throw new Error('ffmpeg could not take that frame');
+ return Buffer.concat(chunks);
+ }
+
+ /** A chosen frame becomes the copy's cover through Media's own custom-thumbnail route, after finalize. */
+ async function applyCover(cut, copy, url) {
+ if (cut.cover_seconds == null) return;
+ const frame = await extractCover(url, cut.cover_seconds);
+ const out = await media.vodThumbnail(copy.media_vod_id, frame);
+ if (!out.ok || !out.data || !out.data.url) throw new Error(`Media refused the cover (${out.code || out.status})`);
+ const absolute = /^https?:/i.test(out.data.url) ? out.data.url : `${String(config.media.publicUrl || 'https://openvibe.media').replace(/\/+$/, '')}${out.data.url}`;
+ await s.db.query('UPDATE video_uploads SET thumbnail_url = $2 WHERE id = $1', [copy.id, absolute]);
+ }
+
async function work(cut) {
async function work(cut) {
const original = await uploads.byId(cut.upload_id);
const original = await uploads.byId(cut.upload_id);
if (!original || original.state !== 'active' || original.status !== 'ready') return save(cut.id, { status: 'failed', error: 'The original is no longer available.', finished_at: s.iso() });
if (!original || original.state !== 'active' || original.status !== 'ready') return save(cut.id, { status: 'failed', error: 'The original is no longer available.', finished_at: s.iso() });
@@ -278,12 +336,20 @@ function createCuts({ s, media, uploads, videoAi = null, config, log = console,
@@ -278,12 +336,20 @@ function createCuts({ s, media, uploads, videoAi = null, config, log = console,
await save(cut.id, { status: 'rendering', ranges: plan.ranges, kept_seconds: plan.kept_seconds, removed_count: plan.removed_count, source_seconds: duration });
await save(cut.id, { status: 'rendering', ranges: plan.ranges, kept_seconds: plan.kept_seconds, removed_count: plan.removed_count, source_seconds: duration });
const label = MODES[cut.mode].label;
const label = MODES[cut.mode].label;
copy = await uploads.startRecording({ requester: original.owner, username: original.owner_username }, {
copy = await uploads.startRecording({ requester: original.owner, username: original.owner_username }, {
- title: `${original.title.slice(0, 100)} (${label})`, description: original.description, visibility: 'private', contentType: 'video/mp4',
+ title: cut.result_title || `${original.title.slice(0, 100)} (${label})`, description: original.description,
+ visibility: cut.result_visibility || 'private', contentType: 'video/mp4',
});
});
await s.db.query('UPDATE video_uploads SET edited_from = $2 WHERE id = $1', [copy.id, original.id]);
await s.db.query('UPDATE video_uploads SET edited_from = $2 WHERE id = $1', [copy.id, original.id]);
await save(cut.id, { result_upload_id: copy.id });
await save(cut.id, { result_upload_id: copy.id });
copy = await render(cut, url, plan.ranges, plan.kept_seconds, info, copy);
copy = await render(cut, url, plan.ranges, plan.kept_seconds, info, copy);
await uploads.complete(copy);
await uploads.complete(copy);
+ if (cut.mode === 'trim' && cut.cover_seconds != null) {
+ // The cover is written after Media's finalize, which makes its own thumbnail: wait for it.
+ const finished = await waitReady(copy.id);
+ if (!finished || finished.status !== 'ready') throw new UploadError(502, 'cut.copy', 'The copy could not be finished.');
+ try { await applyCover(cut, copy, url); }
+ catch (err) { log.warn(`[OpenVibe.Video] cut ${cut.id}: cover not set:`, err.message); }
+ }
return save(cut.id, { status: 'done', progress: 1, finished_at: s.iso() });
return save(cut.id, { status: 'done', progress: 1, finished_at: s.iso() });
} catch (err) {
} catch (err) {
const known = err instanceof UploadError;
const known = err instanceof UploadError;
@@ -11,19 +11,20 @@
@@ -11,19 +11,20 @@
*
*
* GET /api/v1/video/studio/media/:id the video (metadata, and where it came from)
* 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)
* 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
+ * POST /api/v1/video/studio/publish trim [start,end], cover and title → a cut on Video
+ * GET /api/v1/video/studio/cuts/:id how that cut is going, and the copy's address
*
*
* :id is one of the ids the person already has for the video: the upload id (vup_…), the
* :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
* /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
* 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
* 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,
* upload path as a browser recording, so Media probes them, makes the thumbnail and HLS,
- * and the AI edit gets captions and chapters.
+ * and the AI edit gets captions and chapters. The trim itself is made by the existing cuts worker
+ * (server/video/cut.js); Studio never re-encodes a video in the browser.
*/
*/
const { Readable } = require('node:stream');
const { Readable } = require('node:stream');
const { UploadError } = require('./uploads');
const { UploadError } = require('./uploads');
-const PIECE = 8 * 1024 * 1024;
const VUP_RE = /^vup_[0-9A-HJKMNP-TV-Z]{26}$/;
const VUP_RE = /^vup_[0-9A-HJKMNP-TV-Z]{26}$/;
const WATCH_RE = /^[A-Za-z0-9_-]{11}$/;
const WATCH_RE = /^[A-Za-z0-9_-]{11}$/;
const VOD_RE = /^[1-9]\d{0,15}$/;
const VOD_RE = /^[1-9]\d{0,15}$/;
@@ -73,7 +74,7 @@ function wire(row, { canEdit = false, baseUrl = 'https://openvibe.video' } = {})
@@ -73,7 +74,7 @@ function wire(row, { canEdit = false, baseUrl = 'https://openvibe.video' } = {})
};
};
}
}
-function createStudio({ s, media, uploads, config, log = console, fetchImpl = globalThis.fetch }) {
+function createStudio({ s, media, uploads, videoCuts, config, log = console, fetchImpl = globalThis.fetch }) {
const mediaBase = String((config.media && config.media.url) || '').replace(/\/+$/, '');
const mediaBase = String((config.media && config.media.url) || '').replace(/\/+$/, '');
const videoBase = String((config.video && config.video.url) || 'https://openvibe.video').replace(/\/+$/, '');
const videoBase = String((config.video && config.video.url) || 'https://openvibe.video').replace(/\/+$/, '');
@@ -128,60 +129,45 @@ function createStudio({ s, media, uploads, config, log = console, fetchImpl = gl
@@ -128,60 +129,45 @@ function createStudio({ s, media, uploads, config, log = console, fetchImpl = gl
};
};
}
}
+ /** How a cut is told to Studio: no internals, and the result's own address once there is one. */
+ function cutWire(cut, result) {
+ const path = result ? watchPath(result) : null;
+ return {
+ id: cut.id,
+ status: cut.status,
+ mode: cut.mode,
+ progress: Number(cut.progress) || 0,
+ error: cut.error || null,
+ source_seconds: cut.source_seconds == null ? null : Number(cut.source_seconds),
+ kept_seconds: cut.kept_seconds == null ? null : Number(cut.kept_seconds),
+ cover_seconds: cut.cover_seconds == null ? null : Number(cut.cover_seconds),
+ result: result ? {
+ id: result.id, status: result.status, title: result.title,
+ url: path, watch_url: `${videoBase}${path}`,
+ } : null,
+ };
+ }
+
/**
/**
- * 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.
+ * Publish Studio's trim as a new copy on Video, through the cuts service the site already
+ * has. It queues one cut and answers at once; Studio polls /studio/cuts/:id for the copy.
*/
*/
- async function publish(row, principal, { title, visibility } = {}) {
+ async function publish(row, principal, { start, end, cover, title, visibility } = {}) {
if (!row || row.owner !== principal.requester) throw notFound();
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;
+ const cut = await videoCuts.request(row, { requester: principal.requester, username: principal.username || null }, 'trim', {
+ start, end, cover, title, visibility,
+ });
+ return { cut: cutWire(cut, null) };
+ }
+
+ /** The cut's progress and, once made, the copy on Video. Only its owner may ask. */
+ async function cutStatus(id, principal) {
+ const cut = await videoCuts.byId(String(id || ''));
+ if (!cut || cut.owner !== principal.requester) {
+ throw new UploadError(404, 'studio.cut_not_found', 'No such edit, or it is not yours.');
}
}
+ const result = cut.result_upload_id ? await uploads.byId(cut.result_upload_id) : null;
+ return { cut: cutWire(cut, result) };
}
}
return {
return {
@@ -189,6 +175,7 @@ function createStudio({ s, media, uploads, config, log = console, fetchImpl = gl
@@ -189,6 +175,7 @@ function createStudio({ s, media, uploads, config, log = console, fetchImpl = gl
wire: (row, options = {}) => wire(row, { ...options, baseUrl: videoBase }),
wire: (row, options = {}) => wire(row, { ...options, baseUrl: videoBase }),
bytes,
bytes,
publish,
publish,
+ cutStatus,
notFound,
notFound,
findRow: (id) => findRow(uploads, id),
findRow: (id) => findRow(uploads, id),
/** A stream as an Express response can take it (kept here so the route needs no node:stream import). */
/** A stream as an Express response can take it (kept here so the route needs no node:stream import). */
@@ -206,7 +206,7 @@ ${state === 'processing' || state === 'hidden' || state === 'failed' ? html`<a c
@@ -206,7 +206,7 @@ ${state === 'processing' || state === 'hidden' || state === 'failed' ? html`<a c
}
}
const CUT_STATUS = { queued: 'Waiting to start', measuring: 'Finding the pauses', rendering: 'Making the copy' };
const CUT_STATUS = { queued: 'Waiting to start', measuring: 'Finding the pauses', rendering: 'Making the copy' };
- const CUT_LABEL = { pauses: 'Pauses cut', tight: 'Tight cut', highlights: 'Highlights reel' };
+ const CUT_LABEL = { pauses: 'Pauses cut', tight: 'Tight cut', highlights: 'Highlights reel', trim: 'Trimmed' };
function cutLine(c) {
function cutLine(c) {
const what = html`<b>${CUT_LABEL[c.mode] || c.mode}</b>`;
const what = html`<b>${CUT_LABEL[c.mode] || c.mode}</b>`;
if (c.status === 'done') {
if (c.status === 'done') {
@@ -124,13 +124,26 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
@@ -124,13 +124,26 @@ async function startMedia({ maxSingle = 256 * 1024 * 1024 } = {}) {
const vod = [...vods.values()].find((v) => !v.deleted && v.watch_id === wanted);
const vod = [...vods.values()].find((v) => !v.deleted && v.watch_id === wanted);
return vod ? json(res, 200, { kind: 'vod', id: vod.id }) : problem(res, 404, 'media.watch.not_found', 'No such watch id');
return vod ? json(res, 200, { kind: 'vod', id: vod.id }) : problem(res, 404, 'media.watch.not_found', 'No such watch id');
}
}
+ if (parts[3] === 'thumbnails') {
+ // POST /api/v1/:app/thumbnails/vod/:id: the custom cover route (multipart 'thumbnail').
+ const v = vods.get(Number(parts[5]));
+ if (!v || v.deleted) return problem(res, 404, 'vod.not_found', 'VOD not found');
+ if (parts[4] !== 'vod' || req.method !== 'POST') return problem(res, 404, 'route.not_found', 'no such route');
+ const boundary = /boundary=([^;]+)/.exec(String(req.headers['content-type'] || ''));
+ const piece = boundary ? raw.toString('latin1').split(`--${boundary[1]}`).find((x) => /name="thumbnail"/.test(x)) : null;
+ if (!piece) return problem(res, 400, 'thumbnail.invalid', 'No image data provided');
+ const at = piece.indexOf('\r\n\r\n');
+ v.coverBytes = Buffer.from(piece.slice(at + 4, piece.length - 2), 'latin1');
+ v.thumbnail_url = `/t/vod-${v.id}-cover.jpg`;
+ return json(res, 200, { url: v.thumbnail_url });
+ }
if (parts[3] !== 'vods') return problem(res, 404, 'route.not_found', 'no such route');
if (parts[3] !== 'vods') return problem(res, 404, 'route.not_found', 'no such route');
const vodPublicOf = (v) => ({
const vodPublicOf = (v) => ({
id: v.id, watch_id: v.watch_id, title: v.title, visibility: v.visibility, user_id: v.user_id, is_recording: v.status !== 'ready',
id: v.id, watch_id: v.watch_id, title: v.title, visibility: v.visibility, user_id: v.user_id, is_recording: v.status !== 'ready',
duration_seconds: v.status === 'ready' ? vodState.duration : 0, file_size: v.bytes.length,
duration_seconds: v.status === 'ready' ? vodState.duration : 0, file_size: v.bytes.length,
file_url: v.status === 'ready' ? `https://openvibe.media/v/vod-${v.id}.mp4` : null,
file_url: v.status === 'ready' ? `https://openvibe.media/v/vod-${v.id}.mp4` : null,
hls_url: v.status === 'ready' ? `https://openvibe.media/o/med_vod${v.id}/master.m3u8` : null,
hls_url: v.status === 'ready' ? `https://openvibe.media/o/med_vod${v.id}/master.m3u8` : null,
- thumbnail_url: v.status === 'ready' ? `https://openvibe.media/t/vod-${v.id}.jpg` : null,
+ thumbnail_url: v.status === 'ready' ? (v.thumbnail_url ? `https://openvibe.media${v.thumbnail_url}` : `https://openvibe.media/t/vod-${v.id}.jpg`) : null,
meta: v.meta || {},
meta: v.meta || {},
});
});
if (parts.length === 4 && req.method === 'POST') {
if (parts.length === 4 && req.method === 'POST') {
@@ -38,7 +38,7 @@ function areq(base, path, { method = 'GET', bearer, json, range, cookie } = {})
@@ -38,7 +38,7 @@ function areq(base, path, { method = 'GET', bearer, json, range, cookie } = {})
}
}
(async () => {
(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', MEDIAHUB_STUDIO_VIDEO_EDIT: '1' } });
+ 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 kim = t.network.addUser('kim');
const sam = t.network.addUser('sam');
const sam = t.network.addUser('sam');
const kimToken = t.network.userToken(kim);
const kimToken = t.network.userToken(kim);
@@ -94,21 +94,23 @@ function areq(base, path, { method = 'GET', bearer, json, range, cookie } = {})
@@ -94,21 +94,23 @@ function areq(base, path, { method = 'GET', bearer, json, range, cookie } = {})
assert.strictEqual((await areq(t.base, `/api/v1/video/studio/media/${priv.id}/bytes`)).status, 401);
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' } });
+ await check('publishing queues a trim cut through the existing cuts service', async () => {
+ const r = await areq(t.base, '/api/v1/video/studio/publish', { method: 'POST', bearer: kimToken, json: { media: priv.id, start: 1, end: 3, cover: 2, title: 'Rehearsal, from Studio', visibility: 'private' } });
assert.strictEqual(r.status, 202, r.text.slice(0, 400));
assert.strictEqual(r.status, 202, r.text.slice(0, 400));
const out = r.json();
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.match(out.cut.id, /^vcut_[0-9A-Z]{26}$/);
+ assert.strictEqual(out.cut.status, 'queued');
+ assert.strictEqual(out.cut.mode, 'trim');
+ const row = await t.ctx.s.db.maybe('SELECT * FROM video_cuts WHERE id = $1', [out.cut.id]);
+ assert.deepStrictEqual([row.upload_id, row.owner, row.mode, row.cover_seconds, row.result_title, row.result_visibility], [priv.id, `user:${kim.subject}`, 'trim', 2, 'Rehearsal, from Studio', 'private']);
+ assert.deepStrictEqual(row.ranges, [[1, 3]]);
+ assert.strictEqual(row.kept_seconds, 2);
+ const status = await areq(t.base, `/api/v1/video/studio/cuts/${out.cut.id}`, { bearer: kimToken });
+ assert.strictEqual(status.status, 200, status.text.slice(0, 300));
+ assert.strictEqual(status.json().cut.status, 'queued');
+ assert.strictEqual((await areq(t.base, `/api/v1/video/studio/cuts/${out.cut.id}`, { bearer: samToken })).status, 404, 'not sam\'s edit');
assert.strictEqual((await t.ctx.videoUploads.byId(priv.id)).title, 'Private rehearsal', 'the original is unchanged');
assert.strictEqual((await t.ctx.videoUploads.byId(priv.id)).title, 'Private rehearsal', 'the original is unchanged');
+ await t.ctx.s.db.query("UPDATE video_cuts SET status = 'failed' WHERE id = $1", [out.cut.id]);
});
});
await check('a cookie from this site cannot call the Studio hand-off; it is Bearer only', async () => {
await check('a cookie from this site cannot call the Studio hand-off; it is Bearer only', async () => {
@@ -134,20 +136,20 @@ function areq(base, path, { method = 'GET', bearer, json, range, cookie } = {})
@@ -134,20 +136,20 @@ function areq(base, path, { method = 'GET', bearer, json, range, cookie } = {})
await t.close();
await t.close();
- // The default: the hand-off API stays, but no page offers an Edit action until the editor is real.
- const off = 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' } });
+ // The switch off: the hand-off API stays, but no page offers an Edit action.
+ const off = 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', MEDIAHUB_STUDIO_VIDEO_EDIT: 'off' } });
try {
try {
const kim2 = off.network.addUser('kim');
const kim2 = off.network.addUser('kim');
const row = await off.ctx.videoUploads.startRecording({ requester: `user:${kim2.subject}`, username: 'kim' }, { title: 'Default off', contentType: 'video/webm', visibility: 'public' });
const row = await off.ctx.videoUploads.startRecording({ requester: `user:${kim2.subject}`, username: 'kim' }, { title: 'Default off', contentType: 'video/webm', visibility: 'public' });
await off.ctx.s.db.query("UPDATE video_uploads SET status = 'ready', duration_seconds = 2 WHERE id = $1", [row.id]);
await off.ctx.s.db.query("UPDATE video_uploads SET status = 'ready', duration_seconds = 2 WHERE id = $1", [row.id]);
const page = await areq(off.base, `/watch?v=${row.watch_id}`, { cookie: off.signIn(kim2) });
const page = await areq(off.base, `/watch?v=${row.watch_id}`, { cookie: off.signIn(kim2) });
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
- assert.ok(!page.text.includes('Edit in Studio'), 'no Edit action by default');
+ assert.ok(!page.text.includes('Edit in Studio'), 'no Edit action with the switch off');
const mine = await areq(off.base, '/me/videos', { cookie: off.signIn(kim2) });
const mine = await areq(off.base, '/me/videos', { cookie: off.signIn(kim2) });
- assert.ok(!mine.text.includes('Edit in Studio'), 'no link on Your videos by default');
+ assert.ok(!mine.text.includes('Edit in Studio'), 'no link on Your videos with the switch off');
const complete = await areq(off.base, `/api/v1/videos/${row.id}/complete`, { method: 'POST', cookie: off.signIn(kim2) });
const complete = await areq(off.base, `/api/v1/videos/${row.id}/complete`, { method: 'POST', cookie: off.signIn(kim2) });
assert.strictEqual(complete.status, 202);
assert.strictEqual(complete.status, 202);
- assert.ok(!('edit_url' in complete.json().video), 'the record completion omits the link by default');
+ assert.ok(!('edit_url' in complete.json().video), 'the record completion omits the link with the switch off');
const api = await areq(off.base, `/api/v1/video/studio/media/${row.id}`, { bearer: off.network.userToken(kim2) });
const api = await areq(off.base, `/api/v1/video/studio/media/${row.id}`, { bearer: off.network.userToken(kim2) });
assert.strictEqual(api.status, 200, 'the hand-off API stays available');
assert.strictEqual(api.status, 200, 'the hand-off API stays available');
} finally { await off.close(); }
} finally { await off.close(); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
+'use strict';
+/**
+ * Studio's trim, end to end: a real 6-second MP4 becomes a cut of 1.00–4.00 s through the existing
+ * cuts worker and ffmpeg, and the chosen frame becomes the copy's cover through Media's thumbnail
+ * route. The copy's duration is checked against the range (within one second), and a range under
+ * one second or outside the video is refused with a plain sentence.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const os = require('os');
+const path = require('path');
+const { spawnSync } = require('child_process');
+const { boot, check, done } = require('./helpers/boot');
+
+const HAVE_FFMPEG = spawnSync('ffmpeg', ['-version']).status === 0 && spawnSync('ffprobe', ['-version']).status === 0;
+
+function makeVideo(file) {
+ const r = spawnSync('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y',
+ '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=15:duration=6',
+ '-f', 'lavfi', '-i', 'sine=frequency=440:duration=6',
+ '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', file]);
+ if (r.status !== 0) throw new Error(`ffmpeg: ${r.stderr}`);
+ return fs.readFileSync(file);
+}
+
+function probe(buf) {
+ const file = path.join(os.tmpdir(), `ov-trim-probe-${process.pid}-${Date.now()}.mp4`);
+ fs.writeFileSync(file, buf);
+ try {
+ const r = spawnSync('ffprobe', ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', file]);
+ const j = JSON.parse(String(r.stdout));
+ return { duration: Number(j.format.duration), video: j.streams.some((x) => x.codec_type === 'video'), audio: j.streams.some((x) => x.codec_type === 'audio') };
+ } finally { fs.rmSync(file, { force: true }); }
+}
+
+function vreq(base, p, { method = 'GET', bearer, json } = {}) {
+ return new Promise((resolve, reject) => {
+ const u = new URL(base + p);
+ const headers = { Host: 'openvibe.video' };
+ if (bearer) headers.authorization = `Bearer ${bearer}`;
+ if (method !== 'GET') headers['sec-fetch-site'] = 'same-origin';
+ 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 () => {
+ if (!HAVE_FFMPEG) {
+ process.stdout.write(' - ffmpeg is not installed here: the end-to-end trim is skipped\n');
+ return done();
+ }
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-trim-'));
+ const t = await boot({ env: { MEDIAHUB_VIDEO_URL: 'https://openvibe.video', VIDEO_CUTS_NICE: '0', MEDIAHUB_MEDIA_PUBLIC_URL: 'https://openvibe.media' } });
+ const kim = t.network.addUser('kim');
+ const token = t.network.userToken(kim);
+ const owner = `user:${kim.subject}`;
+ try {
+ const bytes = makeVideo(path.join(dir, 'source.mp4'));
+ const duration = probe(bytes).duration;
+ t.media.vods.set(950, { id: 950, title: 'Six seconds', visibility: 'private', bytes, types: ['video/mp4'], status: 'ready', watch_id: 'sixsecs0001' });
+ await t.ctx.s.db.query(`INSERT INTO video_uploads (id, owner, owner_username, title, description, visibility, media_vod_id, content_type, size_bytes, part_size, parts_expected, parts_received, status, state, created_at, updated_at, ready_at, duration_seconds, watch_id)
+ VALUES ($1, $2, 'kim', 'Six seconds', 'For the trim.', 'private', 950, 'video/mp4', $3, 1000, 1, 1, 'ready', 'active', $4, $4, $4, $5, 'sixsecs0001')`,
+ ['vup_01J0000000000000000000000A', owner, bytes.length, t.ctx.s.iso(), duration]);
+
+ await check('the range is checked before a cut is queued', async () => {
+ const short = await vreq(t.base, '/api/v1/video/studio/publish', { method: 'POST', bearer: token, json: { media: 'vup_01J0000000000000000000000A', start: 1, end: 1.4 } });
+ assert.strictEqual(short.status, 422, short.text);
+ assert.match(short.json().detail, /at least one second/i);
+ const outside = await vreq(t.base, '/api/v1/video/studio/publish', { method: 'POST', bearer: token, json: { media: 'vup_01J0000000000000000000000A', start: 2, end: 99 } });
+ assert.strictEqual(outside.status, 422, outside.text);
+ assert.match(outside.json().detail, /inside the video/i);
+ const cover = await vreq(t.base, '/api/v1/video/studio/publish', { method: 'POST', bearer: token, json: { media: 'vup_01J0000000000000000000000A', start: 1, end: 4, cover: 99 } });
+ assert.strictEqual(cover.status, 422, cover.text);
+ assert.match(cover.json().detail, /cover frame/i);
+ assert.strictEqual(Number(await t.ctx.s.db.value("SELECT count(*) FROM video_cuts WHERE upload_id = 'vup_01J0000000000000000000000A'")), 0);
+ });
+
+ await check('a real trim becomes a copy of the chosen duration, with the chosen cover', async () => {
+ const start = 1;
+ const end = 4;
+ const publish = await vreq(t.base, '/api/v1/video/studio/publish', { method: 'POST', bearer: token, json: {
+ media: 'vup_01J0000000000000000000000A', start, end, cover: 2.5, title: 'Studio trim', visibility: 'unlisted',
+ } });
+ assert.strictEqual(publish.status, 202, publish.text);
+ const cutId = publish.json().cut.id;
+ await t.ctx.videoCuts.sweep();
+ const status = await vreq(t.base, `/api/v1/video/studio/cuts/${cutId}`, { bearer: token });
+ assert.strictEqual(status.status, 200, status.text);
+ const cut = status.json().cut;
+ assert.strictEqual(cut.status, 'done', JSON.stringify(cut));
+ assert.ok(cut.result && /^vup_/.test(cut.result.id));
+ const copy = await t.ctx.videoUploads.byId(cut.result.id);
+ assert.deepStrictEqual([copy.title, copy.visibility, copy.edited_from], ['Studio trim', 'unlisted', 'vup_01J0000000000000000000000A']);
+ const vod = t.media.vods.get(Number(copy.media_vod_id));
+ const out = probe(vod.bytes);
+ assert.ok(out.video && out.audio, 'picture and sound');
+ assert.ok(Math.abs(out.duration - (end - start)) <= 1, `the copy is ${out.duration} s for a ${end - start} s range`);
+ assert.match(copy.thumbnail_url || '', /^https:\/\/openvibe\.media\/t\//, 'the chosen frame is the cover');
+ assert.ok(vod.coverBytes && vod.coverBytes.length > 100 && vod.coverBytes[0] === 0xff && vod.coverBytes[1] === 0xd8, 'Media received a JPEG cover');
+ });
+ } finally {
+ await t.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -52,7 +52,7 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru
@@ -52,7 +52,7 @@ function vreq(base, path, { method = 'GET', cookie, form, json, body, same = tru
(async () => {
(async () => {
const live = await startLive();
const live = await startLive();
- const t = await boot({ env: { MEDIAHUB_VIDEO_URL: 'https://openvibe.video', MEDIAHUB_LIVE_URL: live.url, MEDIAHUB_LIVE_PUBLIC_URL: 'https://openvibe.live', MEDIAHUB_CHAT_URL: live.url, VIDEO_UPLOADS_PER_DAY: '4' } });
+ const t = await boot({ env: { MEDIAHUB_VIDEO_URL: 'https://openvibe.video', MEDIAHUB_LIVE_URL: live.url, MEDIAHUB_LIVE_PUBLIC_URL: 'https://openvibe.live', MEDIAHUB_CHAT_URL: live.url, VIDEO_UPLOADS_PER_DAY: '4', MEDIAHUB_STUDIO_VIDEO_EDIT: 'off' } });
const kim = t.network.addUser('kim');
const kim = t.network.addUser('kim');
const sam = t.network.addUser('sam');
const sam = t.network.addUser('sam');
const kimCookie = t.signIn(kim);
const kimCookie = t.signIn(kim);