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
|
MEDIAMTX_HLS_PUBLIC=http://${PUBLIC_HOST}:8890
|
||||||
BLOSSOM_URL_DEFAULT=http://${PUBLIC_HOST}:8098
|
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,
|
# Default nostr relays for NIP-53 live-event announcements (comma separated,
|
||||||
# changeable at runtime in Settings)
|
# changeable at runtime in Settings)
|
||||||
NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band
|
NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band
|
||||||
|
|||||||
+11
-3
@@ -35,7 +35,7 @@ services:
|
|||||||
- blossom
|
- blossom
|
||||||
|
|
||||||
mediamtx:
|
mediamtx:
|
||||||
image: docker.io/bluenviron/mediamtx:1.19.2
|
image: docker.io/bluenviron/mediamtx:1.20.0
|
||||||
container_name: podsteadr-mediamtx
|
container_name: podsteadr-mediamtx
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -44,8 +44,16 @@ services:
|
|||||||
- "8189:8189/udp" # WebRTC ICE
|
- "8189:8189/udp" # WebRTC ICE
|
||||||
- "8890:8888" # HLS (host 8890; 8888 kept free for other apps)
|
- "8890:8888" # HLS (host 8890; 8888 kept free for other apps)
|
||||||
environment:
|
environment:
|
||||||
# Browsers need a reachable ICE host candidate; set PUBLIC_HOST in .env
|
# Browsers need a reachable ICE host candidate for the actual UDP media
|
||||||
MTX_WEBRTCADDITIONALHOSTS: ${PUBLIC_HOST:-localhost}
|
# 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:
|
volumes:
|
||||||
- ./mediamtx/mediamtx.yml:/mediamtx.yml:ro
|
- ./mediamtx/mediamtx.yml:/mediamtx.yml:ro
|
||||||
- mediamtx-recordings:/recordings
|
- mediamtx-recordings:/recordings
|
||||||
|
|||||||
+1
-1
@@ -44,7 +44,7 @@ Three containers on one compose network:
|
|||||||
| Container | Image | Host ports | Role |
|
| Container | Image | Host ports | Role |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `podsteadr` | built from `Dockerfile` (node:22 + ffmpeg) | 8095 | Fastify API + built Vue UI + RSS feeds |
|
| `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 |
|
| `podsteadr-blossom` | `ghcr.io/hzrd149/blossom-server:4` (4.4.1) | 8098→3000 | sha256-addressed media blobs |
|
||||||
|
|
||||||
Key flows:
|
Key flows:
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ export async function uploadToBlossom(
|
|||||||
file: File,
|
file: File,
|
||||||
sha256: string,
|
sha256: string,
|
||||||
onProgress?: (frac: number) => void,
|
onProgress?: (frac: number) => void,
|
||||||
|
onSigning?: () => void,
|
||||||
): Promise<BlossomUpload> {
|
): 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 now = Math.floor(Date.now() / 1000);
|
||||||
const auth = await nip07().signEvent({
|
const auth = await nip07().signEvent({
|
||||||
kind: 24242,
|
kind: 24242,
|
||||||
|
|||||||
@@ -10,9 +10,41 @@ export async function publishWhip(
|
|||||||
bearer: string,
|
bearer: string,
|
||||||
stream: MediaStream,
|
stream: MediaStream,
|
||||||
): Promise<WhipSession> {
|
): 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();
|
const pc = new RTCPeerConnection();
|
||||||
for (const track of stream.getTracks()) {
|
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();
|
const offer = await pc.createOffer();
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const podcast = ref<PodcastSummary | null>(null);
|
|||||||
const creatingNew = ref(false);
|
const creatingNew = ref(false);
|
||||||
|
|
||||||
const file = ref<File | null>(null);
|
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 progress = ref(0);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ const episodeUrl = ref('');
|
|||||||
const phaseLabel = computed(() => ({
|
const phaseLabel = computed(() => ({
|
||||||
idle: '',
|
idle: '',
|
||||||
hashing: 'Computing sha256…',
|
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)}%`,
|
uploading: `Uploading to Blossom… ${(progress.value * 100).toFixed(0)}%`,
|
||||||
registering: 'Publishing episode…',
|
registering: 'Publishing episode…',
|
||||||
}[phase.value]));
|
}[phase.value]));
|
||||||
@@ -72,9 +73,17 @@ async function publish() {
|
|||||||
durationSecs.value = await probeDuration(file.value);
|
durationSecs.value = await probeDuration(file.value);
|
||||||
const sha = await sha256File(file.value);
|
const sha = await sha256File(file.value);
|
||||||
|
|
||||||
phase.value = 'uploading';
|
|
||||||
const blossomUrl = settings.public!.blossomUrl;
|
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 };
|
uploaded.value = { sha256: sha, size: file.value.size };
|
||||||
|
|
||||||
phase.value = 'registering';
|
phase.value = 'registering';
|
||||||
|
|||||||
+19
-7
@@ -25,13 +25,22 @@ rtmpAddress: :1935
|
|||||||
|
|
||||||
hls: yes
|
hls: yes
|
||||||
hlsAddress: :8888
|
hlsAddress: :8888
|
||||||
# Standard HLS, not lowLatency: LL-HLS's small per-part buffering window has very little
|
# Switched from mpegts back to lowLatency (2026-08-11): mpegts can only mux
|
||||||
# tolerance for B-frame reordering (common in most OBS encoder presets), and a real test
|
# H264, and browser (WHIP) publishing sends VP8/VP9 depending on the
|
||||||
# stream crashed the muxer twice in ~2 minutes with "too many reordered frames" / "unable to
|
# machine's available encoders — confirmed live, mpegts crashed with "the
|
||||||
# extract DTS" once frame timing got even slightly irregular. Standard HLS buffers a full
|
# MPEG-TS variant of HLS supports H264 video only" for a VP9 browser stream.
|
||||||
# segment before finalizing, which absorbs that jitter — a few extra seconds of latency
|
# lowLatency supports AV1/VP9/H265/H264/Opus, so it's required for browser
|
||||||
# instead of intermittent muxer crashes / viewer buffering.
|
# publishing to produce any HLS output at all.
|
||||||
hlsVariant: mpegts
|
#
|
||||||
|
# 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
|
hlsAlwaysRemux: yes
|
||||||
hlsAllowOrigins: ["*"]
|
hlsAllowOrigins: ["*"]
|
||||||
|
|
||||||
@@ -39,6 +48,9 @@ webrtc: yes
|
|||||||
webrtcAddress: :8889
|
webrtcAddress: :8889
|
||||||
webrtcLocalUDPAddress: :8189
|
webrtcLocalUDPAddress: :8189
|
||||||
webrtcAllowOrigins: ["*"]
|
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 -----------------------------------------------------------
|
# ---- recording -----------------------------------------------------------
|
||||||
pathDefaults:
|
pathDefaults:
|
||||||
|
|||||||
Reference in New Issue
Block a user