openvibers/openvibe.studio Commits
Studio: real video tools on the trim page (#30)
Changes
@@@ -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 mayread the object; a private object of someone else answers 404 exactly as a missing id does. A picture opens inPhoto (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`).@@@ -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();})();@@@ -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.",@@@ -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;@@@ -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));},};}@@@ -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>';};@@@ -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 thassert.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 aconst 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);});
@@ -66,9 +66,12 @@ per app keeps the same contract, so a base can be swapped:
@@ -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
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
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
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
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`).
`server/pages.js` (`/edit`) and `server/api.js` (`/media/:id/bytes`, `/publish`).
@@ -1,42 +1,148 @@
@@ -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 () {
(function () {
'use strict';
'use strict';
var form = document.querySelector('form[data-studio-publish]');
var form = document.querySelector('form[data-studio-publish]');
if (!form) return;
if (!form) return;
+ var video = document.querySelector('[data-studio-video]');
var status = form.querySelector('[data-role="status"]');
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"]');
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) {
form.addEventListener('submit', function (event) {
event.preventDefault();
event.preventDefault();
if (!button || button.disabled) return;
if (!button || button.disabled) return;
+ var problem = rangeError();
+ if (problem) { say(problem); return; }
button.disabled = true;
button.disabled = true;
+ polls = 0;
say('Publishing to OpenVibe.Video…');
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', {
fetch('/api/v1/studio/publish', {
method: 'POST',
method: 'POST',
credentials: 'same-origin',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
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) {
}).then(function (res) {
return res.json().catch(function () { return {}; }).then(function (data) {
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) {
}).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;
button.disabled = false;
});
});
});
});
+
+ useDuration();
+ update();
})();
})();
@@ -103,16 +103,19 @@
@@ -103,16 +103,19 @@
},
},
"/api/v1/studio/publish": {
"/api/v1/studio/publish": {
"post": {
"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": {
"requestBody": {
"required": true,
"required": true,
"content": {
"content": {
"application/json": {
"application/json": {
"schema": {
"schema": {
"type": "object",
"type": "object",
- "required": ["media"],
+ "required": ["media", "start", "end"],
"properties": {
"properties": {
"media": { "type": "string", "description": "The OpenVibe.Video id (vup_, /watch?v= id or Media VOD id)." },
"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 },
"title": { "type": "string", "maxLength": 120 },
"visibility": { "type": "string", "enum": ["private", "unlisted", "public"] }
"visibility": { "type": "string", "enum": ["private", "unlisted", "public"] }
}
}
@@ -122,17 +125,33 @@
@@ -122,17 +125,33 @@
},
},
"responses": {
"responses": {
"202": {
"202": {
- "description": "The copy is being published on Video."
+ "description": "The trim is queued; poll /api/v1/studio/publish/{id}."
},
},
"401": {
"401": {
"description": "Sign in required."
"description": "Sign in required."
},
},
"404": {
"404": {
"description": "No such video, or it is not this person's to publish."
"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": {
"/api/v1/studio/save": {
"post": {
"post": {
"summary": "Save a file to the person's OpenVibe.Download drive.",
"summary": "Save a file to the person's OpenVibe.Download drive.",
@@ -92,12 +92,24 @@ function createApi({ config, sso, media, pics, video }) {
@@ -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)) {
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.');
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.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.');
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);
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) => {
r.post('/save', express.raw({ type: '*/*', limit: '256mb' }), wrap(async (req, res) => {
const value = token(req, res);
const value = token(req, res);
if (!value) return;
if (!value) return;
@@ -13,7 +13,8 @@ const { StudioError } = require('./media');
@@ -13,7 +13,8 @@ const { StudioError } = require('./media');
*
*
* GET /api/v1/video/studio/media/:id { media, source }
* GET /api/v1/video/studio/media/:id { media, source }
* GET /api/v1/video/studio/media/:id/bytes the bytes (Range reads for the preview)
* 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 }) {
function createVideoClient({ baseUrl, fetchImpl = globalThis.fetch }) {
const root = String(baseUrl || '').replace(/\/+$/, '');
const root = String(baseUrl || '').replace(/\/+$/, '');
@@ -50,14 +51,16 @@ function createVideoClient({ baseUrl, fetchImpl = globalThis.fetch }) {
@@ -50,14 +51,16 @@ function createVideoClient({ baseUrl, fetchImpl = globalThis.fetch }) {
}
}
return { status: res.status, headers: res.headers, body: res.body };
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) {
async publish(token, input) {
- const data = await request(token, '/studio/publish', {
+ return await request(token, '/studio/publish', {
method: 'POST',
method: 'POST',
headers: { 'content-type': 'application/json' },
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input || {}),
body: JSON.stringify(input || {}),
- timeoutMs: 6 * 3600_000,
});
});
- return data;
+ },
+ async cut(token, id) {
+ return await request(token, '/studio/cuts/' + encodeURIComponent(id));
},
},
};
};
}
}
@@ -92,27 +92,51 @@ function createPages({ config, sso, pics, video }) {
@@ -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;
'" 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 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>';
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) {
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="' +
'<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;
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> ' +
'<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>' +
'<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 +
'<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>';
'<script src="/js/edit-video.js" defer></script></section>';
};
};
@@ -69,7 +69,12 @@ const fakeVideo = {
@@ -69,7 +69,12 @@ const fakeVideo = {
publish: async (token, input) => {
publish: async (token, input) => {
fakeVideo.calls.push(['publish', 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' });
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
@@ -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);
assert.equal(video.status, 200);
const videoHtml = await video.text();
const videoHtml = await video.text();
assert.match(videoHtml, /data-studio-edit-video/);
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, /From <a href="https:\/\/openvibe\.video\/watch\?v=abcdefghijk"/);
assert.match(videoHtml, /OpenVibe\.Video/);
assert.match(videoHtml, /OpenVibe\.Video/);
assert.match(videoHtml, /Publish on 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
@@ -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', {
const publish = await fetch(s.base + '/api/v1/studio/publish', {
method: 'POST',
method: 'POST',
headers: { cookie: 'ov_token=person-a', 'content-type': 'application/json', origin: 'http://studio.test' },
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();
const publishText = await publish.text();
assert.equal(publish.status, 202, publishText);
assert.equal(publish.status, 202, publishText);
const out = JSON.parse(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', {
const hidden = await fetch(s.base + '/api/v1/studio/publish', {
method: 'POST',
method: 'POST',
headers: { cookie: 'ov_token=person-a', 'content-type': 'application/json', origin: 'http://studio.test' },
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);
assert.equal(hidden.status, 404);
});
});