Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4c2214c7a | ||
|
|
17e3ac747c | ||
|
|
9ca1cb457d | ||
|
|
b1493d6792 | ||
|
|
2f3a489a8a | ||
|
|
50d66fa2ea |
@@ -10,6 +10,14 @@ MEDIAMTX_WHIP_PUBLIC=http://${PUBLIC_HOST}:8889
|
||||
MEDIAMTX_HLS_PUBLIC=http://${PUBLIC_HOST}:8890
|
||||
BLOSSOM_URL_DEFAULT=http://${PUBLIC_HOST}:8098
|
||||
|
||||
# ICE host candidate MediaMTX advertises for WebRTC/WHIP (browser-publish
|
||||
# "stream from this browser"). If you're behind Cloudflare or similar
|
||||
# HTTP(S)-only proxy, this MUST be the raw origin IP, not PUBLIC_HOST —
|
||||
# Cloudflare never forwards raw UDP, so a proxied hostname here makes the
|
||||
# WHIP handshake succeed while media silently never arrives. Same reasoning
|
||||
# as MEDIAMTX_RTMP_PUBLIC above. Plain host/IP, no scheme or port.
|
||||
MEDIAMTX_WEBRTC_HOST=${PUBLIC_HOST}
|
||||
|
||||
# Default nostr relays for NIP-53 live-event announcements (comma separated,
|
||||
# changeable at runtime in Settings)
|
||||
NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band
|
||||
|
||||
+11
-3
@@ -35,7 +35,7 @@ services:
|
||||
- blossom
|
||||
|
||||
mediamtx:
|
||||
image: docker.io/bluenviron/mediamtx:1.19.2
|
||||
image: docker.io/bluenviron/mediamtx:1.20.0
|
||||
container_name: podsteadr-mediamtx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -44,8 +44,16 @@ services:
|
||||
- "8189:8189/udp" # WebRTC ICE
|
||||
- "8890:8888" # HLS (host 8890; 8888 kept free for other apps)
|
||||
environment:
|
||||
# Browsers need a reachable ICE host candidate; set PUBLIC_HOST in .env
|
||||
MTX_WEBRTCADDITIONALHOSTS: ${PUBLIC_HOST:-localhost}
|
||||
# Browsers need a reachable ICE host candidate for the actual UDP media
|
||||
# path (browser-publish "stream from this browser" / WHIP). This must
|
||||
# be the raw origin IP, NOT PUBLIC_HOST — Cloudflare's proxy only
|
||||
# forwards HTTP(S), never raw UDP, regardless of port (same reason
|
||||
# MEDIAMTX_RTMP_PUBLIC above uses the raw IP instead of the
|
||||
# Cloudflare-proxied domain). Using PUBLIC_HOST here means the browser
|
||||
# resolves the ICE candidate to Cloudflare's edge and the WHIP HTTP
|
||||
# handshake succeeds while media silently never arrives — set
|
||||
# MEDIAMTX_WEBRTC_HOST in .env.
|
||||
MTX_WEBRTCADDITIONALHOSTS: ${MEDIAMTX_WEBRTC_HOST:-localhost}
|
||||
volumes:
|
||||
- ./mediamtx/mediamtx.yml:/mediamtx.yml:ro
|
||||
- mediamtx-recordings:/recordings
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ Three containers on one compose network:
|
||||
| Container | Image | Host ports | Role |
|
||||
|---|---|---|---|
|
||||
| `podsteadr` | built from `Dockerfile` (node:22 + ffmpeg) | 8095 | Fastify API + built Vue UI + RSS feeds |
|
||||
| `podsteadr-mediamtx` | `bluenviron/mediamtx:1.19.2` | 1935 (RTMP), 8889 (WHIP), 8189/udp (ICE), 8890→8888 (HLS) | ingest + HLS output + recording |
|
||||
| `podsteadr-mediamtx` | `bluenviron/mediamtx:1.20.0` | 1935 (RTMP), 8889 (WHIP), 8189/udp (ICE), 8890→8888 (HLS) | ingest + HLS output + recording |
|
||||
| `podsteadr-blossom` | `ghcr.io/hzrd149/blossom-server:4` (4.4.1) | 8098→3000 | sha256-addressed media blobs |
|
||||
|
||||
Key flows:
|
||||
|
||||
@@ -24,7 +24,12 @@ export async function uploadToBlossom(
|
||||
file: File,
|
||||
sha256: string,
|
||||
onProgress?: (frac: number) => void,
|
||||
onSigning?: () => void,
|
||||
): Promise<BlossomUpload> {
|
||||
// signEvent() waits on the NIP-07 extension's own approval popup, which can
|
||||
// take an unbounded amount of time (or go unnoticed) — tell the caller so
|
||||
// the UI doesn't say "Uploading… 0%" while nothing has actually started.
|
||||
onSigning?.();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const auth = await nip07().signEvent({
|
||||
kind: 24242,
|
||||
|
||||
@@ -10,9 +10,41 @@ export async function publishWhip(
|
||||
bearer: string,
|
||||
stream: MediaStream,
|
||||
): Promise<WhipSession> {
|
||||
// MediaMTX's HLS output only muxes AV1, VP9, H265, H264, Opus, MPEG-4
|
||||
// Audio, or KLV (confirmed live in its logs: "the stream doesn't contain
|
||||
// any supported codec" — the muxer gets created then immediately
|
||||
// destroyed, so the WHIP publish itself still succeeds and the stream
|
||||
// shows as live, but hls/live/<id>/index.m3u8 permanently 404s with
|
||||
// "muxer is waiting to be created"). Browsers default to VP8 for
|
||||
// getUserMedia/getDisplayMedia video, which isn't in that list at all.
|
||||
// Reordering codec preference to H264 first didn't actually change what
|
||||
// got negotiated on a real test (RTCRtpSender.getCapabilities('video')
|
||||
// apparently didn't list H264 on that browser/machine — Chrome's H264
|
||||
// encoder is a separate downloadable component and isn't guaranteed
|
||||
// present) — so try every MediaMTX-supported codec in priority order
|
||||
// instead of only H264, and fail loudly if literally none of them are
|
||||
// available rather than silently falling back to the broken default.
|
||||
const MEDIAMTX_HLS_VIDEO_CODECS = ['video/H264', 'video/VP9', 'video/AV1'];
|
||||
|
||||
const pc = new RTCPeerConnection();
|
||||
for (const track of stream.getTracks()) {
|
||||
pc.addTransceiver(track, { direction: 'sendonly' });
|
||||
const transceiver = pc.addTransceiver(track, { direction: 'sendonly' });
|
||||
if (track.kind === 'video' && typeof transceiver.setCodecPreferences === 'function') {
|
||||
const capabilities = RTCRtpSender.getCapabilities('video');
|
||||
const available = capabilities?.codecs ?? [];
|
||||
const preferred = MEDIAMTX_HLS_VIDEO_CODECS.flatMap((mime) =>
|
||||
available.filter((c) => c.mimeType.toLowerCase() === mime.toLowerCase()),
|
||||
);
|
||||
if (preferred.length === 0) {
|
||||
pc.close();
|
||||
throw new Error(
|
||||
"This browser doesn't support any video codec MediaMTX can turn into HLS " +
|
||||
`(needs one of: ${MEDIAMTX_HLS_VIDEO_CODECS.join(', ')}). Try a different browser, or use OBS instead.`,
|
||||
);
|
||||
}
|
||||
const rest = available.filter((c) => !preferred.includes(c));
|
||||
transceiver.setCodecPreferences([...preferred, ...rest]);
|
||||
}
|
||||
}
|
||||
|
||||
const offer = await pc.createOffer();
|
||||
|
||||
@@ -17,7 +17,7 @@ const podcast = ref<PodcastSummary | null>(null);
|
||||
const creatingNew = ref(false);
|
||||
|
||||
const file = ref<File | null>(null);
|
||||
const phase = ref<'idle' | 'hashing' | 'uploading' | 'registering'>('idle');
|
||||
const phase = ref<'idle' | 'hashing' | 'signing' | 'uploading' | 'registering'>('idle');
|
||||
const progress = ref(0);
|
||||
const error = ref('');
|
||||
|
||||
@@ -31,6 +31,7 @@ const episodeUrl = ref('');
|
||||
const phaseLabel = computed(() => ({
|
||||
idle: '',
|
||||
hashing: 'Computing sha256…',
|
||||
signing: 'Waiting for your nostr extension to approve the upload — check for a popup (it may be behind this window).',
|
||||
uploading: `Uploading to Blossom… ${(progress.value * 100).toFixed(0)}%`,
|
||||
registering: 'Publishing episode…',
|
||||
}[phase.value]));
|
||||
@@ -72,9 +73,17 @@ async function publish() {
|
||||
durationSecs.value = await probeDuration(file.value);
|
||||
const sha = await sha256File(file.value);
|
||||
|
||||
phase.value = 'uploading';
|
||||
const blossomUrl = settings.public!.blossomUrl;
|
||||
await uploadToBlossom(blossomUrl, file.value, sha, (f) => (progress.value = f));
|
||||
await uploadToBlossom(
|
||||
blossomUrl,
|
||||
file.value,
|
||||
sha,
|
||||
(f) => {
|
||||
phase.value = 'uploading';
|
||||
progress.value = f;
|
||||
},
|
||||
() => (phase.value = 'signing'),
|
||||
);
|
||||
uploaded.value = { sha256: sha, size: file.value.size };
|
||||
|
||||
phase.value = 'registering';
|
||||
|
||||
+19
-7
@@ -25,13 +25,22 @@ rtmpAddress: :1935
|
||||
|
||||
hls: yes
|
||||
hlsAddress: :8888
|
||||
# Standard HLS, not lowLatency: LL-HLS's small per-part buffering window has very little
|
||||
# tolerance for B-frame reordering (common in most OBS encoder presets), and a real test
|
||||
# stream crashed the muxer twice in ~2 minutes with "too many reordered frames" / "unable to
|
||||
# extract DTS" once frame timing got even slightly irregular. Standard HLS buffers a full
|
||||
# segment before finalizing, which absorbs that jitter — a few extra seconds of latency
|
||||
# instead of intermittent muxer crashes / viewer buffering.
|
||||
hlsVariant: mpegts
|
||||
# Switched from mpegts back to lowLatency (2026-08-11): mpegts can only mux
|
||||
# H264, and browser (WHIP) publishing sends VP8/VP9 depending on the
|
||||
# machine's available encoders — confirmed live, mpegts crashed with "the
|
||||
# MPEG-TS variant of HLS supports H264 video only" for a VP9 browser stream.
|
||||
# lowLatency supports AV1/VP9/H265/H264/Opus, so it's required for browser
|
||||
# publishing to produce any HLS output at all.
|
||||
#
|
||||
# Known risk: this is the variant that was moved AWAY from earlier — LL-HLS's
|
||||
# small per-part buffering window has very little tolerance for B-frame
|
||||
# reordering (common in most OBS encoder presets), and a real OBS test
|
||||
# stream crashed the muxer twice in ~2 minutes with "too many reordered
|
||||
# frames" / "unable to extract DTS" once frame timing got even slightly
|
||||
# irregular. If that recurs, the real fix is running two MediaMTX instances
|
||||
# (mpegts for RTMP/OBS, lowLatency for WHIP/browser) since hlsVariant is a
|
||||
# global setting with no per-path override — not flipping back and forth.
|
||||
hlsVariant: lowLatency
|
||||
hlsAlwaysRemux: yes
|
||||
hlsAllowOrigins: ["*"]
|
||||
|
||||
@@ -39,6 +48,9 @@ webrtc: yes
|
||||
webrtcAddress: :8889
|
||||
webrtcLocalUDPAddress: :8189
|
||||
webrtcAllowOrigins: ["*"]
|
||||
# webrtcAdditionalHosts is set via MTX_WEBRTCADDITIONALHOSTS in
|
||||
# docker-compose.yml (MEDIAMTX_WEBRTC_HOST in .env) — see the comment there
|
||||
# for why it must be the raw IP, not the Cloudflare-proxied domain.
|
||||
|
||||
# ---- recording -----------------------------------------------------------
|
||||
pathDefaults:
|
||||
|
||||
Reference in New Issue
Block a user