aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-23 22:43:35 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-23 22:43:35 +0200
commitb6e2dcea65124673da047f9b3c92bc5e25980d63 (patch)
tree2776598c32b30229dcd7d6a3d030bb4965ad1c70 /packages/meshbay-hub/src
parentb3709ac4d362987a9d025616c95065ceed0d216b (diff)
downloadmeshbay-b6e2dcea65124673da047f9b3c92bc5e25980d63.tar.gz
fix(node,hub): always transcode video audio to stereo AAC, never copy
MSE only decodes AAC/Opus, so copying a source's real audio codec left non-AAC files silently unplayable in-browser (E-AC-3 additionally made ffmpeg itself refuse to write the fragmented MP4 header). Audio is now always transcoded to AAC and downmixed to stereo — multichannel AAC is accepted by ffprobe/VLC but silently rejected by some browsers' MSE decoder once real fragments are appended, which forces the SourceBuffer out of its MediaSource with no explicit error. Video stays copy-only. Also: report a clear client-side error instead of a bare STREAM_END when ffmpeg exits nonzero before producing any output, add video-element/ MediaSource error logging on the client for the next time this class of bug needs diagnosing, and fix a hub test that had grown too broad a scan window after an earlier, unrelated transport.js change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-player.js65
1 files changed, 55 insertions, 10 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
index 42842d2..82e1116 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
@@ -200,16 +200,26 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
const evictBehind = useCallback(() => {
const sb = sbRef.current;
const v = videoRef.current;
- if (!sb || !v || sb.updating || !sb.buffered.length) return false;
- const keepFrom = Math.max(0, v.currentTime - BUFFER_BEHIND_S);
- // The range being watched, not the first one: after a seek backwards the
- // first range is somewhere else entirely, and removing from its start to
- // just behind the playhead would take out everything in between —
- // including what is playing.
- const range = currentRange();
- const start = range ? range[0] : sb.buffered.start(0);
- if (keepFrom - start < 10) return false;
+ if (!sb || !v) return false;
+ // `.buffered`/`.updating` throw InvalidStateError once the SourceBuffer
+ // has been removed from its MediaSource — same reasoning as currentRange
+ // and describeRanges just above, which already guard the same read. This
+ // one did not, and an uncaught throw here skips flushQueue right after it
+ // in pump() too, since nothing between them catches it. Found live:
+ // fired on every incoming segment once the SourceBuffer went stale,
+ // which pump() runs on a 1s timer regardless of whether new data is
+ // arriving — an unguarded read here is not a rare corner, it repeats
+ // forever.
try {
+ if (sb.updating || !sb.buffered.length) return false;
+ const keepFrom = Math.max(0, v.currentTime - BUFFER_BEHIND_S);
+ // The range being watched, not the first one: after a seek backwards the
+ // first range is somewhere else entirely, and removing from its start to
+ // just behind the playhead would take out everything in between —
+ // including what is playing.
+ const range = currentRange();
+ const start = range ? range[0] : sb.buffered.start(0);
+ if (keepFrom - start < 10) return false;
sb.remove(start, keepFrom);
return true;
} catch {
@@ -253,8 +263,22 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
}
queueRef.current.shift();
console.error('[MSE] appendBuffer error:', e);
+ // Anything else here does not get better by retrying: a SourceBuffer
+ // removed from its MediaSource stays removed. Discarding the segment
+ // and continuing left pump()'s credit grant running unchecked — it is
+ // driven by how much is successfully buffered, which never grows when
+ // nothing is actually appending — so the node kept sending and this
+ // kept discarding, forever. Found live: over 2 GB and 8000+ segments
+ // fetched for a picture that never appeared. Stop asking instead of
+ // spinning.
+ queueRef.current = [];
+ endedRef.current = true;
+ const transport = transportRef.current;
+ if (transport) transport.stopStream();
+ setError(t('video.err_transport'));
+ setPhase('error');
}
- }, [evictBehind]);
+ }, [evictBehind, transportRef]);
/**
* Decide whether the node may send more, and keep the pipeline moving.
@@ -591,11 +615,32 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
setPhase('streaming');
flushQueue();
});
+ // Neither fires for the ordinary end-of-stream (that's `endOfStream()`
+ // succeeding, no event needed) — only for the browser's own decoder
+ // giving up on what was appended. Logged, not acted on: by the time
+ // this fires the MediaSource is already unusable and every SourceBuffer
+ // call from here on throws, which the existing catches already handle.
+ ms.addEventListener('sourceclose', () => {
+ console.error('[MSE] sourceclose — MediaSource left "open" on its own',
+ 'readyState:', ms.readyState);
+ });
if (videoRef.current) {
videoRef.current.src = url;
videoRef.current.addEventListener('seeking', onSeeking);
videoRef.current.addEventListener('timeupdate', pump);
+ videoRef.current.addEventListener('error', () => {
+ const err = videoRef.current && videoRef.current.error;
+ console.error('[MSE] video element error, code:', err && err.code,
+ 'message:', err && err.message);
+ if (transportRef.current) {
+ transportRef.current.sendStreamDiag({
+ event: 'video-element-error',
+ code: err ? err.code : null,
+ message: err ? err.message : null,
+ });
+ }
+ });
// The element's own verdict. "buffering" on screen is this, and it
// is the one thing the node cannot infer from a stream it is feeding.
videoRef.current.addEventListener('waiting', onStarved);