openvibers/openvibe.live Commits
A VOD shows its live captions until the full transcript is in; the rewind carries on into the next part (#380)
Changes
@@@ -15,6 +15,11 @@ unavailable analyses retry with backoff; Live does not run its VOD/clip decoderLive stream captions and sound events continue using their real-time paths. The`live_media_analysis_retry_total` metric counts deferred analyses by media kind.++Until a VOD's full transcript is in, `GET /api/vods/:id` serves the captions heard while it was live (the timeline rows++stamped with that VOD, moved into its own time) with `transcript_source: "live"`; the full pass replaces them. The full++pass runs after the stream and waits while the machine is busy, so a part that ended minutes ago used to show no++transcript at all (VOD 6026, 2026-10-26).++> Written before the media split. Storage, cutting, thumbnails and files now live in OpenVibe.Media;> Live proxies to it (`server/media-proxy/`) and starts/stops recordings (`server/streaming/recorder.js`).> Pastes live in OpenVibe.Community. Live's old local `vods`/`clips`/`pastes` tables are frozen: nothing@@@ -2595,11 +2595,17 @@ function switchToDVR(targetTime, part = null) {console.log(`[DVR] Switched to ${part ? `part from ${Math.round(part.at)}s` : 'the recording'} at ${Math.round(targetTime)}s`);}--/** An earlier part played to its end: carry on into the next part (or the recording one) without a gap of choice. */++/**++ * An earlier part played to its end: carry on at the start of the next part (or of the recording one). Its end is not++ * the place: the reconnect's gap lies between, and a seek there lands back in this part.++ */function _dvrPartEnded() {const p = dvrState.playingPart;if (!p || dvrState.isLive) return;-- _dvrSeekBar(p.end - _dvrSpan().start + 0.01);++ const next = dvrState.earlier.find((x) => x.at > p.at);++ const at = next ? next.at : dvrState.activeAt;++ if (at == null) return;++ _dvrSeekBar(at - _dvrSpan().start + 0.01);}/** The recording's HTTP file (Media serves a seekable copy of it while it records). */@@@ -552,6 +552,16 @@ router.get('/:id', optionalAuth, async (req, res) => {}vod.transcript_status = st.transcript_status || vod.transcript_status;}++ // Until the full pass is in (it runs after the stream, and waits while the box is busy: VOD 6026 had none++ // for half an hour, 2026-10-26), the captions heard while it was live stand in, in the VOD's own time.++ if (!st || !st.ai_transcript_json) {++ const live = await liveTranscript(vod.id);++ if (live.length) {++ vod.ai_transcript_json = JSON.stringify(live);++ vod.ai_transcript = live.map((x) => x.text).join(' ');++ vod.transcript_source = 'live';++ }++ }} catch { /* */ }const clips = co?.clips || (Array.isArray(co) ? co : []);@@@ -868,6 +878,22 @@ async function clipPlace(stream, startTime, endTime) {return { vodId: part.vod_id, part: part.part, start: Math.round(start * 10) / 10, end: Math.round(end * 10) / 10 };}++/**++ * The speech heard while a VOD's stream was live (the timeline rows stamped with this VOD), as transcript segments in++ * the VOD's own time ([{ start, end, text }]); [] when there are none.++ */++async function liveTranscript(vodId) {++ const rows = (await db.getTimelineByVod(vodId)).filter((r) => r.kind === 'speech' && String(r.text || '').trim());++ if (!rows.length) return [];++ const history = require('../streaming/stream-history');++ const offset = (await history.vodOffsets([vodId])).get(Number(vodId)) || 0;++ return rows.map((r) => ({++ start: history.vodTime(r.start_sec, offset),++ end: r.end_sec == null ? null : history.vodTime(r.end_sec, offset),++ text: String(r.text).trim(),++ }));++}++async function _notifyStreamerOfClip(streamId, clipper, title, clipId) {try {if (!streamId) return;@@@ -34,7 +34,7 @@ const calls = [];const video = { currentTime: 0 };const ctx = { Math, Number, calls, video, updateDVRUI: () => {}, switchToDVR: (t, part = null) => calls.push([Math.round(t * 10) / 10, part ? part.vodId : 'rec']) };vm.createContext(ctx);--vm.runInContext(['_dvrSpan', '_dvrBarTime', '_dvrSeekBar', '_dvrNudge'].map(extract).join('\n'), ctx);++vm.runInContext(['_dvrSpan', '_dvrBarTime', '_dvrSeekBar', '_dvrNudge', '_dvrPartEnded'].map(extract).join('\n'), ctx);// Stream 2795's shape: part 1 (VOD 6025) from 28 s to 706 s, part 2 recording since 709 s and 900 s long so far.const part1 = { vodId: 6025, at: 28, end: 706, src: 'https://media.test/o/med_1/master.m3u8', hls: true };@@@ -67,6 +67,13 @@ video.currentTime = 2;vm.runInContext('_dvrNudge(video, -5)', ctx); // bar 683 - 5 = 678 → stream 706: part 1 at 677.5 (half a second before its end)assert.deepStrictEqual(calls.pop(), [677.5, 6025]);++// Part 1 played to its end: on at the start of part 2 (the recording), not back to part 1's end across the gap.++vm.runInContext('dvrState.isLive = false; dvrState.playingPart = dvrState.earlier[0]; _dvrPartEnded();', ctx);++assert.deepStrictEqual(calls.pop(), [0, 'rec']);++// With a part between, the next earlier part.++vm.runInContext(`dvrState.earlier.push(${JSON.stringify({ vodId: 6027, at: 710, end: 800, src: 'https://media.test/v/6027', hls: false })}); dvrState.activeAt = 805; dvrState.playingPart = dvrState.earlier[0]; _dvrPartEnded();`, ctx);++assert.deepStrictEqual(calls.pop(), [0, 6027]);++// No history: the bar is the recording part alone, as before.set({ duration: 300, seekable: true, hlsUrl: 'https://media.test/v/7/dvr.m3u8', vodFile: null, earlier: [], activeAt: null, playingPart: null });assert.deepStrictEqual({ ...vm.runInContext('_dvrSpan()', ctx) }, { start: 0, total: 300 });@@@ -202,6 +202,20 @@ const wait = (ms) => new Promise((r) => setTimeout(r, ms));assert.deepStrictEqual(t.json.parts.map((p) => [p.vod_id, p.at]), [[6025, 28], [6026, 709]]);});++ await check('a VOD without its full transcript yet shows the live captions, in its own time', async () => {++ media.listClips = async () => ({ clips: [] });++ const r = await call('GET', '/api/vods/6026');++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json.vod.transcript_source, 'live');++ assert.deepStrictEqual(JSON.parse(r.json.vod.ai_transcript_json), [{ start: 11, end: 14.5, text: 'test successful' }]);++ assert.strictEqual(r.json.vod.ai_transcript, 'test successful');++ await raw.prepare('INSERT INTO vod_ai_state (vod_id, ai_transcript_json, transcript_status) VALUES (?, ?, ?)')++ .run(6026, JSON.stringify([{ start: 11, end: 14.5, text: 'Test successful.' }]), 'done');++ const done = await call('GET', '/api/vods/6026');++ assert.strictEqual(done.json.vod.transcript_source, undefined, 'the full pass wins once it is in');++ assert.strictEqual(done.json.vod.ai_transcript, 'Test successful.');++ });++await check('speech capture offsets are stream time across a restart', async () => {const fs = require('fs');const path = require('path');
@@ -15,6 +15,11 @@ unavailable analyses retry with backoff; Live does not run its VOD/clip decoder
@@ -15,6 +15,11 @@ unavailable analyses retry with backoff; Live does not run its VOD/clip decoder
Live stream captions and sound events continue using their real-time paths. The
Live stream captions and sound events continue using their real-time paths. The
`live_media_analysis_retry_total` metric counts deferred analyses by media kind.
`live_media_analysis_retry_total` metric counts deferred analyses by media kind.
+Until a VOD's full transcript is in, `GET /api/vods/:id` serves the captions heard while it was live (the timeline rows
+stamped with that VOD, moved into its own time) with `transcript_source: "live"`; the full pass replaces them. The full
+pass runs after the stream and waits while the machine is busy, so a part that ended minutes ago used to show no
+transcript at all (VOD 6026, 2026-10-26).
+
> Written before the media split. Storage, cutting, thumbnails and files now live in OpenVibe.Media;
> Written before the media split. Storage, cutting, thumbnails and files now live in OpenVibe.Media;
> Live proxies to it (`server/media-proxy/`) and starts/stops recordings (`server/streaming/recorder.js`).
> Live proxies to it (`server/media-proxy/`) and starts/stops recordings (`server/streaming/recorder.js`).
> Pastes live in OpenVibe.Community. Live's old local `vods`/`clips`/`pastes` tables are frozen: nothing
> Pastes live in OpenVibe.Community. Live's old local `vods`/`clips`/`pastes` tables are frozen: nothing
@@ -2595,11 +2595,17 @@ function switchToDVR(targetTime, part = null) {
@@ -2595,11 +2595,17 @@ function switchToDVR(targetTime, part = null) {
console.log(`[DVR] Switched to ${part ? `part from ${Math.round(part.at)}s` : 'the recording'} at ${Math.round(targetTime)}s`);
console.log(`[DVR] Switched to ${part ? `part from ${Math.round(part.at)}s` : 'the recording'} at ${Math.round(targetTime)}s`);
}
}
-/** An earlier part played to its end: carry on into the next part (or the recording one) without a gap of choice. */
+/**
+ * An earlier part played to its end: carry on at the start of the next part (or of the recording one). Its end is not
+ * the place: the reconnect's gap lies between, and a seek there lands back in this part.
+ */
function _dvrPartEnded() {
function _dvrPartEnded() {
const p = dvrState.playingPart;
const p = dvrState.playingPart;
if (!p || dvrState.isLive) return;
if (!p || dvrState.isLive) return;
- _dvrSeekBar(p.end - _dvrSpan().start + 0.01);
+ const next = dvrState.earlier.find((x) => x.at > p.at);
+ const at = next ? next.at : dvrState.activeAt;
+ if (at == null) return;
+ _dvrSeekBar(at - _dvrSpan().start + 0.01);
}
}
/** The recording's HTTP file (Media serves a seekable copy of it while it records). */
/** The recording's HTTP file (Media serves a seekable copy of it while it records). */
@@ -552,6 +552,16 @@ router.get('/:id', optionalAuth, async (req, res) => {
@@ -552,6 +552,16 @@ router.get('/:id', optionalAuth, async (req, res) => {
}
}
vod.transcript_status = st.transcript_status || vod.transcript_status;
vod.transcript_status = st.transcript_status || vod.transcript_status;
}
}
+ // Until the full pass is in (it runs after the stream, and waits while the box is busy: VOD 6026 had none
+ // for half an hour, 2026-10-26), the captions heard while it was live stand in, in the VOD's own time.
+ if (!st || !st.ai_transcript_json) {
+ const live = await liveTranscript(vod.id);
+ if (live.length) {
+ vod.ai_transcript_json = JSON.stringify(live);
+ vod.ai_transcript = live.map((x) => x.text).join(' ');
+ vod.transcript_source = 'live';
+ }
+ }
} catch { /* */ }
} catch { /* */ }
const clips = co?.clips || (Array.isArray(co) ? co : []);
const clips = co?.clips || (Array.isArray(co) ? co : []);
@@ -868,6 +878,22 @@ async function clipPlace(stream, startTime, endTime) {
@@ -868,6 +878,22 @@ async function clipPlace(stream, startTime, endTime) {
return { vodId: part.vod_id, part: part.part, start: Math.round(start * 10) / 10, end: Math.round(end * 10) / 10 };
return { vodId: part.vod_id, part: part.part, start: Math.round(start * 10) / 10, end: Math.round(end * 10) / 10 };
}
}
+/**
+ * The speech heard while a VOD's stream was live (the timeline rows stamped with this VOD), as transcript segments in
+ * the VOD's own time ([{ start, end, text }]); [] when there are none.
+ */
+async function liveTranscript(vodId) {
+ const rows = (await db.getTimelineByVod(vodId)).filter((r) => r.kind === 'speech' && String(r.text || '').trim());
+ if (!rows.length) return [];
+ const history = require('../streaming/stream-history');
+ const offset = (await history.vodOffsets([vodId])).get(Number(vodId)) || 0;
+ return rows.map((r) => ({
+ start: history.vodTime(r.start_sec, offset),
+ end: r.end_sec == null ? null : history.vodTime(r.end_sec, offset),
+ text: String(r.text).trim(),
+ }));
+}
+
async function _notifyStreamerOfClip(streamId, clipper, title, clipId) {
async function _notifyStreamerOfClip(streamId, clipper, title, clipId) {
try {
try {
if (!streamId) return;
if (!streamId) return;
@@ -34,7 +34,7 @@ const calls = [];
@@ -34,7 +34,7 @@ const calls = [];
const video = { currentTime: 0 };
const video = { currentTime: 0 };
const ctx = { Math, Number, calls, video, updateDVRUI: () => {}, switchToDVR: (t, part = null) => calls.push([Math.round(t * 10) / 10, part ? part.vodId : 'rec']) };
const ctx = { Math, Number, calls, video, updateDVRUI: () => {}, switchToDVR: (t, part = null) => calls.push([Math.round(t * 10) / 10, part ? part.vodId : 'rec']) };
vm.createContext(ctx);
vm.createContext(ctx);
-vm.runInContext(['_dvrSpan', '_dvrBarTime', '_dvrSeekBar', '_dvrNudge'].map(extract).join('\n'), ctx);
+vm.runInContext(['_dvrSpan', '_dvrBarTime', '_dvrSeekBar', '_dvrNudge', '_dvrPartEnded'].map(extract).join('\n'), ctx);
// Stream 2795's shape: part 1 (VOD 6025) from 28 s to 706 s, part 2 recording since 709 s and 900 s long so far.
// Stream 2795's shape: part 1 (VOD 6025) from 28 s to 706 s, part 2 recording since 709 s and 900 s long so far.
const part1 = { vodId: 6025, at: 28, end: 706, src: 'https://media.test/o/med_1/master.m3u8', hls: true };
const part1 = { vodId: 6025, at: 28, end: 706, src: 'https://media.test/o/med_1/master.m3u8', hls: true };
@@ -67,6 +67,13 @@ video.currentTime = 2;
@@ -67,6 +67,13 @@ video.currentTime = 2;
vm.runInContext('_dvrNudge(video, -5)', ctx); // bar 683 - 5 = 678 → stream 706: part 1 at 677.5 (half a second before its end)
vm.runInContext('_dvrNudge(video, -5)', ctx); // bar 683 - 5 = 678 → stream 706: part 1 at 677.5 (half a second before its end)
assert.deepStrictEqual(calls.pop(), [677.5, 6025]);
assert.deepStrictEqual(calls.pop(), [677.5, 6025]);
+// Part 1 played to its end: on at the start of part 2 (the recording), not back to part 1's end across the gap.
+vm.runInContext('dvrState.isLive = false; dvrState.playingPart = dvrState.earlier[0]; _dvrPartEnded();', ctx);
+assert.deepStrictEqual(calls.pop(), [0, 'rec']);
+// With a part between, the next earlier part.
+vm.runInContext(`dvrState.earlier.push(${JSON.stringify({ vodId: 6027, at: 710, end: 800, src: 'https://media.test/v/6027', hls: false })}); dvrState.activeAt = 805; dvrState.playingPart = dvrState.earlier[0]; _dvrPartEnded();`, ctx);
+assert.deepStrictEqual(calls.pop(), [0, 6027]);
+
// No history: the bar is the recording part alone, as before.
// No history: the bar is the recording part alone, as before.
set({ duration: 300, seekable: true, hlsUrl: 'https://media.test/v/7/dvr.m3u8', vodFile: null, earlier: [], activeAt: null, playingPart: null });
set({ duration: 300, seekable: true, hlsUrl: 'https://media.test/v/7/dvr.m3u8', vodFile: null, earlier: [], activeAt: null, playingPart: null });
assert.deepStrictEqual({ ...vm.runInContext('_dvrSpan()', ctx) }, { start: 0, total: 300 });
assert.deepStrictEqual({ ...vm.runInContext('_dvrSpan()', ctx) }, { start: 0, total: 300 });
@@ -202,6 +202,20 @@ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -202,6 +202,20 @@ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
assert.deepStrictEqual(t.json.parts.map((p) => [p.vod_id, p.at]), [[6025, 28], [6026, 709]]);
assert.deepStrictEqual(t.json.parts.map((p) => [p.vod_id, p.at]), [[6025, 28], [6026, 709]]);
});
});
+ await check('a VOD without its full transcript yet shows the live captions, in its own time', async () => {
+ media.listClips = async () => ({ clips: [] });
+ const r = await call('GET', '/api/vods/6026');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json.vod.transcript_source, 'live');
+ assert.deepStrictEqual(JSON.parse(r.json.vod.ai_transcript_json), [{ start: 11, end: 14.5, text: 'test successful' }]);
+ assert.strictEqual(r.json.vod.ai_transcript, 'test successful');
+ await raw.prepare('INSERT INTO vod_ai_state (vod_id, ai_transcript_json, transcript_status) VALUES (?, ?, ?)')
+ .run(6026, JSON.stringify([{ start: 11, end: 14.5, text: 'Test successful.' }]), 'done');
+ const done = await call('GET', '/api/vods/6026');
+ assert.strictEqual(done.json.vod.transcript_source, undefined, 'the full pass wins once it is in');
+ assert.strictEqual(done.json.vod.ai_transcript, 'Test successful.');
+ });
+
await check('speech capture offsets are stream time across a restart', async () => {
await check('speech capture offsets are stream time across a restart', async () => {
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');