openvibers/openvibe.live Commits

Clips: stop telling viewers a failed clip is still being cut

OP OpenVibers committed 18 Aug 2026 · 718fea6273 · 4 files, +56 −8

The player rendered on `!file_path && status !== 'ready'`, which made a FAILED
clip indistinguishable from a processing one: hours-old broken clips still
showed "The server is cutting your clip — this page will update automatically"
and polled every 3s forever. That is what the grid of scissors placeholders was.

Show failed clips as failed, with a Try again button wired to the new
POST /clips/:id/recut, and stop polling once a clip has failed.

Permission for re-cutting matches deleting: whoever may remove a clip may ask
for it to be re-rendered. Nothing is destructive — the row and its timestamps
are untouched, only the media is rebuilt.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Xw6Sf5zzSALw8m93XNu5JE

Changes

ddiff --git a/public/index.html b/public/index.html
iindex 51d6c00a..c8a6c1b2 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -4970,7 +4970,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<!-- Scripts -->
<script src="/js/theme-engine.js?v=4"></script>
<script src="/live-notify.js?v=3" defer></script>
--<script src="/js/app.js?v=124" defer></script>
++<script src="/js/app.js?v=125" defer></script>
<script src="/js/stream-player.js?v=20" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
ddiff --git a/public/js/app.js b/public/js/app.js
iindex b90c26e7..ece7d1a1 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -6788,6 +6788,20 @@ function _startClipCooldownUI(btn) {
}
/* ── Clip Player ──────────────────────────────────────────────── */
++/** Ask the server to re-render a clip whose cut failed, then resume polling. */
++async function recutClip(clipId) {
++ const note = document.getElementById('clp-processing-note');
++ if (note) note.innerHTML = '<i class="fa-solid fa-spinner fa-spin" style="font-size:2rem;color:var(--accent)"></i><p class="muted">Re-cutting…</p>';
++ try {
++ await api(`/clips/${clipId}/recut`, { method: 'POST' });
++ toast('Re-cutting the clip — this page will update when it is ready', 'info');
++ setTimeout(() => { if (window._clpClipId === clipId) loadClipPlayer(clipId); }, 3000);
++ } catch (e) {
++ toast(e.message, 'error');
++ if (window._clpClipId === clipId) loadClipPlayer(clipId);
++ }
++}
++
async function loadClipPlayer(clipId) {
try {
// Clean up chat replay
@@@ -6880,23 +6894,34 @@ async function loadClipPlayer(clipId) {
clearTimeout(window._clpProcessingPoll);
{ const _n = document.getElementById('clp-processing-note'); if (_n) _n.remove(); }
if (!cl.file_path && (cl.status || 'processing') !== 'ready') {
-- // Server is still cutting this clip — show progress instead of a black
-- // player, and poll until it's ready.
++ // A clip with no file is either still being cut or the cut FAILED. These used
++ // to render identically — a failed clip showed "the server is cutting your
++ // clip" and polled forever, which is why hours-old broken clips still claimed
++ // to be in progress. Tell the truth, and offer a retry.
++ const failed = String(cl.status || '') === 'failed';
video.style.display = 'none';
const container = document.getElementById('clp-container');
if (container) {
const note = document.createElement('div');
note.id = 'clp-processing-note';
note.style.cssText = 'display:flex;flex-direction:column;align-items:center;justify-content:center;height:300px;gap:12px;color:var(--text-muted,#999)';
-- note.innerHTML = `
++ note.innerHTML = failed ? `
++ <i class="fa-solid fa-triangle-exclamation" style="font-size:2.5rem;color:var(--danger,#e74c3c)"></i>
++ <p style="font-size:1.05rem;font-weight:600">This clip failed to render</p>
++ <p class="muted" style="font-size:0.85rem">The server couldn't cut it from the recording.</p>
++ <button class="btn btn-sm btn-outline" onclick="recutClip(${cl.id})"><i class="fa-solid fa-rotate-right"></i> Try again</button>` : `
<i class="fa-solid fa-scissors fa-bounce" style="font-size:2.5rem;color:var(--accent)"></i>
<p style="font-size:1.05rem;font-weight:600">Clip is processing…</p>
<p class="muted" style="font-size:0.85rem">The server is cutting your clip — this page will update automatically.</p>`;
container.appendChild(note);
}
-- window._clpProcessingPoll = setTimeout(() => {
-- if (window._clpClipId === cl.id) loadClipPlayer(cl.id);
-- }, 3000);
++ // Only poll while it is genuinely in progress; polling a failed clip forever
++ // was pure noise.
++ if (!failed) {
++ window._clpProcessingPoll = setTimeout(() => {
++ if (window._clpClipId === cl.id) loadClipPlayer(cl.id);
++ }, 3000);
++ }
}
if (cl.file_path) {
const filename = cl.file_path.split('/').pop();
ddiff --git a/server/media-client.js b/server/media-client.js
iindex 1739c38d..b82fd468 100644
---- a/server/media-client.js
++++ b/server/media-client.js
@@@ -191,6 +191,11 @@ function deleteClip(clipId, opts = {}) {
return request('DELETE', `/clips/${clipId}`, opts);
}
++/** Ask Media to re-cut a clip whose cut failed. */
++function recutClip(clipId, opts = {}) {
++ return request('POST', `/clips/${clipId}/recut`, opts);
++}
++
// ── Pastes ───────────────────────────────────────────────────────────────────
/**
@@@ -330,7 +335,7 @@ module.exports = {
uploadVodChunk, completeVodChunks, finalizeVod,
getVod, listVods, updateVod, deleteVod,
// clips
-- createClip, getClip, listClips, updateClip, deleteClip,
++ createClip, getClip, listClips, updateClip, deleteClip, recutClip,
// pastes
createPaste, getPaste, listPastes, deletePaste,
// files + thumbnails
ddiff --git a/server/media-proxy/clips.js b/server/media-proxy/clips.js
iindex 2973c3d7..13b86219 100644
---- a/server/media-proxy/clips.js
++++ b/server/media-proxy/clips.js
@@@ -317,4 +317,22 @@ router.delete('/:id', requireAuth, async (req, res) => {
}
});
++/**
++ * 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
++ * its timestamps are untouched, only the media is rebuilt.
++ */
++router.post('/:id/recut', requireAuth, async (req, res) => {
++ try {
++ let clip;
++ try { clip = await media.getClip(req.params.id); } catch (err) { return mediaErr(res, err, 'Clip not found'); }
++ if (!clip) return res.status(404).json({ error: 'Clip not found' });
++ if (!(await canActorDeleteClip(req.user, clip))) return res.status(403).json({ error: 'Not authorized to re-cut this clip' });
++ const out = await media.recutClip(clip.id);
++ res.json({ message: 'Re-cutting clip', status: out?.status || 'processing' });
++ } catch (err) {
++ mediaErr(res, err, 'Failed to re-cut clip');
++ }
++});
++
module.exports = router;