Enable recorded stream seeking
This commit is contained in:
@@ -75,6 +75,8 @@ The regression history matters:
|
|||||||
|
|
||||||
Default code behavior is `split` when no mode is set. The Compose example uses `relay` because it is the mode to try for IPTV streams.
|
Default code behavior is `split` when no mode is set. The Compose example uses `relay` because it is the mode to try for IPTV streams.
|
||||||
|
|
||||||
|
Finite-duration sessions are treated as recorded video and seekable. When the configured mode is `relay`, recorded sessions switch to the seek-capable `split` path once duration metadata is known; live/unknown-duration streams stay in relay mode.
|
||||||
|
|
||||||
## ffmpeg Pipelines
|
## ffmpeg Pipelines
|
||||||
|
|
||||||
All ffmpeg command builders live near the bottom of `server/index.js`.
|
All ffmpeg command builders live near the bottom of `server/index.js`.
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ Available playback modes:
|
|||||||
- `relay`: One source connection from the backend, then the compressed input bytes are teed into separate audio and frame `ffmpeg` workers. This is intended for IPTV hosts that stop early or reject multiple active connections.
|
- `relay`: One source connection from the backend, then the compressed input bytes are teed into separate audio and frame `ffmpeg` workers. This is intended for IPTV hosts that stop early or reject multiple active connections.
|
||||||
- `single`: One source connection and one `ffmpeg` worker with both audio and frame outputs. This is the simplest one-connection fallback, but audio and frame delivery can affect each other.
|
- `single`: One source connection and one `ffmpeg` worker with both audio and frame outputs. This is the simplest one-connection fallback, but audio and frame delivery can affect each other.
|
||||||
|
|
||||||
|
Finite-duration streams are treated as recorded video and become seekable once metadata is available. If the app is configured for `relay`, recorded streams switch to the seek-capable `split` path; live or unknown-duration streams stay in relay mode.
|
||||||
|
|
||||||
Relay mode uses bounded per-worker input queues so one branch can briefly lag without immediately stalling the other. Tune the cap with `MAX_RELAY_BRANCH_QUEUE_BYTES`; the default is `16777216`.
|
Relay mode uses bounded per-worker input queues so one branch can briefly lag without immediately stalling the other. Tune the cap with `MAX_RELAY_BRANCH_QUEUE_BYTES`; the default is `16777216`.
|
||||||
|
|
||||||
## Tuning
|
## Tuning
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ const FRAME_STARTUP_STALL_RESET_MS = 10000;
|
|||||||
const FRAME_STALL_RESET_MS = 6000;
|
const FRAME_STALL_RESET_MS = 6000;
|
||||||
const PLAYBACK_RESTART_COOLDOWN_MS = 8000;
|
const PLAYBACK_RESTART_COOLDOWN_MS = 8000;
|
||||||
const PLAYBACK_RESTART_DELAY_MS = 750;
|
const PLAYBACK_RESTART_DELAY_MS = 750;
|
||||||
|
const METADATA_REFRESH_ATTEMPTS = 20;
|
||||||
|
const METADATA_REFRESH_INTERVAL_MS = 650;
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
generation: 0,
|
generation: 0,
|
||||||
@@ -866,7 +868,7 @@ async function seekTo(value) {
|
|||||||
async function refreshSessionMetadata(sessionId) {
|
async function refreshSessionMetadata(sessionId) {
|
||||||
const generation = state.generation;
|
const generation = state.generation;
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
for (let attempt = 0; attempt < METADATA_REFRESH_ATTEMPTS; attempt += 1) {
|
||||||
if (generation !== state.generation || state.session?.id !== sessionId) {
|
if (generation !== state.generation || state.session?.id !== sessionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -894,7 +896,7 @@ async function refreshSessionMetadata(sessionId) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await delay(650);
|
await delay(METADATA_REFRESH_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -286,12 +286,13 @@ canvas {
|
|||||||
top: -1.75rem;
|
top: -1.75rem;
|
||||||
right: 1.1rem;
|
right: 1.1rem;
|
||||||
left: 1.1rem;
|
left: 1.1rem;
|
||||||
height: 2.2rem;
|
height: 2.45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-badge {
|
.time-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
pointer-events: none;
|
||||||
min-width: 3.6rem;
|
min-width: 3.6rem;
|
||||||
padding: 0.22rem 0.45rem;
|
padding: 0.22rem 0.45rem;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
@@ -318,10 +319,10 @@ canvas {
|
|||||||
.seek-slider {
|
.seek-slider {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0.18rem;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 1rem;
|
height: 1.35rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@@ -269,11 +269,14 @@ app.get('/audio/:sessionId', (request, response) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (PLAYBACK_CONNECTION_MODE === 'single' || PLAYBACK_CONNECTION_MODE === 'relay') {
|
const playbackMode = getSessionPlaybackConnectionMode(session);
|
||||||
getOrCreatePlayback(session).attachAudio(request, response);
|
|
||||||
|
if (playbackMode === 'single' || playbackMode === 'relay') {
|
||||||
|
getOrCreatePlayback(session, playbackMode).attachAudio(request, response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stopSharedPlaybackIfNeeded(session, playbackMode);
|
||||||
streamSplitAudio(request, response, session);
|
streamSplitAudio(request, response, session);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -301,11 +304,14 @@ wss.on('connection', (websocket, _request, sessionId) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (PLAYBACK_CONNECTION_MODE === 'single' || PLAYBACK_CONNECTION_MODE === 'relay') {
|
const playbackMode = getSessionPlaybackConnectionMode(session);
|
||||||
getOrCreatePlayback(session).attachFrames(websocket);
|
|
||||||
|
if (playbackMode === 'single' || playbackMode === 'relay') {
|
||||||
|
getOrCreatePlayback(session, playbackMode).attachFrames(websocket);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stopSharedPlaybackIfNeeded(session, playbackMode);
|
||||||
streamSplitFrames(websocket, session);
|
streamSplitFrames(websocket, session);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -413,7 +419,19 @@ function formatSessionPayload(session) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isSessionSeekable(session) {
|
function isSessionSeekable(session) {
|
||||||
return PLAYBACK_CONNECTION_MODE !== 'relay' && Number.isFinite(session.duration) && session.duration > 0;
|
return hasRecordedDuration(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRecordedDuration(session) {
|
||||||
|
return Number.isFinite(session.duration) && session.duration > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionPlaybackConnectionMode(session) {
|
||||||
|
if (PLAYBACK_CONNECTION_MODE === 'relay' && hasRecordedDuration(session)) {
|
||||||
|
return 'split';
|
||||||
|
}
|
||||||
|
|
||||||
|
return PLAYBACK_CONNECTION_MODE;
|
||||||
}
|
}
|
||||||
|
|
||||||
function startSessionMetadataProbe(session) {
|
function startSessionMetadataProbe(session) {
|
||||||
@@ -666,18 +684,34 @@ function getSession(sessionId) {
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOrCreatePlayback(session) {
|
function getOrCreatePlayback(session, playbackMode = getSessionPlaybackConnectionMode(session)) {
|
||||||
const existing = playbacks.get(session.id);
|
const existing = playbacks.get(session.id);
|
||||||
|
|
||||||
if (existing && !existing.closed) {
|
if (existing && !existing.closed && existing.mode === playbackMode) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const playback = PLAYBACK_CONNECTION_MODE === 'relay' ? createRelayPlayback(session) : createPlayback(session);
|
stopSharedPlaybackIfNeeded(session, playbackMode);
|
||||||
|
|
||||||
|
const playback = playbackMode === 'relay' ? createRelayPlayback(session) : createPlayback(session);
|
||||||
playbacks.set(session.id, playback);
|
playbacks.set(session.id, playback);
|
||||||
return playback;
|
return playback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stopSharedPlaybackIfNeeded(session, playbackMode) {
|
||||||
|
const existing = playbacks.get(session.id);
|
||||||
|
|
||||||
|
if (!existing || existing.closed || existing.mode === playbackMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.stop('playback_mode_changed');
|
||||||
|
|
||||||
|
if (playbacks.get(session.id) === existing) {
|
||||||
|
playbacks.delete(session.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function streamSplitAudio(request, response, session) {
|
function streamSplitAudio(request, response, session) {
|
||||||
const worker = createAudioWorker(session);
|
const worker = createAudioWorker(session);
|
||||||
const releaseWorker = once(worker.release);
|
const releaseWorker = once(worker.release);
|
||||||
@@ -884,6 +918,7 @@ function createRelayPlayback(session) {
|
|||||||
const frameParser = createJpegFrameParser(handleJpegFrame);
|
const frameParser = createJpegFrameParser(handleJpegFrame);
|
||||||
|
|
||||||
const playback = {
|
const playback = {
|
||||||
|
mode: 'relay',
|
||||||
get closed() {
|
get closed() {
|
||||||
return closed;
|
return closed;
|
||||||
},
|
},
|
||||||
@@ -1243,6 +1278,7 @@ function createPlayback(session) {
|
|||||||
const frameParser = createJpegFrameParser(handleJpegFrame);
|
const frameParser = createJpegFrameParser(handleJpegFrame);
|
||||||
|
|
||||||
const playback = {
|
const playback = {
|
||||||
|
mode: 'single',
|
||||||
get closed() {
|
get closed() {
|
||||||
return closed;
|
return closed;
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user