aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node')
-rw-r--r--packages/meshbay-node/src/meshbay_node/media_probe.py8
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py49
-rw-r--r--packages/meshbay-node/tests/test_stream_hevc_transcode.py155
-rw-r--r--packages/meshbay-node/tests/test_stream_video_transcode.py273
4 files changed, 324 insertions, 161 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/media_probe.py b/packages/meshbay-node/src/meshbay_node/media_probe.py
index e0c59e3..ad668fa 100644
--- a/packages/meshbay-node/src/meshbay_node/media_probe.py
+++ b/packages/meshbay-node/src/meshbay_node/media_probe.py
@@ -43,6 +43,14 @@ async def probe_video(
faithfully reports "hev1..." for a source this pipeline cannot actually
deliver copied.
+ **A `None` codec string means "this must be re-encoded", not "this cannot
+ be played".** Only the four codecs a browser can decode through MediaSource
+ are mapped; everything else — MPEG-4 Part 2 (Xvid, DivX), MPEG-2, VC-1,
+ WMV, Theora — has no string to report because there is no browser decoder
+ to report it to, and the streaming path answers that by re-encoding to
+ H264. It answered it by refusing until 2026-09-09, which read to the
+ operator as a broken file rather than as an unwired code path.
+
width/height come from the same ffprobe call (one extra `-show_entries`
field, no second process spawn) — resolution is deliberately never
guessed from the filename (docs/mediacenter.md §3.5).
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 507650a..a0efa83 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -5673,8 +5673,13 @@ class WebRTCPeerSession:
self._send({"type": "error", "detail": f"Probe failed: {e}"})
return
- if not codec_str:
- self._send({"type": "error", "detail": "Unsupported video codec"})
+ # No video stream at all is the only thing this path cannot serve, and
+ # it is the only thing refused here. A source with no MSE codec string
+ # is emphatically not that — it is the case the re-encode below exists
+ # for, and refusing it here (as "Unsupported video codec") is what this
+ # fixed.
+ if not raw_video_codec:
+ self._send({"type": "error", "detail": "No video stream in this file"})
return
# Where to begin. Seeking is a stream restarted somewhere else: the
@@ -5713,12 +5718,44 @@ class WebRTCPeerSession:
# decode problem as E-AC-3, just without ffmpeg also refusing to mux
# it). Transcoding audio is cheap; it does not change the cost model
# the transcode-slot semaphore is sized around.
- transcode_video = (
- raw_video_codec in BROWSER_INCOMPATIBLE_VIDEO_CODECS
- and self._ctx.get("transcode_incompatible_video", True)
- )
+ #
+ # Two kinds of source cannot be copied, and both re-encode:
+ #
+ # - one whose MSE codec string is real but that no mainstream browser
+ # decodes — HEVC, BROWSER_INCOMPATIBLE_VIDEO_CODECS;
+ # - one with **no MSE codec string at all**: MPEG-4 Part 2 (Xvid,
+ # DivX), MPEG-2, VC-1, WMV, Theora. `stream_init` has to carry a
+ # string the client puts through MediaSource.isTypeSupported, and
+ # `probe_video` returns None for these precisely because no browser
+ # has a MediaSource decoder for them, so there is none to carry.
+ # This second kind used to be refused outright with "Unsupported
+ # video codec" — which named the source's problem and not the
+ # node's answer to it, since ffmpeg re-encodes these in real time on
+ # any machine that can run this daemon. Reported live against an
+ # Xvid/MP3 .avi. `transcode_incompatible_video`'s own documentation
+ # (draft-v6 §2.11) already said "HEVC *and other browser-
+ # incompatible video codecs*"; only HEVC was ever wired up.
+ can_copy = (bool(codec_str)
+ and raw_video_codec not in BROWSER_INCOMPATIBLE_VIDEO_CODECS)
+ allow_transcode = self._ctx.get("transcode_incompatible_video", True)
+ transcode_video = not can_copy and allow_transcode
+ if not can_copy and not allow_transcode and not codec_str:
+ # The operator turned the fallback off and there is nothing to fall
+ # back *to*: a stream_init with no codec string is one the client
+ # refuses before the first byte arrives. Which of the two it is
+ # matters — "unsupported codec" sends the reader to look at the
+ # file, and the file is fine.
+ log.info("stream: %s is %s, which needs a re-encode, and "
+ "transcode_incompatible_video is off — refusing",
+ entry.name, raw_video_codec)
+ self._send({"type": "error",
+ "detail": "This video needs transcoding, which the "
+ "operator has turned off"})
+ return
map_args = ["-map", "0:v:0"]
if transcode_video:
+ log.info("stream: re-encoding %s (%s) to H264", entry.name,
+ raw_video_codec)
# -pix_fmt yuv420p: a 10-bit or 4:4:4 HEVC source (common for HDR
# WEB-DLs) fails "-profile:v high" outright otherwise — libx264's
# High profile is 8-bit 4:2:0 only. Downsampling loses nothing a
diff --git a/packages/meshbay-node/tests/test_stream_hevc_transcode.py b/packages/meshbay-node/tests/test_stream_hevc_transcode.py
deleted file mode 100644
index 7d831ee..0000000
--- a/packages/meshbay-node/tests/test_stream_hevc_transcode.py
+++ /dev/null
@@ -1,155 +0,0 @@
-"""
-HEVC video is transcoded to H264 for streaming, never copied — unlike the
-codecs media_probe.py's BROWSER_INCOMPATIBLE_VIDEO_CODECS excludes.
-
-Found live: a real HEVC/EAC3 WEB-DL streamed fine over MNP (ffprobe/VLC play
-it) but the browser reported "Codec not supported for streaming:
-hev1.1.6.L93.B0,mp4a.40.2" from MediaSource.isTypeSupported — Chrome has no
-HEVC decoder on most non-Apple platforms. "-c:v copy" on an incompatible
-codec is not a mux failure the way EAC3 audio is (test_stream_audio_
-transcode.py); ffmpeg happily remuxes it, and the browser is the one that
-then refuses it, silently, at playback rather than at stream_init.
-
-These tests spawn real ffmpeg/ffprobe against small synthetic files (lavfi
-test sources, ~1s), the same style as test_stream_audio_transcode.py.
-"""
-
-import shutil
-import subprocess
-from pathlib import Path
-
-import pytest
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-
-from meshbay_common.crypto import generate_gek
-from meshbay_common.webcrypto import chunk_key_aes, decrypt_chunk_aes
-from meshbay_node.indexer.group_index import GroupIndex
-from meshbay_node.transport.webrtc_server import WebRTCPeerSession, _probe_video
-
-from conftest import needs_subprocess, one_root
-
-_HAVE_FFMPEG = shutil.which("ffmpeg") and shutil.which("ffprobe")
-_HAVE_HEVC_ENCODER = _HAVE_FFMPEG and b"libx265" in subprocess.run(
- ["ffmpeg", "-hide_banner", "-encoders"], capture_output=True).stdout
-pytestmark = [
- pytest.mark.asyncio,
- pytest.mark.skipif(not _HAVE_HEVC_ENCODER, reason="ffmpeg/libx265 not installed"),
- needs_subprocess,
-]
-
-
-def _make_hevc_clip(path: Path) -> None:
- """~1s of HEVC video + AAC audio — a minimal stand-in for a real HEVC WEB-DL."""
- subprocess.run(
- ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
- "-f", "lavfi", "-i", "testsrc=size=320x240:rate=25:duration=1",
- "-f", "lavfi", "-i", "sine=frequency=440:duration=1:sample_rate=48000",
- "-c:v", "libx265", "-preset", "ultrafast", "-c:a", "aac",
- str(path)],
- check=True, capture_output=True,
- )
-
-
-def _session(video_path: Path, gek: bytes, *, transcode_incompatible_video: bool = True):
- import blake3
- file_bytes = video_path.read_bytes()
- file_id = blake3.blake3(file_bytes).hexdigest()
-
- sk_node = Ed25519PrivateKey.generate()
- index = GroupIndex(group_id="g" * 32, sk_node=sk_node, gek=gek)
- from meshbay_common.protocol import IndexEntry
- index.add_entry(IndexEntry(
- id=file_id, name=video_path.name, path=video_path.parent.name,
- size=len(file_bytes), type="video", added_at=0))
-
- session = WebRTCPeerSession.__new__(WebRTCPeerSession)
- session._ctx = {
- "roots": one_root(video_path.parent),
- "index": index,
- "gek": gek,
- "sk_node": sk_node,
- "max_concurrent_streams": 4,
- "transcode_incompatible_video": transcode_incompatible_video,
- }
- session._group_id = None
- session._user_id = "tester"
- session._stream_stopped = False
- session._stream_keepalives = 0
- session.sent = []
- session._send = session.sent.append
- session._audit = lambda *a, **k: None
- return session, file_id
-
-
-def _reassemble(sent: list[dict], gek: bytes, file_id: str) -> bytes:
- file_hash = bytes.fromhex(file_id)
- segments = sorted(
- (m for m in sent if m.get("type") == "stream_data"),
- key=lambda m: m["segment_index"])
- out = b""
- for m in segments:
- key = chunk_key_aes(gek, file_hash, m["segment_index"])
- out += decrypt_chunk_aes(key, m["nonce"], m["ct"])
- return out
-
-
-def _output_video_codec(path: Path) -> str:
- probe = subprocess.run(
- ["ffprobe", "-v", "error", "-select_streams", "v:0",
- "-show_entries", "stream=codec_name", "-of", "csv=p=0", str(path)],
- check=True, capture_output=True, text=True)
- return probe.stdout.strip()
-
-
-async def test_hevc_video_is_transcoded_to_h264_by_default(tmp_path):
- clip = tmp_path / "clip.mkv"
- _make_hevc_clip(clip)
- gek = generate_gek()
- session, file_id = _session(clip, gek)
-
- await session._stream_video_inner({"file_id": file_id, "start": 0, "credits": 0})
-
- errors = [m for m in session.sent if m.get("type") == "error"]
- assert not errors, f"streaming must not fail: {errors}"
-
- init = next(m for m in session.sent if m.get("type") == "stream_init")
- assert init["codec"].startswith("avc1."), (
- "the reported codec must be the transcoded H264 string, never the "
- f"source's hev1 string a browser cannot decode: {init['codec']}")
-
- remuxed = _reassemble(session.sent, gek, file_id)
- out_path = tmp_path / "out.mp4"
- out_path.write_bytes(remuxed)
- assert _output_video_codec(out_path) == "h264", \
- "the bytes on the wire must actually be H264, not just the reported label"
-
-
-async def test_hevc_transcode_can_be_disabled_by_the_operator(tmp_path):
- clip = tmp_path / "clip.mkv"
- _make_hevc_clip(clip)
- gek = generate_gek()
- session, file_id = _session(clip, gek, transcode_incompatible_video=False)
-
- await session._stream_video_inner({"file_id": file_id, "start": 0, "credits": 0})
-
- assert not [m for m in session.sent if m.get("type") == "error"]
- init = next(m for m in session.sent if m.get("type") == "stream_init")
- assert init["codec"].startswith("hev1."), (
- "with the fallback disabled, the source is copied as-is and the "
- f"original HEVC codec string must be reported unchanged: {init['codec']}")
-
- remuxed = _reassemble(session.sent, gek, file_id)
- out_path = tmp_path / "out.mp4"
- out_path.write_bytes(remuxed)
- assert _output_video_codec(out_path) == "hevc", \
- "with the fallback disabled, the wire bytes must still be copied HEVC"
-
-
-async def test_probe_video_reports_raw_codec_name_for_hevc(tmp_path):
- clip = tmp_path / "clip.mkv"
- _make_hevc_clip(clip)
-
- codec, duration, has_audio, width, height, raw_codec = await _probe_video(str(clip))
-
- assert raw_codec == "hevc"
- assert codec is not None and codec.startswith("hev1.")
diff --git a/packages/meshbay-node/tests/test_stream_video_transcode.py b/packages/meshbay-node/tests/test_stream_video_transcode.py
new file mode 100644
index 0000000..69f4c2d
--- /dev/null
+++ b/packages/meshbay-node/tests/test_stream_video_transcode.py
@@ -0,0 +1,273 @@
+"""
+A video the browser cannot decode is re-encoded to H264, never refused and
+never copied. There are two kinds of those, and both are here.
+
+**A codec string that is real but useless.** A real HEVC/EAC3 WEB-DL streamed
+fine over MNP (ffprobe/VLC play it) but the browser reported "Codec not
+supported for streaming: hev1.1.6.L93.B0,mp4a.40.2" from
+MediaSource.isTypeSupported — Chrome has no HEVC decoder on most non-Apple
+platforms. "-c:v copy" on such a codec is not a mux failure the way EAC3 audio
+is (test_stream_audio_transcode.py); ffmpeg happily remuxes it, and the browser
+is the one that then refuses it, silently, at playback rather than at
+stream_init. That is what BROWSER_INCOMPATIBLE_VIDEO_CODECS names.
+
+**No codec string at all.** MPEG-4 Part 2 (Xvid, DivX), MPEG-2, VC-1, WMV and
+Theora have no MediaSource decoder anywhere, so `probe_video` maps them to
+None — there is nothing to put in `stream_init` for the client to check. Until
+2026-09-09 the streaming path read that None as "unsupported" and refused, with
+"Unsupported video codec", on files ffmpeg re-encodes in real time. Reported
+live against an episode rip in a `.avi` — mpeg4 video, mp3 audio, 720x404 —
+which the reporting machine re-encodes at about six times playback speed. The setting
+that governs it, `transcode_incompatible_video`, was documented from the start
+as covering "HEVC *and other browser-incompatible video codecs*" (draft-v6
+§2.11); only HEVC was ever wired up.
+
+These tests spawn real ffmpeg/ffprobe against small synthetic files (lavfi
+test sources, ~1s), the same style as test_stream_audio_transcode.py.
+"""
+
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from meshbay_common.crypto import generate_gek
+from meshbay_common.webcrypto import chunk_key_aes, decrypt_chunk_aes
+from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession, _probe_video
+
+from conftest import needs_subprocess, one_root
+
+_HAVE_FFMPEG = shutil.which("ffmpeg") and shutil.which("ffprobe")
+_ENCODERS = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"],
+ capture_output=True).stdout if _HAVE_FFMPEG else b""
+# libx265 gates the HEVC cases and only those: an ffmpeg without it still
+# encodes MPEG-4 Part 2 (a built-in) and can therefore still prove the half of
+# this policy that has no codec string. Marking the whole module on it skipped
+# the reported defect on any box without x265.
+_HAVE_HEVC_ENCODER = b"libx265" in _ENCODERS
+_HAVE_MP3_ENCODER = b"libmp3lame" in _ENCODERS
+pytestmark = [
+ pytest.mark.asyncio,
+ pytest.mark.skipif(not _HAVE_FFMPEG, reason="ffmpeg/ffprobe not installed"),
+ needs_subprocess,
+]
+needs_hevc = pytest.mark.skipif(not _HAVE_HEVC_ENCODER,
+ reason="ffmpeg built without libx265")
+needs_mp3 = pytest.mark.skipif(not _HAVE_MP3_ENCODER,
+ reason="ffmpeg built without libmp3lame")
+
+
+def _make_mpeg4_clip(path: Path) -> None:
+ """~1s of Xvid-style MPEG-4 Part 2 video + MP3 audio in an AVI.
+
+ The same shape as the reported file, down to the container: ffprobe reports
+ `codec_name: mpeg4`, which is what an Xvid encoder produces and what
+ `probe_video` has no MSE string for.
+ """
+ subprocess.run(
+ ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
+ "-f", "lavfi", "-i", "testsrc=size=320x240:rate=25:duration=1",
+ "-f", "lavfi", "-i", "sine=frequency=440:duration=1:sample_rate=48000",
+ "-c:v", "mpeg4", "-c:a", "libmp3lame",
+ str(path)],
+ check=True, capture_output=True,
+ )
+
+
+def _make_hevc_clip(path: Path) -> None:
+ """~1s of HEVC video + AAC audio — a minimal stand-in for a real HEVC WEB-DL."""
+ subprocess.run(
+ ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
+ "-f", "lavfi", "-i", "testsrc=size=320x240:rate=25:duration=1",
+ "-f", "lavfi", "-i", "sine=frequency=440:duration=1:sample_rate=48000",
+ "-c:v", "libx265", "-preset", "ultrafast", "-c:a", "aac",
+ str(path)],
+ check=True, capture_output=True,
+ )
+
+
+def _session(video_path: Path, gek: bytes, *, transcode_incompatible_video: bool = True):
+ import blake3
+ file_bytes = video_path.read_bytes()
+ file_id = blake3.blake3(file_bytes).hexdigest()
+
+ sk_node = Ed25519PrivateKey.generate()
+ index = GroupIndex(group_id="g" * 32, sk_node=sk_node, gek=gek)
+ from meshbay_common.protocol import IndexEntry
+ index.add_entry(IndexEntry(
+ id=file_id, name=video_path.name, path=video_path.parent.name,
+ size=len(file_bytes), type="video", added_at=0))
+
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ session._ctx = {
+ "roots": one_root(video_path.parent),
+ "index": index,
+ "gek": gek,
+ "sk_node": sk_node,
+ "max_concurrent_streams": 4,
+ "transcode_incompatible_video": transcode_incompatible_video,
+ }
+ session._group_id = None
+ session._user_id = "tester"
+ session._stream_stopped = False
+ session._stream_keepalives = 0
+ session.sent = []
+ session._send = session.sent.append
+ session._audit = lambda *a, **k: None
+ return session, file_id
+
+
+def _reassemble(sent: list[dict], gek: bytes, file_id: str) -> bytes:
+ file_hash = bytes.fromhex(file_id)
+ segments = sorted(
+ (m for m in sent if m.get("type") == "stream_data"),
+ key=lambda m: m["segment_index"])
+ out = b""
+ for m in segments:
+ key = chunk_key_aes(gek, file_hash, m["segment_index"])
+ out += decrypt_chunk_aes(key, m["nonce"], m["ct"])
+ return out
+
+
+def _output_video_codec(path: Path) -> str:
+ probe = subprocess.run(
+ ["ffprobe", "-v", "error", "-select_streams", "v:0",
+ "-show_entries", "stream=codec_name", "-of", "csv=p=0", str(path)],
+ check=True, capture_output=True, text=True)
+ return probe.stdout.strip()
+
+
+@needs_hevc
+async def test_hevc_video_is_transcoded_to_h264_by_default(tmp_path):
+ clip = tmp_path / "clip.mkv"
+ _make_hevc_clip(clip)
+ gek = generate_gek()
+ session, file_id = _session(clip, gek)
+
+ await session._stream_video_inner({"file_id": file_id, "start": 0, "credits": 0})
+
+ errors = [m for m in session.sent if m.get("type") == "error"]
+ assert not errors, f"streaming must not fail: {errors}"
+
+ init = next(m for m in session.sent if m.get("type") == "stream_init")
+ assert init["codec"].startswith("avc1."), (
+ "the reported codec must be the transcoded H264 string, never the "
+ f"source's hev1 string a browser cannot decode: {init['codec']}")
+
+ remuxed = _reassemble(session.sent, gek, file_id)
+ out_path = tmp_path / "out.mp4"
+ out_path.write_bytes(remuxed)
+ assert _output_video_codec(out_path) == "h264", \
+ "the bytes on the wire must actually be H264, not just the reported label"
+
+
+@needs_hevc
+async def test_hevc_transcode_can_be_disabled_by_the_operator(tmp_path):
+ clip = tmp_path / "clip.mkv"
+ _make_hevc_clip(clip)
+ gek = generate_gek()
+ session, file_id = _session(clip, gek, transcode_incompatible_video=False)
+
+ await session._stream_video_inner({"file_id": file_id, "start": 0, "credits": 0})
+
+ assert not [m for m in session.sent if m.get("type") == "error"]
+ init = next(m for m in session.sent if m.get("type") == "stream_init")
+ assert init["codec"].startswith("hev1."), (
+ "with the fallback disabled, the source is copied as-is and the "
+ f"original HEVC codec string must be reported unchanged: {init['codec']}")
+
+ remuxed = _reassemble(session.sent, gek, file_id)
+ out_path = tmp_path / "out.mp4"
+ out_path.write_bytes(remuxed)
+ assert _output_video_codec(out_path) == "hevc", \
+ "with the fallback disabled, the wire bytes must still be copied HEVC"
+
+
+@needs_hevc
+async def test_probe_video_reports_raw_codec_name_for_hevc(tmp_path):
+ clip = tmp_path / "clip.mkv"
+ _make_hevc_clip(clip)
+
+ codec, duration, has_audio, width, height, raw_codec = await _probe_video(str(clip))
+
+ assert raw_codec == "hevc"
+ assert codec is not None and codec.startswith("hev1.")
+
+
+@needs_mp3
+async def test_probe_video_reports_no_codec_string_for_mpeg4(tmp_path):
+ """The other shape, and the reason the streaming path has to read it as
+ "re-encode this" rather than as a verdict on the file: there is no MSE
+ string for MPEG-4 Part 2 because no browser has a decoder to give one to.
+ The raw name is still reported, which is what the caller decides on."""
+ clip = tmp_path / "clip.avi"
+ _make_mpeg4_clip(clip)
+
+ codec, duration, has_audio, width, height, raw_codec = await _probe_video(str(clip))
+
+ assert raw_codec == "mpeg4"
+ assert codec is None, (
+ "a codec string for MPEG-4 Part 2 would be one no browser can act on")
+ assert has_audio is True
+ assert (width, height) == (320, 240)
+
+
+@needs_mp3
+async def test_a_codec_with_no_mse_string_is_transcoded_not_refused(tmp_path):
+ """The reported defect.
+
+ An Xvid/MP3 .avi answered "Unsupported video codec" — a refusal, on a file
+ ffmpeg re-encodes faster than it plays. Nothing about the source changed;
+ the node simply never reached its own re-encode path.
+ """
+ clip = tmp_path / "clip.avi"
+ _make_mpeg4_clip(clip)
+ gek = generate_gek()
+ session, file_id = _session(clip, gek)
+
+ await session._stream_video_inner({"file_id": file_id, "start": 0, "credits": 0})
+
+ errors = [m for m in session.sent if m.get("type") == "error"]
+ assert not errors, (
+ f"a source with no MSE codec string was refused instead of re-encoded: "
+ f"{errors}")
+
+ init = next(m for m in session.sent if m.get("type") == "stream_init")
+ assert init["codec"].startswith("avc1."), (
+ "stream_init must advertise the H264 the client is about to receive; "
+ f"there is no string for the source and none may be invented: {init['codec']}")
+
+ remuxed = _reassemble(session.sent, gek, file_id)
+ out_path = tmp_path / "out.mp4"
+ out_path.write_bytes(remuxed)
+ assert _output_video_codec(out_path) == "h264", (
+ "the bytes on the wire must actually be H264, not just the label")
+
+
+@needs_mp3
+async def test_the_operator_can_refuse_the_re_encode_and_says_so(tmp_path):
+ """`transcode_incompatible_video = false` still means what it says.
+
+ HEVC falls back to a copy there (the case above), because there is a codec
+ string to report. Here there is none, so a copy is not a lesser answer —
+ it is a stream_init the client refuses before the first byte. The refusal
+ has to name the setting: "Unsupported video codec" is what sent the last
+ reader to look at a file that was fine.
+ """
+ clip = tmp_path / "clip.avi"
+ _make_mpeg4_clip(clip)
+ gek = generate_gek()
+ session, file_id = _session(clip, gek, transcode_incompatible_video=False)
+
+ await session._stream_video_inner({"file_id": file_id, "start": 0, "credits": 0})
+
+ errors = [m for m in session.sent if m.get("type") == "error"]
+ assert errors, "a stream that cannot be produced must be refused, not started"
+ detail = errors[0]["detail"]
+ assert "transcoding" in detail, (
+ f"the refusal must point at the setting that caused it: {detail!r}")
+ assert not [m for m in session.sent if m.get("type") == "stream_init"], (
+ "a stream_init went out with no codec string the client could check")