summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-17 02:16:02 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-17 02:16:02 +0200
commit3f74fcc3515cd461f9fdd8d95a9d80aee67e1e58 (patch)
treeb5ca8282ccc1fae0f63227849a26861504080529
parentf57b2f6c0c2770fe0f456128d985e29427d2432f (diff)
downloadmeshbay-3f74fcc3515cd461f9fdd8d95a9d80aee67e1e58.tar.gz
feat(node): seeking, as a stream restarted somewhere else
The scrubber was drawn the length of the film — `ms.duration` has always been the real duration — and then `onSeeking` quietly clamped every target back into whatever happened to be buffered. The bar invited a click and refused it. `stream_req` gains a `start`. The session's previous stream is retired by the path that already exists for switching films, and ffmpeg is spawned again with `-ss` **before** `-i`: an index lookup rather than decoding and discarding up to the point, which is milliseconds on a 500 MB film instead of tens of seconds. Measured over real MNP: 0s -> 492 MB, 600s -> 418 MB, 3000s -> 179 MB. A seek at or past the end is pulled back, because ffmpeg would produce nothing and the player would wait for segments that are never coming. `stream_init` reports the position actually used. It has to: ffmpeg restarts its output timestamps at zero however far in it seeks — `-copyts` does not change that for this input, measured — so the client is the one that puts the fragments back on the film's timeline, and it cannot guess by how much. The value is also not what was asked for, since `-c copy` lands on the keyframe at or before it. The diagnostics that found the rest of this are here too: a seek, a first init and a re-init are each one line at INFO, which is rare enough to keep on. The five-second client report stays at DEBUG.
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js7
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py56
2 files changed, 51 insertions, 12 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 1b9abb3..4ee5810 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -579,8 +579,11 @@ class MeshBayTransport {
* MediaSource consumes a segment at a time — which is fine for a clip and
* fatal for anything worth streaming.
*/
- requestStream(fileId, credits = STREAM_CREDITS) {
- this._send({ type: 'stream_req', v: '0.1', file_id: fileId, credits });
+ requestStream(fileId, credits = STREAM_CREDITS, start = 0) {
+ // `start` is a seek: the node retires whatever this session was streaming
+ // and spawns ffmpeg again from there. Omitted or zero is the film's
+ // beginning, which is what an 0.1 node understands.
+ this._send({ type: 'stream_req', v: '0.1', file_id: fileId, credits, start });
}
/** Room for `n` more segments. */
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 5f5c9ff..6b87e31 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -463,18 +463,29 @@ class WebRTCPeerSession:
# whatever one likes into the operator's file.
def _f(key: str, n: int = 24) -> str:
return str(msg.get(key))[:n].replace("\n", " ")
+ if msg.get("event"):
+ # Once per stream or per seek, not once per five seconds —
+ # and a seek nobody asked for looks exactly like a viewer
+ # dragging the scrubber from this side, so it has to be
+ # visible without turning DEBUG on.
+ log.info(
+ "stream: client %s target=%s t=%ss offset=%s ready=%s "
+ "duration=%s ranges=[%s]",
+ _f("event", 16), _f("target"), _f("t"), _f("offset"),
+ _f("ready"), _f("duration"), _f("ranges", 120))
# Debug: one line every five seconds per viewer. Run the daemon
# with --log-level debug to see inside a player that is
# misbehaving — it is the only view of the browser there is
# when the browser is a phone.
- log.debug(
- "stream: client t=%ss ahead=%ss ready=%s paused=%s "
- "stalled=%s q=%s inflight=%s appending=%s updating=%s "
- "quota=%s ms=%s err=%s ranges=[%s] (sent=%d)",
- _f("t"), _f("ahead"), _f("ready"), _f("paused"),
- _f("stalled"), _f("q"), _f("inflight"), _f("appending"),
- _f("updating"), _f("quota"), _f("ms"), _f("err", 80),
- _f("ranges", 120), self._stream_segments)
+ else:
+ log.debug(
+ "stream: client t=%ss ahead=%ss ready=%s paused=%s "
+ "stalled=%s q=%s inflight=%s appending=%s updating=%s "
+ "quota=%s ms=%s err=%s ranges=[%s] (sent=%d)",
+ _f("t"), _f("ahead"), _f("ready"), _f("paused"),
+ _f("stalled"), _f("q"), _f("inflight"), _f("appending"),
+ _f("updating"), _f("quota"), _f("ms"), _f("err", 80),
+ _f("ranges", 120), self._stream_segments)
elif mtype == MNP.STREAM_STOP:
age = (time.monotonic() - self._stream_started_at
if self._stream_started_at else -1)
@@ -2056,8 +2067,29 @@ class WebRTCPeerSession:
self._send({"type": "error", "detail": "Unsupported video codec"})
return
+ # Where to begin. Seeking is a stream restarted somewhere else: the
+ # viewer moves the scrubber, this session's previous stream is retired
+ # by _replace_stream, and ffmpeg is spawned again with -ss.
+ try:
+ start = float(msg.get("start", 0) or 0)
+ except (TypeError, ValueError):
+ start = 0.0
+ # Past the end would produce an empty stream and a player waiting for
+ # segments that are never coming.
+ if duration and start >= duration - 1:
+ start = max(0.0, duration - 5)
+ start = max(0.0, start)
+
+ # -ss BEFORE -i, which seeks by the container index rather than by
+ # decoding up to the point: milliseconds on a 500 MB film instead of
+ # tens of seconds. It lands on the keyframe at or before `start`, so
+ # the picture can begin a few seconds earlier than asked — which is
+ # what every streaming player does, and why the client is told the
+ # value used rather than left to assume its own.
+ seek_args = ["-ss", f"{start:.3f}"] if start > 0 else []
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-hide_banner", "-loglevel", "error",
+ *seek_args,
"-i", str(file_path),
"-c", "copy",
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
@@ -2071,6 +2103,10 @@ class WebRTCPeerSession:
"file_id": file_id,
"codec": codec_str,
"duration": duration,
+ # ffmpeg restarts its timestamps at zero whatever we seek to, so
+ # this is what the client adds back (`SourceBuffer.timestampOffset`)
+ # to put the fragments where they belong on the timeline.
+ "start": start,
})
# A client that says nothing gets the old behaviour, which is why this
@@ -2087,8 +2123,8 @@ class WebRTCPeerSession:
self._stream_started_at = time.monotonic()
self._stream_segments = 0
reason = "eof"
- log.info("stream: stream_init sent file=%s paced=%s credits=%d",
- file_id[:12], paced, self._stream_credit)
+ log.info("stream: stream_init sent file=%s paced=%s credits=%d start=%.1fs",
+ file_id[:12], paced, self._stream_credit, start)
try:
while True:
if paced and not await self._await_stream_credit():