openvibers/openvibe.live Commits
The clip editor's Save works again: POST /api/clips/:id/trim saves the title and has Media trim the clip in place (same id; a trim is cut again from the source and followed like the first cut); it was a 501 stub since the media split, so every save failed (#293)
Changes
@@@ -3218,8 +3218,9 @@ function _buildClipTitleModal(overlay, clipId) {}// After a clip is created, let the user preview it and trim the start/end further.--// Only the final cut is stored (the server re-encodes and replaces the file).--// Falls back to the plain title prompt if the clip can't be loaded for preview.++// Only the final cut is stored (Media cuts the kept part again from the source, same clip id).++// Falls back to the plain title prompt if the clip can't be loaded for preview, or was uploaded++// from the browser already cut (no source recording to trim from).async function openClipTrimEditor(clipId) {let clip;try {@@@ -3228,8 +3229,8 @@ async function openClipTrimEditor(clipId) {const data = await resp.json().catch(() => ({}));clip = data.clip;} catch { /* */ }-- if (!clip || !clip.file_path || !(clip.duration_seconds > 0)) {-- // Can't preview — fall back to the simple title prompt.++ if (!clip || !clip.file_path || !clip.vod_id || !(clip.duration_seconds > 0)) {++ // Can't preview or trim — fall back to the simple title prompt.return promptClipTitle(clipId);}const dur = Number(clip.duration_seconds);@@@ -3336,15 +3337,25 @@ function _buildClipTrimModal(clipId, dur, defaultTitle) {saveBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Saving...';try {const token = localStorage.getItem('token');-- const resp = await fetch(`/api/vods/clips/${clipId}/trim`, {++ const resp = await fetch(`/api/clips/${clipId}/trim`, {method: 'POST',headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },body: JSON.stringify({ start: startT, end: endT, title }),});const data = await resp.json().catch(() => ({}));if (!resp.ok) throw new Error(data.error || 'Failed to save');-- toast(data.unchanged ? 'Clip saved!' : 'Clip trimmed & saved!', 'success');close();++ // A trim is cut again on the server: follow that cut like the first one.++ if (data.job_id && window.OVClipJobs) {++ toast('Saving your trimmed clip…', 'info');++ window.OVClipJobs.follow(clipId, data.job_id, {++ onReady: () => toast('Clip trimmed & saved!', 'success'),++ onFailed: (msg) => toast('The trimmed clip could not be cut: ' + msg, 'error'),++ onRetry: _clipCutHandlers.onRetry,++ });++ } else {++ toast(data.unchanged ? 'Clip saved!' : 'Clip trimmed & saved!', 'success');++ }} catch (err) {toast('Failed to save clip: ' + (err.message || 'error'), 'error');saveBtn.disabled = false;@@@ -271,6 +271,12 @@ async function recutClip(clipId, opts = {}) {return await request('POST', `/clips/${clipId}/recut`, opts);}++/** Narrow a ready clip in place to { start_s, end_s }, seconds into it → 202 { id, status, job_id } (cut again from its++ * source), or the clip (a virtual clip, at once; `unchanged: true` for the whole clip). */++async function trimClip(clipId, { start_s, end_s }, opts = {}) {++ return await request('POST', `/clips/${clipId}/trim`, { body: { start_s, end_s }, ...opts });++}++// ── Files ────────────────────────────────────────────────────────────────────/** POST /files (multipart) → { key, url, size, mime } */@@@ -425,7 +431,7 @@ module.exports = {uploadVodChunk, completeVodChunks, finalizeVod,getVod, listVods, updateVod, deleteVod,// clips-- createClip, getClip, listClips, updateClip, deleteClip, recutClip, getJob,++ createClip, getClip, listClips, updateClip, deleteClip, recutClip, trimClip, getJob,// files + thumbnailsuploadFile, getFileMeta, deleteFile,uploadThumbnail, generateThumbnail,@@@ -387,6 +387,35 @@ router.delete('/:id', requireAuth, async (req, res) => {}});++/**++ * The clip editor's Save (trim & name a clip just made): the title, then, when the kept part is not the whole clip,++ * Media narrows the clip in place and cuts it again from its source (Media's POST /clips/:id/trim): the same id, so++ * its links, comments and chat announcement stay. The same rule as the title: whoever made the clip, the channel's++ * streamer and mods, staff.++ * body { start, end, title? }, seconds into the clip → { id, title, unchanged, status, job_id }; a job_id is the cut++ * the editor follows (GET /api/clips/:id/job).++ */++router.post('/:id/trim', requireAuth, async (req, res) => {++ try {++ if (!/^\d+$/.test(req.params.id)) return clipNotFound(res);++ let clip;++ try { clip = await media.getClip(req.params.id); } catch (err) { return mediaErr(res, err, 'Clip not found'); }++ if (!clip) return clipNotFound(res);++ if (!(await canActorModerateClip(req.user, clip))) return await refuse(req, res, clip, 'Not authorized to edit this clip');++ const start = Number(req.body?.start), end = Number(req.body?.end);++ if (!Number.isFinite(start) || !Number.isFinite(end)) return res.status(400).json({ error: 'start and end are required' });++ const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';++ if (title.length > 200) return res.status(400).json({ error: 'Title must be 1-200 characters' });++ if (title && title !== clip.title) await media.updateClip(clip.id, { title });++ const out = await media.trimClip(clip.id, { start_s: start, end_s: end });++ // A pending chat announcement goes out now with the title (after the cut, when there is one).++ if (title) { try { await require('./clip-notify').bumpClipNotifyNow(clip.id); } catch { /* */ } }++ res.json({ id: clip.id, title: title || clip.title, unchanged: !!out?.unchanged, status: out?.status || 'ready', job_id: out?.job_id || null });++ } catch (err) {++ mediaErr(res, err, 'Failed to save clip');++ }++});++/*** Retry a failed clip cut. Same permission rule as deleting one: whoever may remove a* clip may also ask for it to be re-rendered. Nothing here is destructive — the row and@@@ -800,10 +800,11 @@ router.get('/clips/stream/:streamId', optionalAuth, async (req, res) => {}});--// TODO(contract): clip trimming (re-cut an existing clip in place) has no Media API v1--// endpoint. Cut a fresh clip from the source VOD instead once Media exposes trim.++// The clip editor's Save is POST /api/clips/:id/trim (media-proxy/clips.js). Tabs still running the client that posted++// here are sent there with the method and body kept.router.post('/clips/:id/trim', requireAuth, (req, res) => {-- res.status(501).json({ error: 'Clip trimming is temporarily unavailable while media moves to openvibe.media.' });++ if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'Clip not found' });++ res.redirect(307, `/api/clips/${req.params.id}/trim`);});module.exports = router;@@@ -11,7 +11,7 @@* - streams and slots: edit, end, delete, the slot's settings, its stream key regeneration (the* answer would be the new key), the slot's vibe-coding settings;* - restream destinations (each holds a platform stream key): edit, delete, start, stop;-- * - API tokens, ONVIF cameras and their presets.++ * - API tokens.** Every refusal must leave the object exactly as it was (the row, or no call to Media at all) and* return nothing of it (no key); each area also has a positive control, so a route that simply@@@ -154,8 +154,10 @@ async function check(name, fn) {refused(await call('PUT', '/api/clips/200/visibility', BOB, { visibility: 'private' }), 'clip visibility');refused(await call('DELETE', '/api/clips/200', BOB), 'DELETE clip');refused(await call('POST', '/api/clips/200/recut', BOB), 'recut');-- const trim = await call('POST', '/api/vods/clips/200/trim', BOB, { start_time: 1, end_time: 2 });-- assert.ok([403, 404, 410, 400, 501].includes(trim.status), `trim: ${trim.status} ${trim.text.slice(0, 120)}`);++ refused(await call('POST', '/api/clips/200/trim', BOB, { start: 1, end: 2, title: 'pwned' }), 'trim');++ // The old path only sends tabs on to the route above.++ const old = await call('POST', '/api/vods/clips/200/trim', BOB, { start: 1, end: 2 });++ assert.deepStrictEqual([old.status, old.text.includes('Alice')], [307, false], 'the old trim path redirects');const bulk = await call('POST', '/api/clips/bulk', BOB, { ids: [200], action: 'delete' });assert.deepStrictEqual([bulk.status, bulk.json && bulk.json.done], [200, 0], bulk.text);assert.deepStrictEqual(writesTo('clip', 200), []);@@@ -180,6 +182,10 @@ async function check(name, fn) {assert.strictEqual((await call('PUT', '/api/clips/200/title', ALICE, { title: 'Alice renamed' })).status, 200);assert.ok(writesTo('vod', 100).length >= 1 && writesTo('clip', 200).length >= 1);mediaCalls.length = 0;++ const trim = await call('POST', '/api/clips/200/trim', ALICE, { start: 1, end: 4, title: 'Alice trimmed' });++ assert.strictEqual(trim.status, 200, trim.text);++ assert.deepStrictEqual(mediaCalls.map(([k, ...a]) => [k, ...a.slice(0, 2)]), [['updateClip', 200, { title: 'Alice trimmed' }], ['trimClip', 200, { start_s: 1, end_s: 4 }]]);++ mediaCalls.length = 0;});await check('streams: Bob cannot edit, end or delete Alice\'s stream', async () => {
@@ -3218,8 +3218,9 @@ function _buildClipTitleModal(overlay, clipId) {
@@ -3218,8 +3218,9 @@ function _buildClipTitleModal(overlay, clipId) {
}
}
// After a clip is created, let the user preview it and trim the start/end further.
// After a clip is created, let the user preview it and trim the start/end further.
-// Only the final cut is stored (the server re-encodes and replaces the file).
-// Falls back to the plain title prompt if the clip can't be loaded for preview.
+// Only the final cut is stored (Media cuts the kept part again from the source, same clip id).
+// Falls back to the plain title prompt if the clip can't be loaded for preview, or was uploaded
+// from the browser already cut (no source recording to trim from).
async function openClipTrimEditor(clipId) {
async function openClipTrimEditor(clipId) {
let clip;
let clip;
try {
try {
@@ -3228,8 +3229,8 @@ async function openClipTrimEditor(clipId) {
@@ -3228,8 +3229,8 @@ async function openClipTrimEditor(clipId) {
const data = await resp.json().catch(() => ({}));
const data = await resp.json().catch(() => ({}));
clip = data.clip;
clip = data.clip;
} catch { /* */ }
} catch { /* */ }
- if (!clip || !clip.file_path || !(clip.duration_seconds > 0)) {
- // Can't preview — fall back to the simple title prompt.
+ if (!clip || !clip.file_path || !clip.vod_id || !(clip.duration_seconds > 0)) {
+ // Can't preview or trim — fall back to the simple title prompt.
return promptClipTitle(clipId);
return promptClipTitle(clipId);
}
}
const dur = Number(clip.duration_seconds);
const dur = Number(clip.duration_seconds);
@@ -3336,15 +3337,25 @@ function _buildClipTrimModal(clipId, dur, defaultTitle) {
@@ -3336,15 +3337,25 @@ function _buildClipTrimModal(clipId, dur, defaultTitle) {
saveBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Saving...';
saveBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Saving...';
try {
try {
const token = localStorage.getItem('token');
const token = localStorage.getItem('token');
- const resp = await fetch(`/api/vods/clips/${clipId}/trim`, {
+ const resp = await fetch(`/api/clips/${clipId}/trim`, {
method: 'POST',
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ start: startT, end: endT, title }),
body: JSON.stringify({ start: startT, end: endT, title }),
});
});
const data = await resp.json().catch(() => ({}));
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || 'Failed to save');
if (!resp.ok) throw new Error(data.error || 'Failed to save');
- toast(data.unchanged ? 'Clip saved!' : 'Clip trimmed & saved!', 'success');
close();
close();
+ // A trim is cut again on the server: follow that cut like the first one.
+ if (data.job_id && window.OVClipJobs) {
+ toast('Saving your trimmed clip…', 'info');
+ window.OVClipJobs.follow(clipId, data.job_id, {
+ onReady: () => toast('Clip trimmed & saved!', 'success'),
+ onFailed: (msg) => toast('The trimmed clip could not be cut: ' + msg, 'error'),
+ onRetry: _clipCutHandlers.onRetry,
+ });
+ } else {
+ toast(data.unchanged ? 'Clip saved!' : 'Clip trimmed & saved!', 'success');
+ }
} catch (err) {
} catch (err) {
toast('Failed to save clip: ' + (err.message || 'error'), 'error');
toast('Failed to save clip: ' + (err.message || 'error'), 'error');
saveBtn.disabled = false;
saveBtn.disabled = false;
@@ -271,6 +271,12 @@ async function recutClip(clipId, opts = {}) {
@@ -271,6 +271,12 @@ async function recutClip(clipId, opts = {}) {
return await request('POST', `/clips/${clipId}/recut`, opts);
return await request('POST', `/clips/${clipId}/recut`, opts);
}
}
+/** Narrow a ready clip in place to { start_s, end_s }, seconds into it → 202 { id, status, job_id } (cut again from its
+ * source), or the clip (a virtual clip, at once; `unchanged: true` for the whole clip). */
+async function trimClip(clipId, { start_s, end_s }, opts = {}) {
+ return await request('POST', `/clips/${clipId}/trim`, { body: { start_s, end_s }, ...opts });
+}
+
// ── Files ────────────────────────────────────────────────────────────────────
// ── Files ────────────────────────────────────────────────────────────────────
/** POST /files (multipart) → { key, url, size, mime } */
/** POST /files (multipart) → { key, url, size, mime } */
@@ -425,7 +431,7 @@ module.exports = {
@@ -425,7 +431,7 @@ module.exports = {
uploadVodChunk, completeVodChunks, finalizeVod,
uploadVodChunk, completeVodChunks, finalizeVod,
getVod, listVods, updateVod, deleteVod,
getVod, listVods, updateVod, deleteVod,
// clips
// clips
- createClip, getClip, listClips, updateClip, deleteClip, recutClip, getJob,
+ createClip, getClip, listClips, updateClip, deleteClip, recutClip, trimClip, getJob,
// files + thumbnails
// files + thumbnails
uploadFile, getFileMeta, deleteFile,
uploadFile, getFileMeta, deleteFile,
uploadThumbnail, generateThumbnail,
uploadThumbnail, generateThumbnail,
@@ -387,6 +387,35 @@ router.delete('/:id', requireAuth, async (req, res) => {
@@ -387,6 +387,35 @@ router.delete('/:id', requireAuth, async (req, res) => {
}
}
});
});
+/**
+ * The clip editor's Save (trim & name a clip just made): the title, then, when the kept part is not the whole clip,
+ * Media narrows the clip in place and cuts it again from its source (Media's POST /clips/:id/trim): the same id, so
+ * its links, comments and chat announcement stay. The same rule as the title: whoever made the clip, the channel's
+ * streamer and mods, staff.
+ * body { start, end, title? }, seconds into the clip → { id, title, unchanged, status, job_id }; a job_id is the cut
+ * the editor follows (GET /api/clips/:id/job).
+ */
+router.post('/:id/trim', requireAuth, async (req, res) => {
+ try {
+ if (!/^\d+$/.test(req.params.id)) return clipNotFound(res);
+ let clip;
+ try { clip = await media.getClip(req.params.id); } catch (err) { return mediaErr(res, err, 'Clip not found'); }
+ if (!clip) return clipNotFound(res);
+ if (!(await canActorModerateClip(req.user, clip))) return await refuse(req, res, clip, 'Not authorized to edit this clip');
+ const start = Number(req.body?.start), end = Number(req.body?.end);
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return res.status(400).json({ error: 'start and end are required' });
+ const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
+ if (title.length > 200) return res.status(400).json({ error: 'Title must be 1-200 characters' });
+ if (title && title !== clip.title) await media.updateClip(clip.id, { title });
+ const out = await media.trimClip(clip.id, { start_s: start, end_s: end });
+ // A pending chat announcement goes out now with the title (after the cut, when there is one).
+ if (title) { try { await require('./clip-notify').bumpClipNotifyNow(clip.id); } catch { /* */ } }
+ res.json({ id: clip.id, title: title || clip.title, unchanged: !!out?.unchanged, status: out?.status || 'ready', job_id: out?.job_id || null });
+ } catch (err) {
+ mediaErr(res, err, 'Failed to save clip');
+ }
+});
+
/**
/**
* Retry a failed clip cut. Same permission rule as deleting one: whoever may remove a
* Retry a failed clip cut. Same permission rule as deleting one: whoever may remove a
* clip may also ask for it to be re-rendered. Nothing here is destructive — the row and
* clip may also ask for it to be re-rendered. Nothing here is destructive — the row and
@@ -800,10 +800,11 @@ router.get('/clips/stream/:streamId', optionalAuth, async (req, res) => {
@@ -800,10 +800,11 @@ router.get('/clips/stream/:streamId', optionalAuth, async (req, res) => {
}
}
});
});
-// TODO(contract): clip trimming (re-cut an existing clip in place) has no Media API v1
-// endpoint. Cut a fresh clip from the source VOD instead once Media exposes trim.
+// The clip editor's Save is POST /api/clips/:id/trim (media-proxy/clips.js). Tabs still running the client that posted
+// here are sent there with the method and body kept.
router.post('/clips/:id/trim', requireAuth, (req, res) => {
router.post('/clips/:id/trim', requireAuth, (req, res) => {
- res.status(501).json({ error: 'Clip trimming is temporarily unavailable while media moves to openvibe.media.' });
+ if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'Clip not found' });
+ res.redirect(307, `/api/clips/${req.params.id}/trim`);
});
});
module.exports = router;
module.exports = router;
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
* - streams and slots: edit, end, delete, the slot's settings, its stream key regeneration (the
* - streams and slots: edit, end, delete, the slot's settings, its stream key regeneration (the
* answer would be the new key), the slot's vibe-coding settings;
* answer would be the new key), the slot's vibe-coding settings;
* - restream destinations (each holds a platform stream key): edit, delete, start, stop;
* - restream destinations (each holds a platform stream key): edit, delete, start, stop;
- * - API tokens, ONVIF cameras and their presets.
+ * - API tokens.
*
*
* Every refusal must leave the object exactly as it was (the row, or no call to Media at all) and
* Every refusal must leave the object exactly as it was (the row, or no call to Media at all) and
* return nothing of it (no key); each area also has a positive control, so a route that simply
* return nothing of it (no key); each area also has a positive control, so a route that simply
@@ -154,8 +154,10 @@ async function check(name, fn) {
@@ -154,8 +154,10 @@ async function check(name, fn) {
refused(await call('PUT', '/api/clips/200/visibility', BOB, { visibility: 'private' }), 'clip visibility');
refused(await call('PUT', '/api/clips/200/visibility', BOB, { visibility: 'private' }), 'clip visibility');
refused(await call('DELETE', '/api/clips/200', BOB), 'DELETE clip');
refused(await call('DELETE', '/api/clips/200', BOB), 'DELETE clip');
refused(await call('POST', '/api/clips/200/recut', BOB), 'recut');
refused(await call('POST', '/api/clips/200/recut', BOB), 'recut');
- const trim = await call('POST', '/api/vods/clips/200/trim', BOB, { start_time: 1, end_time: 2 });
- assert.ok([403, 404, 410, 400, 501].includes(trim.status), `trim: ${trim.status} ${trim.text.slice(0, 120)}`);
+ refused(await call('POST', '/api/clips/200/trim', BOB, { start: 1, end: 2, title: 'pwned' }), 'trim');
+ // The old path only sends tabs on to the route above.
+ const old = await call('POST', '/api/vods/clips/200/trim', BOB, { start: 1, end: 2 });
+ assert.deepStrictEqual([old.status, old.text.includes('Alice')], [307, false], 'the old trim path redirects');
const bulk = await call('POST', '/api/clips/bulk', BOB, { ids: [200], action: 'delete' });
const bulk = await call('POST', '/api/clips/bulk', BOB, { ids: [200], action: 'delete' });
assert.deepStrictEqual([bulk.status, bulk.json && bulk.json.done], [200, 0], bulk.text);
assert.deepStrictEqual([bulk.status, bulk.json && bulk.json.done], [200, 0], bulk.text);
assert.deepStrictEqual(writesTo('clip', 200), []);
assert.deepStrictEqual(writesTo('clip', 200), []);
@@ -180,6 +182,10 @@ async function check(name, fn) {
@@ -180,6 +182,10 @@ async function check(name, fn) {
assert.strictEqual((await call('PUT', '/api/clips/200/title', ALICE, { title: 'Alice renamed' })).status, 200);
assert.strictEqual((await call('PUT', '/api/clips/200/title', ALICE, { title: 'Alice renamed' })).status, 200);
assert.ok(writesTo('vod', 100).length >= 1 && writesTo('clip', 200).length >= 1);
assert.ok(writesTo('vod', 100).length >= 1 && writesTo('clip', 200).length >= 1);
mediaCalls.length = 0;
mediaCalls.length = 0;
+ const trim = await call('POST', '/api/clips/200/trim', ALICE, { start: 1, end: 4, title: 'Alice trimmed' });
+ assert.strictEqual(trim.status, 200, trim.text);
+ assert.deepStrictEqual(mediaCalls.map(([k, ...a]) => [k, ...a.slice(0, 2)]), [['updateClip', 200, { title: 'Alice trimmed' }], ['trimClip', 200, { start_s: 1, end_s: 4 }]]);
+ mediaCalls.length = 0;
});
});
await check('streams: Bob cannot edit, end or delete Alice\'s stream', async () => {
await check('streams: Bob cannot edit, end or delete Alice\'s stream', async () => {