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)

OP OpenVibers committed 22 days ago · 6d90a139bd · 5 files, +66 −13

Changes

ddiff --git a/public/js/stream-player.js b/public/js/stream-player.js
iindex 438fb1b6..c26ecfa8 100644
---- a/public/js/stream-player.js
++++ b/public/js/stream-player.js
@@@ -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;
ddiff --git a/server/media-client.js b/server/media-client.js
iindex 3f0ef3be..c190535e 100644
---- a/server/media-client.js
++++ b/server/media-client.js
@@@ -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 + thumbnails
uploadFile, getFileMeta, deleteFile,
uploadThumbnail, generateThumbnail,
ddiff --git a/server/media-proxy/clips.js b/server/media-proxy/clips.js
iindex 40152ca9..3865348e 100644
---- a/server/media-proxy/clips.js
++++ b/server/media-proxy/clips.js
@@@ -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
ddiff --git a/server/media-proxy/vods.js b/server/media-proxy/vods.js
iindex 261c3c4e..c411bf51 100644
---- a/server/media-proxy/vods.js
++++ b/server/media-proxy/vods.js
@@@ -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;
ddiff --git a/test/security-idor.test.js b/test/security-idor.test.js
iindex 6efa3262..7d3dc92d 100644
---- a/test/security-idor.test.js
++++ b/test/security-idor.test.js
@@@ -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 () => {