openvibers/openvibe.live Commits
A browser chunk opens a new VOD only for a live stream and only when it starts a WebM file; the broadcast page stops or restarts its recorder when told (#340)
Changes
@@@ -2439,13 +2439,15 @@ function startVodRecording(streamId) {ss.vodUploading = false; // upload lockss.vodFinalized = false; // prevent double finalize on actual stream endss.vodSegmentId = (ss.vodSegmentId || 0) + 1;-- ss.vodRecorder = new MediaRecorder(ss.localStream, {++ const recorder = new MediaRecorder(ss.localStream, {mimeType,videoBitsPerSecond: Math.max(600000, Math.round(getTargetVideoBitrate() * 0.85)),audioBitsPerSecond: 128000,});++ ss.vodRecorder = recorder;-- ss.vodRecorder.ondataavailable = (e) => {++ recorder.ondataavailable = (e) => {++ if (ss.vodRecorder !== recorder) return; // a recorder replaced by restartVodRecording: its last slice is droppedif (e.data.size > 0) {ss.vodChunks.push(e.data);uploadVodChunk(streamId); // try to flush immediately@@@ -2458,6 +2460,22 @@ function startVodRecording(streamId) {} catch (err) { console.warn('[VOD] Failed to start:', err); }}++/**++ * Start a fresh MediaRecorder for a stream whose recording the server has to open again (needs-header): only a++ * recorder's first slice carries the WebM header, so the pending slices are dropped and the next upload starts a++ * playable file.++ */++function restartVodRecording(streamId) {++ const ss = getStreamState(streamId);++ if (!ss) return;++ const old = ss.vodRecorder;++ ss.vodRecorder = null;++ ss.vodChunks = [];++ if (old && old.state !== 'inactive') { try { old.stop(); } catch {} }++ console.log('[VOD] Restarting the browser recorder so the next slice starts a new file');++ startVodRecording(streamId);++}++/*** Upload the next pending VOD chunk to the server.* Sequential — only one upload at a time per stream to maintain chunk order.@@@ -2494,6 +2512,17 @@ async function uploadVodChunk(streamId) {ss.vodRecorder = null;console.log('[VOD] Server-side recording active — browser chunk upload stopped');}++ } else if (resp.status === 409) {++ const data = await resp.json().catch(() => ({}));++ if (data.status === 'stream-ended') {++ // The server ended this stream: nothing more to record, and no new VOD should start from it.++ ss.vodChunks = [];++ const old = ss.vodRecorder; ss.vodRecorder = null;++ if (old && old.state !== 'inactive') { try { old.stop(); } catch {} }++ console.log('[VOD] The stream has ended on the server — browser recording stopped');++ } else if (data.status === 'needs-header') {++ restartVodRecording(streamId);++ }} else {console.warn('[VOD] Chunk upload HTTP error:', resp.status);}@@@ -14,6 +14,14 @@ const db = require('../db/database');const chatReads = require('../chat/chat-reads');const media = require('../media-client');const recorder = require('../streaming/recorder');++// The EBML magic every WebM file starts with: a MediaRecorder's first slice has it, the later ones do not.++const WEBM_MAGIC = Buffer.from([0x1a, 0x45, 0xdf, 0xa3]);++/** May this chunk open a new VOD? null when it may, else the 409 answer: the stream ended, or the chunk is not a file start. */++function chunkOpensVod(stream, buffer) {++ if (stream && stream.ended_at) return { error: 'This stream has ended', status: 'stream-ended' };++ if (!Buffer.isBuffer(buffer) || !buffer.subarray(0, 4).equals(WEBM_MAGIC)) return { error: 'A new recording must start with the WebM header', status: 'needs-header' };++ return null;++}const { requireAuth, optionalAuth } = require('../auth/auth');const permissions = require('../auth/permissions');const access = require('./access');@@@ -212,6 +220,13 @@ router.post('/stream/:streamId/chunk', requireAuth, memUpload.single('chunk'), areturn res.json({ vodId: rec.vodId || null, status: 'server-recording', skipped: true });}if (!rec) {++ // A chunk opens a new VOD only for a stream that is still live, and only when it starts a WebM file. After++ // Live ended Maticus's stream (2026-10-23 23:31, ingest_ended) his browser kept sending its 30-second++ // slices: each one with no recording open made a new VOD from a mid-stream slice with no header, which++ // nothing could play (5939, 5940). The browser stops on stream-ended and restarts its recorder (a slice++ // with a header) on needs-header.++ const refused = chunkOpensVod(stream, req.file.buffer);++ if (refused) return res.status(409).json(refused);const { id } = await media.createVod({title: stream.title || 'Stream Recording',stream_id: streamId,@@@ -808,3 +823,4 @@ router.post('/clips/:id/trim', requireAuth, (req, res) => {module.exports = router;module.exports.isGhostVod = isGhostVod;++module.exports.chunkOpensVod = chunkOpensVod;nnew file mode 100644@@@ -0,0 +1,23 @@++'use strict';++// A browser chunk opens a new VOD only for a live stream, and only when it starts a WebM file (server/media-proxy/vods.js++// chunkOpensVod). After Live ended Maticus's stream on 2026-10-23 his browser kept sending its 30-second MediaRecorder++// slices; each one with no recording open made a VOD from a slice with no header, which nothing could play (5939,++// 5940). The answers are what public/js/broadcast.js acts on: stream-ended stops the recorder, needs-header restarts it.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { chunkOpensVod } = require('../server/media-proxy/vods');++++const header = Buffer.concat([Buffer.from([0x1a, 0x45, 0xdf, 0xa3]), Buffer.alloc(100)]);++const middle = Buffer.concat([Buffer.from([0x43, 0xc3, 0x81, 0x17]), Buffer.alloc(100)]); // 5939's first bytes++assert.strictEqual(chunkOpensVod({ id: 1, ended_at: null }, header), null, 'a live stream, a file start: a new VOD');++assert.strictEqual(chunkOpensVod({ id: 1, ended_at: null }, middle).status, 'needs-header');++assert.strictEqual(chunkOpensVod({ id: 1, ended_at: '2026-10-23 23:31:42' }, header).status, 'stream-ended');++assert.strictEqual(chunkOpensVod({ id: 1 }, null).status, 'needs-header');++++// The browser handles both answers (a static file: these names are its contract with the route).++const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'broadcast.js'), 'utf8');++assert.ok(/data\.status === 'stream-ended'/.test(js) && /data\.status === 'needs-header'/.test(js), 'broadcast.js reads both 409 answers');++assert.ok(/function restartVodRecording\(/.test(js) && /ss\.vodRecorder !== recorder/.test(js), "a replaced recorder's last slice is dropped");++console.log('chunk-opens-vod: ok');++process.exit(0);
@@ -2439,13 +2439,15 @@ function startVodRecording(streamId) {
@@ -2439,13 +2439,15 @@ function startVodRecording(streamId) {
ss.vodUploading = false; // upload lock
ss.vodUploading = false; // upload lock
ss.vodFinalized = false; // prevent double finalize on actual stream end
ss.vodFinalized = false; // prevent double finalize on actual stream end
ss.vodSegmentId = (ss.vodSegmentId || 0) + 1;
ss.vodSegmentId = (ss.vodSegmentId || 0) + 1;
- ss.vodRecorder = new MediaRecorder(ss.localStream, {
+ const recorder = new MediaRecorder(ss.localStream, {
mimeType,
mimeType,
videoBitsPerSecond: Math.max(600000, Math.round(getTargetVideoBitrate() * 0.85)),
videoBitsPerSecond: Math.max(600000, Math.round(getTargetVideoBitrate() * 0.85)),
audioBitsPerSecond: 128000,
audioBitsPerSecond: 128000,
});
});
+ ss.vodRecorder = recorder;
- ss.vodRecorder.ondataavailable = (e) => {
+ recorder.ondataavailable = (e) => {
+ if (ss.vodRecorder !== recorder) return; // a recorder replaced by restartVodRecording: its last slice is dropped
if (e.data.size > 0) {
if (e.data.size > 0) {
ss.vodChunks.push(e.data);
ss.vodChunks.push(e.data);
uploadVodChunk(streamId); // try to flush immediately
uploadVodChunk(streamId); // try to flush immediately
@@ -2458,6 +2460,22 @@ function startVodRecording(streamId) {
@@ -2458,6 +2460,22 @@ function startVodRecording(streamId) {
} catch (err) { console.warn('[VOD] Failed to start:', err); }
} catch (err) { console.warn('[VOD] Failed to start:', err); }
}
}
+/**
+ * Start a fresh MediaRecorder for a stream whose recording the server has to open again (needs-header): only a
+ * recorder's first slice carries the WebM header, so the pending slices are dropped and the next upload starts a
+ * playable file.
+ */
+function restartVodRecording(streamId) {
+ const ss = getStreamState(streamId);
+ if (!ss) return;
+ const old = ss.vodRecorder;
+ ss.vodRecorder = null;
+ ss.vodChunks = [];
+ if (old && old.state !== 'inactive') { try { old.stop(); } catch {} }
+ console.log('[VOD] Restarting the browser recorder so the next slice starts a new file');
+ startVodRecording(streamId);
+}
+
/**
/**
* Upload the next pending VOD chunk to the server.
* Upload the next pending VOD chunk to the server.
* Sequential — only one upload at a time per stream to maintain chunk order.
* Sequential — only one upload at a time per stream to maintain chunk order.
@@ -2494,6 +2512,17 @@ async function uploadVodChunk(streamId) {
@@ -2494,6 +2512,17 @@ async function uploadVodChunk(streamId) {
ss.vodRecorder = null;
ss.vodRecorder = null;
console.log('[VOD] Server-side recording active — browser chunk upload stopped');
console.log('[VOD] Server-side recording active — browser chunk upload stopped');
}
}
+ } else if (resp.status === 409) {
+ const data = await resp.json().catch(() => ({}));
+ if (data.status === 'stream-ended') {
+ // The server ended this stream: nothing more to record, and no new VOD should start from it.
+ ss.vodChunks = [];
+ const old = ss.vodRecorder; ss.vodRecorder = null;
+ if (old && old.state !== 'inactive') { try { old.stop(); } catch {} }
+ console.log('[VOD] The stream has ended on the server — browser recording stopped');
+ } else if (data.status === 'needs-header') {
+ restartVodRecording(streamId);
+ }
} else {
} else {
console.warn('[VOD] Chunk upload HTTP error:', resp.status);
console.warn('[VOD] Chunk upload HTTP error:', resp.status);
}
}
@@ -14,6 +14,14 @@ const db = require('../db/database');
@@ -14,6 +14,14 @@ const db = require('../db/database');
const chatReads = require('../chat/chat-reads');
const chatReads = require('../chat/chat-reads');
const media = require('../media-client');
const media = require('../media-client');
const recorder = require('../streaming/recorder');
const recorder = require('../streaming/recorder');
+// The EBML magic every WebM file starts with: a MediaRecorder's first slice has it, the later ones do not.
+const WEBM_MAGIC = Buffer.from([0x1a, 0x45, 0xdf, 0xa3]);
+/** May this chunk open a new VOD? null when it may, else the 409 answer: the stream ended, or the chunk is not a file start. */
+function chunkOpensVod(stream, buffer) {
+ if (stream && stream.ended_at) return { error: 'This stream has ended', status: 'stream-ended' };
+ if (!Buffer.isBuffer(buffer) || !buffer.subarray(0, 4).equals(WEBM_MAGIC)) return { error: 'A new recording must start with the WebM header', status: 'needs-header' };
+ return null;
+}
const { requireAuth, optionalAuth } = require('../auth/auth');
const { requireAuth, optionalAuth } = require('../auth/auth');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
const access = require('./access');
const access = require('./access');
@@ -212,6 +220,13 @@ router.post('/stream/:streamId/chunk', requireAuth, memUpload.single('chunk'), a
@@ -212,6 +220,13 @@ router.post('/stream/:streamId/chunk', requireAuth, memUpload.single('chunk'), a
return res.json({ vodId: rec.vodId || null, status: 'server-recording', skipped: true });
return res.json({ vodId: rec.vodId || null, status: 'server-recording', skipped: true });
}
}
if (!rec) {
if (!rec) {
+ // A chunk opens a new VOD only for a stream that is still live, and only when it starts a WebM file. After
+ // Live ended Maticus's stream (2026-10-23 23:31, ingest_ended) his browser kept sending its 30-second
+ // slices: each one with no recording open made a new VOD from a mid-stream slice with no header, which
+ // nothing could play (5939, 5940). The browser stops on stream-ended and restarts its recorder (a slice
+ // with a header) on needs-header.
+ const refused = chunkOpensVod(stream, req.file.buffer);
+ if (refused) return res.status(409).json(refused);
const { id } = await media.createVod({
const { id } = await media.createVod({
title: stream.title || 'Stream Recording',
title: stream.title || 'Stream Recording',
stream_id: streamId,
stream_id: streamId,
@@ -808,3 +823,4 @@ router.post('/clips/:id/trim', requireAuth, (req, res) => {
@@ -808,3 +823,4 @@ router.post('/clips/:id/trim', requireAuth, (req, res) => {
module.exports = router;
module.exports = router;
module.exports.isGhostVod = isGhostVod;
module.exports.isGhostVod = isGhostVod;
+module.exports.chunkOpensVod = chunkOpensVod;
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+'use strict';
+// A browser chunk opens a new VOD only for a live stream, and only when it starts a WebM file (server/media-proxy/vods.js
+// chunkOpensVod). After Live ended Maticus's stream on 2026-10-23 his browser kept sending its 30-second MediaRecorder
+// slices; each one with no recording open made a VOD from a slice with no header, which nothing could play (5939,
+// 5940). The answers are what public/js/broadcast.js acts on: stream-ended stops the recorder, needs-header restarts it.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { chunkOpensVod } = require('../server/media-proxy/vods');
+
+const header = Buffer.concat([Buffer.from([0x1a, 0x45, 0xdf, 0xa3]), Buffer.alloc(100)]);
+const middle = Buffer.concat([Buffer.from([0x43, 0xc3, 0x81, 0x17]), Buffer.alloc(100)]); // 5939's first bytes
+assert.strictEqual(chunkOpensVod({ id: 1, ended_at: null }, header), null, 'a live stream, a file start: a new VOD');
+assert.strictEqual(chunkOpensVod({ id: 1, ended_at: null }, middle).status, 'needs-header');
+assert.strictEqual(chunkOpensVod({ id: 1, ended_at: '2026-10-23 23:31:42' }, header).status, 'stream-ended');
+assert.strictEqual(chunkOpensVod({ id: 1 }, null).status, 'needs-header');
+
+// The browser handles both answers (a static file: these names are its contract with the route).
+const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'broadcast.js'), 'utf8');
+assert.ok(/data\.status === 'stream-ended'/.test(js) && /data\.status === 'needs-header'/.test(js), 'broadcast.js reads both 409 answers');
+assert.ok(/function restartVodRecording\(/.test(js) && /ss\.vodRecorder !== recorder/.test(js), "a replaced recorder's last slice is dropped");
+console.log('chunk-opens-vod: ok');
+process.exit(0);