openvibers/openvibe.studio Commits

Studio: real video tools on the trim page (#30)

OP OpenVibers committed 3 days ago · 5571ed7a22 · 7 files, +231 −48

* Studio: real video tools on the trim page

The video page now trims by start/end seconds (buttons read the player), shows the kept range, takes a cover frame, and publishes through Video's cuts service. It polls the cut and links the finished copy; a range under one second or outside the video is refused in plain words. Title and visibility go with the trim.

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

* Studio: test the initial trim range

* Studio: say when no cover frame is chosen

Changes

ddiff --git a/README.md b/README.md
iindex ff2cf1f..543003a 100644
---- a/README.md
++++ b/README.md
@@@ -66,9 +66,12 @@ per app keeps the same contract, so a base can be swapped:
hand-off into Studio. Studio asks the source service with the person's own Network token whether that person may
read the object; a private object of someone else answers 404 exactly as a missing id does. A picture opens in
Photo (or `app=light`) with its source named (`From OpenVibe.Cam`); the browser layer fetches the bytes through
--`/api/v1/studio/files/pics/:slug/bytes` and puts them into the running app. A video opens in a plain editor shell
--with its preview at `/api/v1/studio/media/:id/bytes`; when it came from OpenVibe.Video the page offers **Publish on
--Video**, which tells Video's `/api/v1/video/studio/publish` to make a new private copy (the original is unchanged).
++`/api/v1/studio/files/pics/:slug/bytes` and puts them into the running app. A video opens in **Trim and publish**: the preview is at
++`/api/v1/studio/media/:id/bytes`; two seconds fields and **Set start here** / **Set end here** take the time from the
++player, **Use this frame as the cover** records the frame, and **Publish on Video** sends the range, cover, title and
++visibility to Video's `/api/v1/video/studio/publish`. Video's existing cuts service re-encodes that range as a new
++copy (the original is unchanged); the page polls `/api/v1/studio/publish/:cut` until the copy is ready. A range under
++one second or outside the video is refused in plain words.
Set `OV_VIDEO_URL` when Video is not at `https://openvibe.video` (the drill). The Studio routes are documented in
`server/pages.js` (`/edit`) and `server/api.js` (`/media/:id/bytes`, `/publish`).
ddiff --git a/public/js/edit-video.js b/public/js/edit-video.js
iindex 0d531f3..b90bd9a 100644
---- a/public/js/edit-video.js
++++ b/public/js/edit-video.js
@@@ -1,42 +1,148 @@
--/* The Studio side of "Publish on Video": the person's edit goes to OpenVibe.Video, which makes
-- * the copy (the original is unchanged). The frame is plain: status beside the button. */
++/* Studio's video tools: trim a range from the player, take a cover frame, and publish a copy on
++ * OpenVibe.Video through its cuts service. The page is usable without it (plain form fields), but
++ * with it the buttons read the player's current time. */
(function () {
'use strict';
var form = document.querySelector('form[data-studio-publish]');
if (!form) return;
++ var video = document.querySelector('[data-studio-video]');
var status = form.querySelector('[data-role="status"]');
++ var timeEl = form.querySelector('[data-role="time"]');
++ var rangeEl = form.querySelector('[data-role="range"]');
++ var coverEl = form.querySelector('[data-role="cover"]');
var button = form.querySelector('button[type="submit"]');
-- function say(text) { if (status) status.textContent = text; }
++ var start = form.elements.start;
++ var end = form.elements.end;
++ var duration = Number(form.getAttribute('data-duration')) || 0;
++ var cover = null;
++ var polls = 0;
++
++ function say(text) { if (status) status.textContent = text || ''; }
++ function clock(seconds) {
++ var n = Math.max(0, Math.round(Number(seconds) || 0));
++ return Math.floor(n / 60) + ':' + String(n % 60).padStart(2, '0');
++ }
++ function spoken(seconds) {
++ var n = Math.max(0, Math.round(Number(seconds) || 0));
++ if (n < 60) return n + ' s';
++ return Math.floor(n / 60) + ' min' + (n % 60 ? ' ' + (n % 60) + ' s' : '');
++ }
++ function here() { return video && Number.isFinite(video.currentTime) ? video.currentTime : 0; }
++ function useDuration() {
++ if (!video || !Number.isFinite(video.duration) || video.duration <= 0) return;
++ duration = video.duration;
++ if (!end.value) end.value = duration.toFixed(2);
++ update();
++ }
++ function update() {
++ if (timeEl) timeEl.textContent = clock(here());
++ var a = Number(start.value), b = Number(end.value);
++ if (rangeEl && Number.isFinite(a) && Number.isFinite(b) && b > a) {
++ rangeEl.textContent = 'Keeps ' + clock(a) + ' to ' + clock(b) + ' (' + spoken(b - a) + ')';
++ }
++ }
++ function rangeError() {
++ var a = Number(start.value), b = Number(end.value);
++ if (!Number.isFinite(a) || !Number.isFinite(b)) return 'Choose a start and an end in seconds.';
++ if (a < 0) return 'The start cannot be before the video begins.';
++ if (duration > 0 && (a > duration + 0.05 || b > duration + 0.05)) return 'The range has to stay inside the video.';
++ if (b <= a) return 'The end has to be after the start.';
++ if (b - a < 1) return 'Keep at least one second of the video.';
++ return null;
++ }
++ function readPlayer() {
++ if (!video) return;
++ update();
++ }
++ if (video) {
++ video.addEventListener('loadedmetadata', useDuration);
++ video.addEventListener('timeupdate', readPlayer);
++ video.addEventListener('seeked', readPlayer);
++ }
++ start.addEventListener('input', update);
++ end.addEventListener('input', update);
++ var setStart = form.querySelector('[data-set-start]');
++ var setEnd = form.querySelector('[data-set-end]');
++ var setCover = form.querySelector('[data-set-cover]');
++ if (setStart) setStart.addEventListener('click', function () { start.value = here().toFixed(2); update(); });
++ if (setEnd) setEnd.addEventListener('click', function () { end.value = here().toFixed(2); update(); });
++ if (setCover) setCover.addEventListener('click', function () {
++ cover = here();
++ if (coverEl) coverEl.textContent = 'Cover: ' + clock(cover);
++ });
++
++ function poll(id) {
++ fetch('/api/v1/studio/publish/' + encodeURIComponent(id), { credentials: 'same-origin' })
++ .then(function (res) {
++ return res.json().catch(function () { return {}; }).then(function (data) {
++ if (!res.ok) throw new Error(data.detail || data.title || 'The edit could not be read.');
++ var cut = data.cut || {};
++ if (cut.status === 'done') {
++ say('Published. Your copy is ready on OpenVibe.Video.');
++ if (cut.result && cut.result.watch_url && status) {
++ var link = document.createElement('a');
++ link.href = cut.result.watch_url;
++ link.rel = 'noopener';
++ link.textContent = 'Open your copy on Video';
++ status.appendChild(document.createTextNode(' '));
++ status.appendChild(link);
++ }
++ button.disabled = false;
++ return;
++ }
++ if (cut.status === 'failed') {
++ say(cut.error || 'The copy was not made. Try again.');
++ button.disabled = false;
++ return;
++ }
++ polls += 1;
++ if (polls > 300) { say('The copy is taking longer than expected. Open OpenVibe.Video to check it.'); button.disabled = false; return; }
++ var pct = cut.status === 'rendering' ? ' ' + Math.round((Number(cut.progress) || 0) * 100) + '%' : '';
++ say(cut.status === 'rendering' ? 'Making the copy…' + pct : 'Waiting for the cuts service…');
++ window.setTimeout(function () { poll(id); }, 2000);
++ });
++ }).catch(function (err) {
++ say(err && err.message ? err.message : 'The edit could not be read.');
++ button.disabled = false;
++ });
++ }
++
form.addEventListener('submit', function (event) {
event.preventDefault();
if (!button || button.disabled) return;
++ var problem = rangeError();
++ if (problem) { say(problem); return; }
button.disabled = true;
++ polls = 0;
say('Publishing to OpenVibe.Video…');
++ var payload = {
++ media: form.getAttribute('data-media'),
++ start: Number(start.value),
++ end: Number(end.value),
++ title: form.elements.title ? form.elements.title.value : '',
++ visibility: form.elements.visibility ? form.elements.visibility.value : 'private'
++ };
++ if (cover != null) payload.cover = Number(cover.toFixed(2));
fetch('/api/v1/studio/publish', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
-- body: JSON.stringify({
-- media: form.getAttribute('data-media'),
-- title: form.elements.title ? form.elements.title.value : '',
-- visibility: form.elements.visibility ? form.elements.visibility.value : 'private'
-- })
++ body: JSON.stringify(payload)
}).then(function (res) {
return res.json().catch(function () { return {}; }).then(function (data) {
-- if (!res.ok) throw new Error(data.detail || data.title || 'OpenVibe.Video did not take the copy.');
-- say('Published. Your copy is processing on OpenVibe.Video.');
-- if (data.video && data.video.watch_url && status) {
-- var link = document.createElement('a');
-- link.href = data.video.watch_url;
-- link.rel = 'noopener';
-- link.textContent = 'Open your copy on Video';
-- status.appendChild(document.createTextNode(' '));
-- status.appendChild(link);
-- }
++ if (!res.ok) throw new Error(data.detail || data.title || 'OpenVibe.Video did not take the trim.');
++ var cut = data.cut || {};
++ if (!cut.id) throw new Error('OpenVibe.Video did not start the trim.');
++ if (cut.status === 'done' && cut.result) { say('Published.'); button.disabled = false; return; }
++ say('Waiting for the cuts service…');
++ window.setTimeout(function () { poll(cut.id); }, 1000);
});
}).catch(function (err) {
-- say(err && err.message ? err.message : 'The copy was not published.');
++ say(err && err.message ? err.message : 'The trim was not published.');
button.disabled = false;
});
});
++
++ useDuration();
++ update();
})();
ddiff --git a/public/openapi.json b/public/openapi.json
iindex b361dc3..fba895e 100644
---- a/public/openapi.json
++++ b/public/openapi.json
@@@ -103,16 +103,19 @@
},
"/api/v1/studio/publish": {
"post": {
-- "summary": "Publish an edit back on OpenVibe.Video as a new private copy (the original is unchanged).",
++ "summary": "Queue a trim of a video as a new copy on OpenVibe.Video (the original is unchanged).",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
-- "required": ["media"],
++ "required": ["media", "start", "end"],
"properties": {
"media": { "type": "string", "description": "The OpenVibe.Video id (vup_, /watch?v= id or Media VOD id)." },
++ "start": { "type": "number", "minimum": 0, "description": "Seconds into the video." },
++ "end": { "type": "number", "minimum": 0, "description": "Seconds into the video; at least one second after start." },
++ "cover": { "type": "number", "minimum": 0, "description": "Seconds into the video for the cover frame." },
"title": { "type": "string", "maxLength": 120 },
"visibility": { "type": "string", "enum": ["private", "unlisted", "public"] }
}
@@@ -122,17 +125,33 @@
},
"responses": {
"202": {
-- "description": "The copy is being published on Video."
++ "description": "The trim is queued; poll /api/v1/studio/publish/{id}."
},
"401": {
"description": "Sign in required."
},
"404": {
"description": "No such video, or it is not this person's to publish."
++ },
++ "422": {
++ "description": "The range is under one second, outside the video, or otherwise invalid."
}
}
}
},
++ "/api/v1/studio/publish/{id}": {
++ "get": {
++ "summary": "How a queued trim is going, and the copy's address when it is done.",
++ "parameters": [
++ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
++ ],
++ "responses": {
++ "200": { "description": "The cut: queued, measuring, rendering, done with its result, or failed." },
++ "401": { "description": "Sign in required." },
++ "404": { "description": "No such edit, or it is not this person's." }
++ }
++ }
++ },
"/api/v1/studio/save": {
"post": {
"summary": "Save a file to the person's OpenVibe.Download drive.",
ddiff --git a/server/api.js b/server/api.js
iindex bc00bd1..95959b4 100644
---- a/server/api.js
++++ b/server/api.js
@@@ -92,12 +92,24 @@ function createApi({ config, sso, media, pics, video }) {
if (typeof b.media !== 'string' || !/^[A-Za-z0-9_.:-]{1,80}$/.test(b.media)) {
throw new StudioError(422, 'studio.publish.invalid', 'Open a video with /edit?media=<id> first.');
}
++ const start = Number(b.start);
++ const end = Number(b.end);
++ if (!Number.isFinite(start) || !Number.isFinite(end)) throw new StudioError(422, 'studio.publish.invalid', 'Choose a start and an end inside the video.');
++ if (b.cover !== undefined && b.cover !== null && b.cover !== '' && !Number.isFinite(Number(b.cover))) throw new StudioError(422, 'studio.publish.invalid', 'Choose a cover frame inside the video.');
if (b.title !== undefined && typeof b.title !== 'string') throw new StudioError(422, 'studio.publish.invalid', 'The title must be text.');
if (b.visibility !== undefined && !['private', 'unlisted', 'public'].includes(b.visibility)) throw new StudioError(422, 'studio.publish.invalid', 'Choose private, unlisted or public.');
-- const out = await video.publish(value, { media: b.media, title: b.title, visibility: b.visibility });
++ const out = await video.publish(value, { media: b.media, start, end, cover: b.cover, title: b.title, visibility: b.visibility });
res.status(202).json(out);
}));
++ /** Poll the trim Studio queued: queued, measuring, rendering, done (with the copy) or failed. */
++ r.get('/publish/:id', wrap(async (req, res) => {
++ const value = token(req, res);
++ if (!value) return;
++ const out = await video.cut(value, String(req.params.id || ''));
++ res.json(out);
++ }));
++
r.post('/save', express.raw({ type: '*/*', limit: '256mb' }), wrap(async (req, res) => {
const value = token(req, res);
if (!value) return;
ddiff --git a/server/openvibe/video.js b/server/openvibe/video.js
iindex a1611fb..3c7d3c1 100644
---- a/server/openvibe/video.js
++++ b/server/openvibe/video.js
@@@ -13,7 +13,8 @@ const { StudioError } = require('./media');
*
* GET /api/v1/video/studio/media/:id { media, source }
* GET /api/v1/video/studio/media/:id/bytes the bytes (Range reads for the preview)
-- * POST /api/v1/video/studio/publish { media, title, visibility } → { video }
++ * POST /api/v1/video/studio/publish { media, start, end, cover, title, visibility } → { cut }
++ * GET /api/v1/video/studio/cuts/:id how the trim is going → { cut }
*/
function createVideoClient({ baseUrl, fetchImpl = globalThis.fetch }) {
const root = String(baseUrl || '').replace(/\/+$/, '');
@@@ -50,14 +51,16 @@ function createVideoClient({ baseUrl, fetchImpl = globalThis.fetch }) {
}
return { status: res.status, headers: res.headers, body: res.body };
},
++ /** Queue a trim; Video's cuts worker makes the copy and the page polls cut(). */
async publish(token, input) {
-- const data = await request(token, '/studio/publish', {
++ return await request(token, '/studio/publish', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input || {}),
-- timeoutMs: 6 * 3600_000,
});
-- return data;
++ },
++ async cut(token, id) {
++ return await request(token, '/studio/cuts/' + encodeURIComponent(id));
},
};
}
ddiff --git a/server/pages.js b/server/pages.js
iindex 2fb565a..9ca4414 100644
---- a/server/pages.js
++++ b/server/pages.js
@@@ -92,27 +92,51 @@ function createPages({ config, sso, pics, video }) {
'" data-studio-signed-in="' + (viewer.kind === 'user' ? 'true' : 'false') + '"' + openAttrs + '>' + head + files + frame + '</section>' + scripts;
};
-- /** The Studio side of "Edit in Studio": a video from OpenVibe.Video, loaded in a plain editor shell. */
++ const clock = (seconds) => {
++ const n = Math.max(0, Math.round(Number(seconds) || 0));
++ return Math.floor(n / 60) + ':' + String(n % 60).padStart(2, '0');
++ };
++ const spoken = (seconds) => {
++ const n = Math.max(0, Math.round(Number(seconds) || 0));
++ return n >= 60 ? Math.floor(n / 60) + ' min' + (n % 60 ? ' ' + (n % 60) + ' s' : '') : n + ' s';
++ };
++
++ /** Studio's video tools: trim a range, take a cover frame, title it, and publish it back on Video. */
const videoEditor = (media, source) => {
-- const duration = Number(media.duration_seconds) > 0 ? Math.round(Number(media.duration_seconds)) + ' s' : 'a video';
-- const preview = '<div class="app-frame-wrap"><video class="app-frame" controls preload="metadata" playsinline src="/api/v1/studio/media/' +
++ const duration = Number(media.duration_seconds) > 0 ? Number(media.duration_seconds) : 0;
++ const durationText = duration > 0 ? clock(duration) : 'the whole video';
++ const preview = '<div class="app-frame-wrap"><video class="app-frame" data-studio-video controls preload="metadata" playsinline src="/api/v1/studio/media/' +
encodeURIComponent(media.id) + '/bytes"></video></div>';
++ const sourceLine = '<p class="fine-print app-source">From <a href="' + esc(source.url) + '" rel="noopener">' + esc(source.name) +
++ '</a> · ' + esc(media.title) + ' · ' + esc(durationText) + '</p>';
if (!media.can_edit) {
-- return '<section class="page-intro"><h1>Edit in Studio</h1><p class="fine-print app-source">From <a href="' + esc(source.url) +
-- '" rel="noopener">' + esc(source.name) + '</a> · ' + esc(media.title) + '</p></section>' +
++ return '<section class="page-intro"><h1>Trim and publish</h1>' + sourceLine + '</section>' +
'<p class="app-status">You can watch this video here, but only its owner can edit it. <a class="ovk-btn ovk-btn--quiet" href="' +
esc(source.url) + '" rel="noopener">Open on Video</a></p>' + preview;
}
-- const form = media.status === 'ready' ? '<form data-studio-publish data-media="' + esc(media.id) + '">' +
-- '<label>Title <input name="title" type="text" maxlength="120" required value="' + esc(media.title) + '"></label>' +
-- '<label>Who can see the copy <select name="visibility"><option value="private" selected>Only you</option><option value="unlisted">Only people with the link</option><option value="public">Anyone</option></select></label>' +
-- '<p class="fine-print">Publishing makes a new copy on OpenVibe.Video. The original stays as it is; the copy starts processing there.</p>' +
++ if (media.status !== 'ready') {
++ return '<section class="page-intro"><h1>Trim and publish</h1>' + sourceLine + '</section>' +
++ '<p class="app-status">The video is still processing on OpenVibe.Video. Reload this page when it is ready. <a href="' +
++ esc(source.url) + '">Open on Video</a></p>' + preview;
++ }
++ const form = '<form data-studio-publish data-media="' + esc(media.id) + '" data-duration="' + esc(duration) + '">' +
++ '<p class="fine-print">At <span data-role="time">0:00</span> · use the player controls or the fields below.</p>' +
++ '<fieldset class="app-trim"><legend>Keep a range</legend>' +
++ '<label for="edit-start">Start (seconds)</label> <input id="edit-start" name="start" type="number" min="0" step="0.1" value="0"> ' +
++ '<button class="ovk-btn ovk-btn--quiet" type="button" data-set-start>Set start here</button>' +
++ '<label for="edit-end">End (seconds)</label> <input id="edit-end" name="end" type="number" min="0" step="0.1" value="' + esc(duration.toFixed(2)) + '"> ' +
++ '<button class="ovk-btn ovk-btn--quiet" type="button" data-set-end>Set end here</button>' +
++ '<p class="fine-print" data-role="range">Keeps ' + clock(0) + ' to ' + clock(duration) + ' (' + spoken(duration) + ')</p></fieldset>' +
++ '<p><button class="ovk-btn ovk-btn--quiet" type="button" data-set-cover>Use this frame as the cover</button> ' +
++ '<span class="fine-print" data-role="cover">Cover: not chosen yet</span></p>' +
++ '<label for="edit-title">Title</label> <input id="edit-title" name="title" type="text" maxlength="120" required value="' + esc(media.title) + '">' +
++ '<label for="edit-visibility">Who can see the copy</label> <select id="edit-visibility" name="visibility">' +
++ '<option value="private" selected>Only you</option><option value="unlisted">Only people with the link</option><option value="public">Anyone</option></select>' +
++ '<p class="fine-print">Publishing makes a new copy on OpenVibe.Video through its cuts service. The original stays as it is; the copy starts processing there.</p>' +
'<p><button class="ovk-btn ovk-btn--primary" type="submit">Publish on Video</button> ' +
'<a class="ovk-btn ovk-btn--quiet" href="' + esc(source.url) + '" rel="noopener">Open on Video</a></p>' +
-- '<p class="app-status" data-role="status" role="status" aria-live="polite"></p></form>'
-- : '<p class="app-status">The video is still processing on OpenVibe.Video. Reload this page when it is ready. <a href="' + esc(source.url) + '">Open on Video</a></p>';
-- return '<section class="page-intro"><h1>Edit in Studio</h1><p class="fine-print app-source">From <a href="' + esc(source.url) +
-- '" rel="noopener">' + esc(source.name) + '</a> · ' + esc(media.title) + ' · ' + esc(duration) + '</p></section>' +
++ '<p class="app-status" data-role="status" role="status" aria-live="polite"></p></form>';
++ return '<section class="page-intro"><h1>Trim and publish</h1>' + sourceLine + '</section>' +
'<section class="app-shell" data-studio-edit-video data-studio-media="' + esc(media.id) + '">' + preview + form +
'<script src="/js/edit-video.js" defer></script></section>';
};
ddiff --git a/test/studio.test.js b/test/studio.test.js
iindex 435d4ec..73f6ab0 100644
---- a/test/studio.test.js
++++ b/test/studio.test.js
@@@ -69,7 +69,12 @@ const fakeVideo = {
publish: async (token, input) => {
fakeVideo.calls.push(['publish', token, input]);
if (input.media !== 'vup_ok') throw Object.assign(new Error('not found'), { status: 404, code: 'studio.media_not_found', detail: 'no such video' });
-- return { video: { id: 'vup_copy', status: 'processing', url: '/watch?v=copycopycop', watch_url: 'https://openvibe.video/watch?v=copycopycop' } };
++ return { cut: { id: 'vcut_01J0000000000000000000001', status: 'queued', mode: 'trim' } };
++ },
++ cut: async (token, id) => {
++ fakeVideo.calls.push(['cut', token, id]);
++ if (id !== 'vcut_01J0000000000000000000001') throw Object.assign(new Error('not found'), { status: 404, code: 'studio.cut_not_found', detail: 'no such edit' });
++ return { cut: { id, status: 'done', result: { watch_url: 'https://openvibe.video/watch?v=copycopycop' } } };
},
};
@@@ -254,6 +259,13 @@ test('Edit in Studio is a signed-in hand-off: the source decides who may read th
assert.equal(video.status, 200);
const videoHtml = await video.text();
assert.match(videoHtml, /data-studio-edit-video/);
++ assert.match(videoHtml, /<h1>Trim and publish<\/h1>/);
++ assert.match(videoHtml, /Set start here/);
++ assert.match(videoHtml, /Set end here/);
++ assert.match(videoHtml, /Use this frame as the cover/);
++ assert.match(videoHtml, /id="edit-start"/);
++ assert.match(videoHtml, /id="edit-end"/);
++ assert.match(videoHtml, /Keeps 0:00 to 0:12 \(12 s\)/);
assert.match(videoHtml, /From <a href="https:\/\/openvibe\.video\/watch\?v=abcdefghijk"/);
assert.match(videoHtml, /OpenVibe\.Video/);
assert.match(videoHtml, /Publish on Video/);
@@@ -289,18 +301,22 @@ test('the video preview and Publish on Video use the person token, and refuse a
const publish = await fetch(s.base + '/api/v1/studio/publish', {
method: 'POST',
headers: { cookie: 'ov_token=person-a', 'content-type': 'application/json', origin: 'http://studio.test' },
-- body: JSON.stringify({ media: 'vup_ok', title: 'A copy', visibility: 'private' }),
++ body: JSON.stringify({ media: 'vup_ok', start: 1, end: 4, cover: 2.5, title: 'A copy', visibility: 'private' }),
});
const publishText = await publish.text();
assert.equal(publish.status, 202, publishText);
const out = JSON.parse(publishText);
-- assert.equal(out.video.watch_url, 'https://openvibe.video/watch?v=copycopycop');
-- assert.deepEqual(fakeVideo.calls.find((call) => call[0] === 'publish').slice(1), ['person-a', { media: 'vup_ok', title: 'A copy', visibility: 'private' }]);
++ assert.equal(out.cut.id, 'vcut_01J0000000000000000000001');
++ assert.equal(out.cut.status, 'queued');
++ assert.deepEqual(fakeVideo.calls.find((call) => call[0] === 'publish').slice(1), ['person-a', { media: 'vup_ok', start: 1, end: 4, cover: 2.5, title: 'A copy', visibility: 'private' }]);
++ const poll = await fetch(s.base + '/api/v1/studio/publish/vcut_01J0000000000000000000001', { headers: { cookie: 'ov_token=person-a' } });
++ assert.equal(poll.status, 200);
++ assert.equal((await poll.json()).cut.result.watch_url, 'https://openvibe.video/watch?v=copycopycop');
const hidden = await fetch(s.base + '/api/v1/studio/publish', {
method: 'POST',
headers: { cookie: 'ov_token=person-a', 'content-type': 'application/json', origin: 'http://studio.test' },
-- body: JSON.stringify({ media: 'vup_private' }),
++ body: JSON.stringify({ media: 'vup_private', start: 0, end: 2 }),
});
assert.equal(hidden.status, 404);
});