diff --git a/cli/stream-mac.sh b/cli/stream-mac.sh index 742af436..fab10cc2 100755 --- a/cli/stream-mac.sh +++ b/cli/stream-mac.sh @@ -22,7 +22,7 @@ if [ -z "$STREAM_KEY" ]; then fi exec ffmpeg -f avfoundation -capture_cursor 1 -framerate 60 -i "2:1" \ - -c:v h264_videotoolbox -b:v 30000k -maxrate 45000k -bufsize 90000k \ + -c:v h264_videotoolbox -b:v 50000k -maxrate 100000k -bufsize 200000k \ -profile:v high -pix_fmt yuv420p \ -g 120 -keyint_min 120 \ -c:a aac -b:a 256k -ar 48000 -ac 2 \ diff --git a/cli/stream/Sources/stream-capture/main.swift b/cli/stream/Sources/stream-capture/main.swift index d6937b93..f308bc75 100644 --- a/cli/stream/Sources/stream-capture/main.swift +++ b/cli/stream/Sources/stream-capture/main.swift @@ -388,13 +388,13 @@ class HardwareEncoder { let baseWidth = 2560 let baseHeight = 1440 let baseFrameRate = 60 - let baseBitrate = 30_000_000 + let baseBitrate = 50_000_000 let pixels = max(1, width) * max(1, height) let basePixels = baseWidth * baseHeight let fpsScale = Double(max(frameRate, 1)) / Double(baseFrameRate) let raw = Double(baseBitrate) * (Double(pixels) / Double(basePixels)) * fpsScale - return min(max(Int(raw.rounded()), 12_000_000), 80_000_000) + return min(max(Int(raw.rounded()), 12_000_000), 120_000_000) } deinit { diff --git a/packages/web/drizzle/0003_add_webrtc_url_to_streams.sql b/packages/web/drizzle/0003_add_webrtc_url_to_streams.sql new file mode 100644 index 00000000..cf72bfa5 --- /dev/null +++ b/packages/web/drizzle/0003_add_webrtc_url_to_streams.sql @@ -0,0 +1,2 @@ +ALTER TABLE "streams" ADD COLUMN "webrtc_url" text; +--> statement-breakpoint diff --git a/packages/web/drizzle/meta/_journal.json b/packages/web/drizzle/meta/_journal.json index 7f528a42..b875be4a 100644 --- a/packages/web/drizzle/meta/_journal.json +++ b/packages/web/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1765916542205, "tag": "0002_uneven_the_renegades", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1766456542436, + "tag": "0003_add_webrtc_url_to_streams", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/web/src/components/WebRTCPlayer.tsx b/packages/web/src/components/WebRTCPlayer.tsx index d036ded3..0b382c17 100644 --- a/packages/web/src/components/WebRTCPlayer.tsx +++ b/packages/web/src/components/WebRTCPlayer.tsx @@ -209,6 +209,7 @@ export function WebRTCPlayer({ method: "POST", headers: { "content-type": "application/sdp", + accept: "application/sdp", }, body: localSdp, signal: abortController.signal, diff --git a/packages/web/src/lib/stream/playback.ts b/packages/web/src/lib/stream/playback.ts index 846a7583..e0fc790b 100644 --- a/packages/web/src/lib/stream/playback.ts +++ b/packages/web/src/lib/stream/playback.ts @@ -13,6 +13,7 @@ type PlaybackInput = { webrtcUrl?: string | null cloudflareUid?: string | null cloudflareCustomerCode?: string | null + preferWebRtc?: boolean } export function resolveStreamPlayback({ @@ -20,24 +21,25 @@ export function resolveStreamPlayback({ webrtcUrl, cloudflareUid, cloudflareCustomerCode, + preferWebRtc = true, }: PlaybackInput): StreamPlayback | null { - if (webrtcUrl) { - return { type: "webrtc", url: webrtcUrl } + const cloudflare = resolveCloudflareStreamRef({ + hlsUrl, + cloudflareUid, + cloudflareCustomerCode, + }) + + const resolvedWebRtcUrl = preferWebRtc + ? resolveWebRtcUrl({ + webrtcUrl, + cloudflare, + }) + : null + + if (resolvedWebRtcUrl) { + return { type: "webrtc", url: resolvedWebRtcUrl } } - if (cloudflareUid) { - return { - type: "cloudflare", - uid: cloudflareUid, - customerCode: cloudflareCustomerCode ?? undefined, - } - } - - if (!hlsUrl) { - return null - } - - const cloudflare = parseCloudflareStreamUrl(hlsUrl) if (cloudflare) { return { type: "cloudflare", @@ -46,6 +48,10 @@ export function resolveStreamPlayback({ } } + if (!hlsUrl) { + return null + } + return { type: "hls", url: hlsUrl } } @@ -82,3 +88,55 @@ export function parseCloudflareStreamUrl(url: string): CloudflareStreamRef | nul return { uid, customerCode } } + +type CloudflareResolveInput = { + hlsUrl?: string | null + cloudflareUid?: string | null + cloudflareCustomerCode?: string | null +} + +export function resolveCloudflareStreamRef({ + hlsUrl, + cloudflareUid, + cloudflareCustomerCode, +}: CloudflareResolveInput): CloudflareStreamRef | null { + if (cloudflareUid) { + return { + uid: cloudflareUid, + customerCode: cloudflareCustomerCode ?? undefined, + } + } + + if (!hlsUrl) { + return null + } + + return parseCloudflareStreamUrl(hlsUrl) +} + +export function buildCloudflareWhepUrl(ref: CloudflareStreamRef): string { + if (ref.customerCode) { + return `https://customer-${ref.customerCode}.cloudflarestream.com/${ref.uid}/whep` + } + return `https://videodelivery.net/${ref.uid}/whep` +} + +type WebRtcResolveInput = { + webrtcUrl?: string | null + cloudflare: CloudflareStreamRef | null +} + +export function resolveWebRtcUrl({ + webrtcUrl, + cloudflare, +}: WebRtcResolveInput): string | null { + if (webrtcUrl) { + return webrtcUrl + } + + if (!cloudflare) { + return null + } + + return buildCloudflareWhepUrl(cloudflare) +} diff --git a/packages/web/src/routes/$username.tsx b/packages/web/src/routes/$username.tsx index 5051d283..30ca8791 100644 --- a/packages/web/src/routes/$username.tsx +++ b/packages/web/src/routes/$username.tsx @@ -111,7 +111,11 @@ function StreamPage() { const stream = data?.stream ?? null const playback = stream?.playback ?? null const fallbackPlayback = stream?.hls_url - ? resolveStreamPlayback({ hlsUrl: stream.hls_url, webrtcUrl: null }) + ? resolveStreamPlayback({ + hlsUrl: stream.hls_url, + webrtcUrl: null, + preferWebRtc: false, + }) : null const activePlayback = playback?.type === "webrtc" && webRtcFailed @@ -179,7 +183,6 @@ function StreamPage() { ) } - const { user } = data const showPlayer = activePlayback?.type === "cloudflare" || activePlayback?.type === "webrtc" || diff --git a/packages/web/src/routes/api/profile.ts b/packages/web/src/routes/api/profile.ts index 319d8f8c..69387166 100644 --- a/packages/web/src/routes/api/profile.ts +++ b/packages/web/src/routes/api/profile.ts @@ -4,7 +4,11 @@ import { getDb } from "@/db/connection" import { users, streams } from "@/db/schema" import { getAuth } from "@/lib/auth" import { randomUUID } from "crypto" -import { resolveStreamPlayback } from "@/lib/stream/playback" +import { + resolveCloudflareStreamRef, + resolveStreamPlayback, + resolveWebRtcUrl, +} from "@/lib/stream/playback" const resolveDatabaseUrl = (request: Request) => { try { @@ -49,6 +53,12 @@ const getProfile = async ({ request }: { request: Request }) => { where: eq(streams.user_id, user.id), }) + const cloudflare = stream + ? resolveCloudflareStreamRef({ hlsUrl: stream.hls_url }) + : null + const webRtcUrl = stream + ? resolveWebRtcUrl({ webrtcUrl: stream.webrtc_url, cloudflare }) + : null const playback = stream ? resolveStreamPlayback({ hlsUrl: stream.hls_url, @@ -69,7 +79,7 @@ const getProfile = async ({ request }: { request: Request }) => { title: stream.title, is_live: stream.is_live, hls_url: stream.hls_url, - webrtc_url: stream.webrtc_url, + webrtc_url: webRtcUrl, playback, stream_key: stream.stream_key, } diff --git a/packages/web/src/routes/api/stream.ts b/packages/web/src/routes/api/stream.ts index 0de0a340..6365c32e 100644 --- a/packages/web/src/routes/api/stream.ts +++ b/packages/web/src/routes/api/stream.ts @@ -3,7 +3,11 @@ import { eq } from "drizzle-orm" import { getDb } from "@/db/connection" import { streams } from "@/db/schema" import { getAuth } from "@/lib/auth" -import { resolveStreamPlayback } from "@/lib/stream/playback" +import { + resolveCloudflareStreamRef, + resolveStreamPlayback, + resolveWebRtcUrl, +} from "@/lib/stream/playback" const resolveDatabaseUrl = (request: Request) => { try { @@ -43,15 +47,23 @@ const getStream = async ({ request }: { request: Request }) => { }) } + const cloudflare = resolveCloudflareStreamRef({ hlsUrl: stream.hls_url }) + const webRtcUrl = resolveWebRtcUrl({ + webrtcUrl: stream.webrtc_url, + cloudflare, + }) const playback = resolveStreamPlayback({ hlsUrl: stream.hls_url, webrtcUrl: stream.webrtc_url, }) - return new Response(JSON.stringify({ ...stream, playback }), { - status: 200, - headers: { "content-type": "application/json" }, - }) + return new Response( + JSON.stringify({ ...stream, webrtc_url: webRtcUrl, playback }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ) } catch (error) { console.error("Stream GET error:", error) return new Response(JSON.stringify({ error: "Internal server error" }), { diff --git a/packages/web/src/routes/api/streams.$username.ts b/packages/web/src/routes/api/streams.$username.ts index 9ae576bd..93342d9e 100644 --- a/packages/web/src/routes/api/streams.$username.ts +++ b/packages/web/src/routes/api/streams.$username.ts @@ -2,7 +2,11 @@ import { createFileRoute } from "@tanstack/react-router" import { eq } from "drizzle-orm" import { getDb } from "@/db/connection" import { users, streams } from "@/db/schema" -import { resolveStreamPlayback } from "@/lib/stream/playback" +import { + resolveCloudflareStreamRef, + resolveStreamPlayback, + resolveWebRtcUrl, +} from "@/lib/stream/playback" const resolveDatabaseUrl = (request: Request) => { try { @@ -59,6 +63,12 @@ const serve = async ({ where: eq(streams.user_id, user.id), }) + const cloudflare = stream + ? resolveCloudflareStreamRef({ hlsUrl: stream.hls_url }) + : null + const webRtcUrl = stream + ? resolveWebRtcUrl({ webrtcUrl: stream.webrtc_url, cloudflare }) + : null const playback = stream ? resolveStreamPlayback({ hlsUrl: stream.hls_url, @@ -81,7 +91,7 @@ const serve = async ({ is_live: stream.is_live, viewer_count: stream.viewer_count, hls_url: stream.hls_url, - webrtc_url: stream.webrtc_url, + webrtc_url: webRtcUrl, playback, thumbnail_url: stream.thumbnail_url, started_at: stream.started_at?.toISOString() ?? null,