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)

OP OpenVibers committed 18 days ago · fa98dca3f7 · 5 files, +61 −3

* A VOD shows the captions heard while it was live until its full transcript is in

VOD 6026 ended at 21:37 and had no transcript for the half hour its full pass took (deferred while the box was busy),
while part 1, finished during the stream, had one. GET /api/vods/:id now serves the timeline rows stamped with the
VOD, in its own time, with transcript_source "live"; the full pass replaces them.

* Rewind: an earlier part played to its end carries on at the next part's start, not back across the reconnect's gap

_dvrPartEnded sought the part's own end, which lies before the next part when the broadcaster was away between them:
the seek landed in the same part again and it replayed its last half second over and over.

Changes

ddiff --git a/docs/vods-and-clips.md b/docs/vods-and-clips.md
iindex 4b1d6fb9..3c46a6f2 100644
---- a/docs/vods-and-clips.md
++++ b/docs/vods-and-clips.md
@@@ -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_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
ddiff --git a/public/js/stream-player.js b/public/js/stream-player.js
iindex 71f515ac..7286987e 100644
---- a/public/js/stream-player.js
++++ b/public/js/stream-player.js
@@@ -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). */
ddiff --git a/server/media-proxy/vods.js b/server/media-proxy/vods.js
iindex 2eddc83e..a6ff32a2 100644
---- a/server/media-proxy/vods.js
++++ b/server/media-proxy/vods.js
@@@ -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;
ddiff --git a/test/player-dvr-parts.test.js b/test/player-dvr-parts.test.js
iindex 187ee253..3fdd892d 100644
---- a/test/player-dvr-parts.test.js
++++ b/test/player-dvr-parts.test.js
@@@ -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 });
ddiff --git a/test/stream-history.test.js b/test/stream-history.test.js
iindex 6aeeea35..09b735f9 100644
---- a/test/stream-history.test.js
++++ b/test/stream-history.test.js
@@@ -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');