openvibers/openvibe.mediahub Commits

Video: trim and cover for Studio's editor (#369)

OP OpenVibers committed 3 days ago · cb58921cc7 · 14 files, +317 −99

* Video: show Edit in Studio only when Studio can edit video

MEDIAHUB_STUDIO_VIDEO_EDIT (default off) gates the watch-page action, the Your videos link and edit_url in the record completion response. The permission-checked hand-off API stays available. Tests cover both the default and the flag.

* Video: trim and cover for Studio's editor

Adds the trim mode to the existing cuts worker: an exact start/end range, a cover frame taken through Media's custom-thumbnail route, and the title/visibility for the copy. The hand-off API queues the cut and reports its progress, so Studio can poll. Tested with a real 6 s MP4 and ffprobe (a 1-4 s range makes a 3 s copy, cover JPEG stored).

* Merge remote-tracking branch 'origin/main' into video-studio-video-edit

# Conflicts:
#	README.md

* Video: turn the Studio editor switch on now the tools exist

MEDIAHUB_STUDIO_VIDEO_EDIT now defaults on; off still hides the action. The hand-off test keeps the off case with an explicit switch, and the upload test runs with it off.

* Video: label a Studio trim and check its cover range

The cut list calls a Studio trim 'Trimmed', and the end-to-end test refuses a cover second outside the video.

* Video: tidy the cover helper

* Video: fail a trim whose copy never becomes ready

Changes

ddiff --git a/.env.example b/.env.example
iindex 3ff288f..2455489 100644
---- a/.env.example
++++ b/.env.example
@@@ -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.media
MEDIAHUB_MEDIA_APP=media-hub
#MEDIAHUB_MEDIA_TIMEOUT_MS=30000
@@@ -63,8 +65,8 @@ MEDIAHUB_MEDIA_APP=media-hub
MEDIAHUB_VIDEO_URL=https://openvibe.video
# Where "Edit in Studio" goes for a finished upload (the hand-off route).
MEDIAHUB_STUDIO_URL=https://openvibe.studio
--# 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
ddiff --git a/README.md b/README.md
iindex df6cc44..b69dac8 100644
---- a/README.md
++++ b/README.md
@@@ -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.
- The returned `File` goes through the ordinary multipart upload form, with progress and retries. A recording is
private by default. The older `/api/v1/videos/recordings` chunk API remains available to its existing callers.
--- **Edit in Studio** (`server/video/studio.js`): 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
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
(owner, staff, or a ready public/unlisted item); a private item of someone else answers 404 exactly like a
missing id. Only the owner may publish, and a cookie from this site cannot call these routes.
-- - Publishing makes a **new private copy** through the upload path (title and visibility from Studio), so Media
-- probes it and gives it its own AI captions and chapters; the original is unchanged and the copy names it in
-- `video_uploads.edited_from`. `MEDIAHUB_STUDIO_URL` points the links at a drill or another Studio.
++ - 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 video
through Media's signed URL (private ones too), runs locally (whisper.cpp, the local model) and never uses a paid
ddiff --git a/migrations/0029_video_trim.sql b/migrations/0029_video_trim.sql
nnew file mode 100644
iindex 0000000..1eb5963
---- /dev/null
++++ b/migrations/0029_video_trim.sql
@@@ -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;
ddiff --git a/server/app.js b/server/app.js
iindex 085cc12..f6b7738 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 });
ddiff --git a/server/config.js b/server/config.js
iindex dd22c3d..64dd1ba 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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),
ddiff --git a/server/media/client.js b/server/media/client.js
iindex 268daed..659c469 100644
---- a/server/media/client.js
++++ b/server/media/client.js
@@@ -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 = console
const 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 = console
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 }),
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 }) {
ddiff --git a/server/video/api.js b/server/video/api.js
iindex c756221..95c3192 100644
---- a/server/video/api.js
++++ b/server/video/api.js
@@@ -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;
ddiff --git a/server/video/cut.js b/server/video/cut.js
iindex 9ab0dac..e6b16e1 100644
---- a/server/video/cut.js
++++ b/server/video/cut.js
@@@ -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;
ddiff --git a/server/video/studio.js b/server/video/studio.js
iindex 84f1b30..5c99579 100644
---- a/server/video/studio.js
++++ b/server/video/studio.js
@@@ -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 = gl
wire: (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). */
ddiff --git a/server/video/upload-routes.js b/server/video/upload-routes.js
iindex 2303bfc..909380e 100644
---- a/server/video/upload-routes.js
++++ b/server/video/upload-routes.js
@@@ -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') {
ddiff --git a/test/helpers/media.js b/test/helpers/media.js
iindex 2a791aa..0d85e9e 100644
---- a/test/helpers/media.js
++++ b/test/helpers/media.js
@@@ -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') {
ddiff --git a/test/video-studio.test.js b/test/video-studio.test.js
iindex 738bee9..0edb966 100644
---- a/test/video-studio.test.js
++++ b/test/video-studio.test.js
@@@ -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(); }
ddiff --git a/test/video-trim.test.js b/test/video-trim.test.js
nnew file mode 100644
iindex 0000000..8d3aed7
---- /dev/null
++++ b/test/video-trim.test.js
@@@ -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); });
ddiff --git a/test/video-uploads.test.js b/test/video-uploads.test.js
iindex 88992da..dfd46df 100644
---- a/test/video-uploads.test.js
++++ b/test/video-uploads.test.js
@@@ -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);