Live video on the AT Protocol

add livepeer transcoding part 2!

See merge request streamplace/streamplace!122

Changelog: feature

Eli Streams ddef4c96 b14d5862

+3246 -1227
+15 -4
Makefile
··· 187 187 mkdir -p .build \ 188 188 && curl -L -o ./.build/bundletool.jar https://github.com/google/bundletool/releases/download/1.17.0/bundletool-all-1.17.0.jar 189 189 190 - OPTS = -D "gst-plugins-base:audioresample=enabled" \ 190 + OPTS = \ 191 + --buildtype=debugoptimized \ 192 + -D "gst-plugins-base:audioresample=enabled" \ 191 193 -D "gst-plugins-base:playback=enabled" \ 192 194 -D "gst-plugins-base:opus=enabled" \ 193 195 -D "gst-plugins-base:gio-typefinder=enabled" \ ··· 197 199 -D "gst-plugins-base:compositor=enabled" \ 198 200 -D "gst-plugins-base:videorate=enabled" \ 199 201 -D "gst-plugins-base:app=enabled" \ 202 + -D "gst-plugins-base:audiorate=enabled" \ 200 203 -D "gst-plugins-base:audiotestsrc=enabled" \ 201 204 -D "gst-plugins-base:audioconvert=enabled" \ 202 205 -D "gst-plugins-good:matroska=enabled" \ ··· 211 214 -D "gst-plugins-good:audioparsers=enabled" \ 212 215 -D "gst-plugins-bad:videoparsers=enabled" \ 213 216 -D "gst-plugins-bad:mpegtsmux=enabled" \ 217 + -D "gst-plugins-bad:mpegtsdemux=enabled" \ 214 218 -D "gst-plugins-bad:codectimestamper=enabled" \ 215 219 -D "gst-plugins-bad:opus=enabled" \ 216 220 -D "gst-plugins-ugly:x264=enabled" \ 217 221 -D "gst-plugins-ugly:gpl=enabled" \ 218 222 -D "x264:asm=enabled" \ 219 223 -D "gstreamer-full:gst-full=enabled" \ 220 - -D "gstreamer-full:gst-full-plugins=libgstopusparse.a;libgstcodectimestamper.a;libgstrtp.a;libgstaudioresample.a;libgstlibav.a;libgstmatroska.a;libgstmultifile.a;libgstjpeg.a;libgstaudiotestsrc.a;libgstaudioconvert.a;libgstaudioparsers.a;libgstfdkaac.a;libgstisomp4.a;libgstapp.a;libgstvideoconvertscale.a;libgstvideobox.a;libgstvideorate.a;libgstpng.a;libgstcompositor.a;libgstx264.a;libgstopus.a;libgstvideotestsrc.a;libgstvideoparsersbad.a;libgstaudioparsers.a;libgstmpegtsmux.a;libgstplayback.a;libgsttypefindfunctions.a" \ 224 + -D "gstreamer-full:gst-full-plugins=libgstopusparse.a;libgstcodectimestamper.a;libgstrtp.a;libgstaudioresample.a;libgstlibav.a;libgstmatroska.a;libgstmultifile.a;libgstjpeg.a;libgstaudiotestsrc.a;libgstaudioconvert.a;libgstaudioparsers.a;libgstfdkaac.a;libgstisomp4.a;libgstapp.a;libgstvideoconvertscale.a;libgstvideobox.a;libgstvideorate.a;libgstpng.a;libgstcompositor.a;libgstaudiorate.a;libgstx264.a;libgstopus.a;libgstvideotestsrc.a;libgstvideoparsersbad.a;libgstaudioparsers.a;libgstmpegtsmux.a;libgstmpegtsdemux.a;libgstplayback.a;libgsttypefindfunctions.a" \ 221 225 -D "gstreamer-full:gst-full-libraries=gstreamer-controller-1.0,gstreamer-plugins-base-1.0,gstreamer-pbutils-1.0" \ 222 226 -D "gstreamer-full:gst-full-target-type=static_library" \ 223 - -D "gstreamer-full:gst-full-elements=coreelements:concat,filesrc,queue,queue2,multiqueue,typefind,tee,capsfilter,fakesink,identity" \ 227 + -D "gstreamer-full:gst-full-elements=coreelements:concat,filesrc,filesink,queue,queue2,multiqueue,typefind,tee,capsfilter,fakesink,identity" \ 224 228 -D "gstreamer-full:bad=enabled" \ 225 229 -D "gstreamer-full:tls=disabled" \ 226 230 -D "gstreamer-full:libav=enabled" \ 227 231 -D "gstreamer-full:ugly=enabled" \ 228 232 -D "gstreamer-full:gpl=enabled" \ 229 - -D "gstreamer-full:gst-full-typefind-functions=" 233 + -D "gstreamer-full:gst-full-typefind-functions=" \ 234 + -D "gstreamer-full:glib_assert=false" \ 235 + -D "gstreamer:glib_assert=false" \ 236 + -D "gst-plugins-good:glib_assert=false" \ 237 + -D "gst-plugins-bad:glib_assert=false" \ 238 + -D "gst-plugins-base:glib_assert=false" \ 239 + -D "gst-plugins-ugly:glib_assert=false" \ 240 + -D "glib:glib_assert=false" 230 241 231 242 .PHONY: meson-setup 232 243 meson-setup:
+1 -2
js/app/components/livestream/livestream.tsx
··· 30 30 const telemetry = useAppSelector(selectTelemetry); 31 31 const player = useAppSelector(usePlayer()); 32 32 33 - const { src, protocol, ...extraProps } = props; 33 + const { src, ...extraProps } = props; 34 34 const dispatch = useAppDispatch(); 35 35 const { width, height } = useWindowDimensions(); 36 36 const video = player.segment?.video?.[0]; ··· 147 147 <Player 148 148 telemetry={telemetry === true} 149 149 src={src} 150 - forceProtocol={protocol} 151 150 {...extraProps} 152 151 /> 153 152 <View
+117 -29
js/app/components/player/controls.tsx
··· 9 9 Settings, 10 10 Shell, 11 11 Sparkle, 12 - Squirrel, 13 12 Star, 14 13 Volume2, 15 14 VolumeX, 16 15 } from "@tamagui/lucide-icons"; 17 - import { useEffect, useRef, useState } from "react"; 16 + import { Dispatch, Fragment, useEffect, useRef, useState } from "react"; 18 17 import { Animated, Pressable } from "react-native"; 19 18 import { 20 19 Button, ··· 32 31 H5, 33 32 Paragraph, 34 33 } from "tamagui"; 35 - import { 36 - PlayerProps, 37 - PROTOCOL_HLS, 38 - PROTOCOL_PROGRESSIVE_MP4, 39 - PROTOCOL_PROGRESSIVE_WEBM, 40 - PROTOCOL_WEBRTC, 41 - } from "./props"; 34 + import { PlayerProps, PROTOCOL_HLS, PROTOCOL_WEBRTC } from "./props"; 42 35 import { 43 36 usePlayer, 44 37 usePlayerActions, 38 + usePlayerProtocol, 39 + usePlayerRenditions, 45 40 usePlayerSegment, 41 + usePlayerSelectedRendition, 46 42 } from "features/player/playerSlice"; 47 43 import { useAppDispatch, useAppSelector } from "store/hooks"; 48 44 import Loading from "components/loading/loading"; 49 45 import Viewers from "components/viewers"; 50 46 import { userMute } from "features/streamplace/streamplaceSlice"; 51 47 import { Countdown } from "components/countdown"; 48 + import { Rendition } from "lexicons/types/place/stream/defs"; 52 49 53 50 const Bar = (props) => ( 54 51 <XStack ··· 182 179 export function PopoverMenu(props: PlayerProps) { 183 180 const [open, setOpen] = useState(false); 184 181 const media = useMedia(); 182 + const renditions = useAppSelector(usePlayerRenditions()); 183 + const selectedRendition = useAppSelector(usePlayerSelectedRendition()); 184 + const protocol = useAppSelector(usePlayerProtocol()); 185 + const { setSelectedRendition, setProtocol } = usePlayerActions(); 186 + const dispatch = useAppDispatch(); 187 + // on android, this appears to lose its context. idk. so we just pass everything through. 188 + const gearMenu = ( 189 + <GearMenu 190 + {...props} 191 + renditions={renditions} 192 + selectedRendition={selectedRendition ?? "source"} 193 + protocol={protocol} 194 + setSelectedRendition={setSelectedRendition} 195 + setProtocol={setProtocol} 196 + dispatch={dispatch} 197 + /> 198 + ); 185 199 useEffect(() => { 186 200 if (!media.sm && props.showControls === false) { 187 201 setOpen(false); ··· 211 225 212 226 <Adapt when="sm" platform="touch"> 213 227 <Popover.Sheet modal dismissOnSnapToBottom snapPoints={[50]}> 214 - <Popover.Sheet.Frame padding="$2"> 215 - <GearMenu {...props} /> 216 - </Popover.Sheet.Frame> 228 + <Popover.Sheet.Frame padding="$2">{gearMenu}</Popover.Sheet.Frame> 217 229 <Popover.Sheet.Overlay 218 230 animation="lazy" 219 231 enterStyle={{ opacity: 0 }} ··· 238 250 }, 239 251 ]} 240 252 > 241 - <GearMenu {...props} /> 253 + {gearMenu} 242 254 </Popover.Content> 243 255 </Popover> 244 256 ); ··· 296 308 return <Loading />; 297 309 } 298 310 299 - function GearMenu(props: PlayerProps) { 311 + function GearMenu( 312 + props: PlayerProps & { 313 + renditions: Rendition[]; 314 + selectedRendition: string; 315 + protocol: string; 316 + setSelectedRendition: (rendition: string) => void; 317 + setProtocol: (protocol: string) => void; 318 + dispatch: Dispatch<any>; 319 + }, 320 + ) { 300 321 const [menu, setMenu] = useState("root"); 322 + const { 323 + renditions, 324 + selectedRendition, 325 + protocol, 326 + setSelectedRendition, 327 + setProtocol, 328 + dispatch, 329 + } = props; 330 + 301 331 return ( 302 332 <YGroup alignSelf="center" bordered width={240} size="$5" borderRadius="$0"> 303 333 {menu == "root" && ( ··· 319 349 hoverTheme 320 350 pressTheme 321 351 title="Quality" 322 - subTitle="WIP" 352 + subTitle="Adjust bandwidth usage" 323 353 icon={Sparkle} 324 354 iconAfter={ChevronRight} 355 + onPress={() => setMenu("quality")} 325 356 /> 326 357 </YGroup.Item> 327 358 </> ··· 345 376 title="HLS" 346 377 subTitle="HTTP Live Streaming" 347 378 icon={Star} 348 - iconAfter={props.protocol === PROTOCOL_HLS ? CheckCircle : Circle} 349 - onPress={() => props.setProtocol(PROTOCOL_HLS)} 379 + iconAfter={protocol === PROTOCOL_HLS ? CheckCircle : Circle} 380 + onPress={() => dispatch(setProtocol(PROTOCOL_HLS))} 350 381 /> 351 382 </YGroup.Item> 352 - <Separator /> 383 + {/* <Separator /> 353 384 <YGroup.Item> 354 385 <ListItem 355 386 hoverTheme ··· 358 389 subTitle="MP4 but loooong" 359 390 icon={Shell} 360 391 iconAfter={ 361 - props.protocol === PROTOCOL_PROGRESSIVE_MP4 362 - ? CheckCircle 363 - : Circle 392 + protocol === PROTOCOL_PROGRESSIVE_MP4 ? CheckCircle : Circle 364 393 } 365 - onPress={() => props.setProtocol(PROTOCOL_PROGRESSIVE_MP4)} 394 + onPress={() => dispatch(setProtocol(PROTOCOL_PROGRESSIVE_MP4))} 366 395 /> 367 396 </YGroup.Item> 368 397 <Separator /> ··· 374 403 subTitle="WebM but loooong" 375 404 icon={Squirrel} 376 405 iconAfter={ 377 - props.protocol === PROTOCOL_PROGRESSIVE_WEBM 378 - ? CheckCircle 379 - : Circle 406 + protocol === PROTOCOL_PROGRESSIVE_WEBM ? CheckCircle : Circle 380 407 } 381 - onPress={() => props.setProtocol(PROTOCOL_PROGRESSIVE_WEBM)} 408 + onPress={() => dispatch(setProtocol(PROTOCOL_PROGRESSIVE_WEBM))} 382 409 /> 383 - </YGroup.Item> 410 + </YGroup.Item> */} 384 411 <Separator /> 385 412 <YGroup.Item> 386 413 <ListItem ··· 389 416 title="WebRTC" 390 417 subTitle="Lowest latency, probably" 391 418 icon={Antenna} 419 + iconAfter={protocol === PROTOCOL_WEBRTC ? CheckCircle : Circle} 420 + onPress={() => dispatch(setProtocol(PROTOCOL_WEBRTC))} 421 + /> 422 + </YGroup.Item> 423 + </> 424 + )} 425 + {menu == "quality" && ( 426 + <> 427 + <YGroup.Item> 428 + <ListItem 429 + hoverTheme 430 + pressTheme 431 + title="Back" 432 + icon={ChevronLeft} 433 + onPress={() => setMenu("root")} 434 + /> 435 + </YGroup.Item> 436 + <Separator /> 437 + {protocol === PROTOCOL_HLS && ( 438 + <> 439 + <YGroup.Item> 440 + <ListItem 441 + hoverTheme 442 + pressTheme 443 + title="Auto" 444 + subTitle="Automatic with HLS" 445 + icon={Star} 446 + iconAfter={ 447 + props.selectedRendition === "auto" ? CheckCircle : Circle 448 + } 449 + onPress={() => dispatch(setSelectedRendition("auto"))} 450 + /> 451 + </YGroup.Item> 452 + <Separator /> 453 + </> 454 + )} 455 + <YGroup.Item> 456 + <ListItem 457 + hoverTheme 458 + pressTheme 459 + title="Source" 460 + subTitle="Original quality" 461 + icon={Star} 392 462 iconAfter={ 393 - props.protocol === PROTOCOL_WEBRTC ? CheckCircle : Circle 463 + props.selectedRendition === "source" ? CheckCircle : Circle 394 464 } 395 - onPress={() => props.setProtocol(PROTOCOL_WEBRTC)} 465 + onPress={() => dispatch(setSelectedRendition("source"))} 396 466 /> 397 467 </YGroup.Item> 468 + {renditions.map((rendition) => ( 469 + <Fragment key={rendition.name}> 470 + <Separator /> 471 + <YGroup.Item> 472 + <ListItem 473 + hoverTheme 474 + pressTheme 475 + title={rendition.name} 476 + subTitle={rendition.name} 477 + icon={Shell} 478 + iconAfter={ 479 + selectedRendition === rendition.name ? CheckCircle : Circle 480 + } 481 + onPress={() => dispatch(setSelectedRendition(rendition.name))} 482 + /> 483 + </YGroup.Item> 484 + </Fragment> 485 + ))} 398 486 </> 399 487 )} 400 488 </YGroup>
+10 -18
js/app/components/player/player.tsx
··· 10 10 PlayerProps, 11 11 PlayerStatus, 12 12 PlayerStatusTracker, 13 - PROTOCOL_WEBRTC, 14 13 } from "./props"; 15 14 import PlayerProvider from "./provider"; 16 15 import { selectUserMuted } from "features/streamplace/streamplaceSlice"; 17 16 import { useAppSelector } from "store/hooks"; 18 - import { usePlayerSegment } from "features/player/playerSlice"; 17 + import { 18 + usePlayerRenditions, 19 + usePlayerSegment, 20 + usePlayerSelectedRendition, 21 + } from "features/player/playerSlice"; 19 22 20 23 const HIDE_CONTROLS_AFTER = 2000; 21 24 const OFFLINE_THRESHOLD = 10000; ··· 55 58 setTouchTime(Date.now()); 56 59 setShowControls(true); 57 60 }; 58 - // keeping this other logic for now in case we need a second-best choice 59 - let defProto = PROTOCOL_WEBRTC; 60 - // const plat = usePlatform(); 61 - // if (plat.isIOS) { 62 - // defProto = PROTOCOL_HLS; 63 - // } else if (plat.isSafari) { 64 - // defProto = PROTOCOL_HLS; 65 - // } else if (plat.isFirefox) { 66 - // defProto = PROTOCOL_HLS; 67 - // } 68 - if (props.forceProtocol) { 69 - defProto = props.forceProtocol; 70 - } 71 61 const { url } = useStreamplaceNode(); 72 62 const info = usePlatform(); 73 63 const playerEvent = async ( ··· 98 88 }; 99 89 const [status, setStatus] = usePlayerStatus(playerEvent); 100 90 const [playTime, setPlayTime] = useState(0); 101 - const [protocol, setProtocol] = useState(defProto); 102 91 const [fullscreen, setFullscreen] = useState(false); 103 92 104 93 const [offline, setOffline] = useState(true); ··· 107 96 const segment = useAppSelector(usePlayerSegment()); 108 97 const [lastCheck, setLastCheck] = useState(0); 109 98 99 + const renditions = useAppSelector(usePlayerRenditions()); 100 + const selectedRendition = useAppSelector(usePlayerSelectedRendition()); 101 + 110 102 useEffect(() => { 111 103 if (playing) { 112 104 setOffline(false); ··· 143 135 setFullscreen: setFullscreen, 144 136 fullscreen: fullscreen, 145 137 offline: offline, 146 - protocol: protocol, 147 - setProtocol: setProtocol, 148 138 showControls: props.showControls ?? showControls, 149 139 userInteraction: userInteraction, 150 140 playerEvent: playerEvent, ··· 154 144 setPlayTime: setPlayTime, 155 145 ingestMediaSource: props.ingestMediaSource ?? IngestMediaSource.USER, 156 146 ingestAutoStart: props.ingestAutoStart ?? false, 147 + renditions: renditions ?? [], 148 + selectedRendition: selectedRendition ?? "source", 157 149 ...props, 158 150 }; 159 151 return (
+4 -2
js/app/components/player/props.tsx
··· 1 + import { Rendition } from "lexicons/types/place/stream/defs"; 2 + 1 3 export enum IngestMediaSource { 2 4 USER = "user", 3 5 DISPLAY = "display", ··· 9 11 src: string; 10 12 muted: boolean; 11 13 fullscreen: boolean; 12 - protocol: string; 13 14 forceProtocol?: string; 14 15 showControls: boolean; 15 16 telemetry: boolean; 16 17 setMuted: (isMuted: boolean) => void; 17 18 setFullscreen: (isFullscreen: boolean) => void; 18 - setProtocol: (protocol: string) => void; 19 19 userInteraction: () => void; 20 20 playerEvent: ( 21 21 time: string, ··· 33 33 ingestAutoStart?: boolean; 34 34 avSyncTest?: boolean; 35 35 offline: boolean; 36 + renditions: Rendition[]; 37 + selectedRendition: string; 36 38 }; 37 39 38 40 export type PlayerEvent = {
+4 -1
js/app/components/player/provider.tsx
··· 37 37 if (props.playerId) { 38 38 newPlayerAction.payload.playerId = props.playerId; 39 39 } 40 - setPlayerId(newPlayerAction.payload.playerId); 40 + if (props.forceProtocol) { 41 + newPlayerAction.payload.forceProtocol = props.forceProtocol; 42 + } 41 43 dispatch(newPlayerAction); 44 + setPlayerId(newPlayerAction.payload.playerId); 42 45 }, []); 43 46 if (!playerId) { 44 47 return <></>;
+17 -10
js/app/components/player/shared.tsx
··· 15 15 webrtc: PROTOCOL_WEBRTC, 16 16 }; 17 17 18 - export function srcToUrl(props: PlayerProps): { 18 + export function srcToUrl( 19 + props: PlayerProps, 20 + protocol: string, 21 + ): { 19 22 url: string; 20 23 protocol: string; 21 24 } { ··· 34 37 } 35 38 } 36 39 let outUrl; 37 - if (props.protocol === PROTOCOL_HLS) { 38 - outUrl = `${url}/api/playback/${props.src}/hls/stream.m3u8`; 39 - } else if (props.protocol === PROTOCOL_PROGRESSIVE_MP4) { 40 + if (protocol === PROTOCOL_HLS) { 41 + if (props.selectedRendition === "auto") { 42 + outUrl = `${url}/api/playback/${props.src}/hls/index.m3u8`; 43 + } else { 44 + outUrl = `${url}/api/playback/${props.src}/hls/index.m3u8?rendition=${props.selectedRendition}`; 45 + } 46 + } else if (protocol === PROTOCOL_PROGRESSIVE_MP4) { 40 47 outUrl = `${url}/api/playback/${props.src}/stream.mp4`; 41 - } else if (props.protocol === PROTOCOL_PROGRESSIVE_WEBM) { 48 + } else if (protocol === PROTOCOL_PROGRESSIVE_WEBM) { 42 49 outUrl = `${url}/api/playback/${props.src}/stream.webm`; 43 - } else if (props.protocol === PROTOCOL_WEBRTC) { 44 - outUrl = `${url}/api/playback/${props.src}/webrtc`; 50 + } else if (protocol === PROTOCOL_WEBRTC) { 51 + outUrl = `${url}/api/playback/${props.src}/webrtc?rendition=${props.selectedRendition}`; 45 52 } else { 46 - throw new Error(`unknown playback protocol: ${props.protocol}`); 53 + throw new Error(`unknown playback protocol: ${protocol}`); 47 54 } 48 55 return { 49 - protocol: props.protocol, 56 + protocol: protocol, 50 57 url: outUrl, 51 58 }; 52 - }, [props.src, props.protocol, url]); 59 + }, [props.src, protocol, url]); 53 60 }
+1 -1
js/app/components/player/use-webrtc.tsx
··· 126 126 * This specifies how the client should communicate, 127 127 * and what kind of media client and server have negotiated to exchange. 128 128 */ 129 - let response = await postSDPOffer(endpoint, ofr.sdp, bearerToken); 129 + let response = await postSDPOffer(`${endpoint}`, ofr.sdp, bearerToken); 130 130 if (response.status === 201) { 131 131 let answerSDP = await response.text(); 132 132 if ((peerConnection.connectionState as string) === "closed") {
+8 -1
js/app/components/player/video-retry.tsx
··· 1 1 import React, { useEffect, useState } from "react"; 2 2 import { PlayerProps, PlayerStatus } from "./props"; 3 + import { usePlayerSelectedRendition } from "features/player/playerSlice"; 4 + import { useAppSelector } from "store/hooks"; 3 5 4 6 export default function VideoRetry( 5 7 props: PlayerProps & { children: React.ReactNode }, ··· 7 9 const [resetTime, setResetTime] = useState<number>(Date.now()); 8 10 const [retryCount, setRetryCount] = useState(0); 9 11 const isPlaying = props.status === PlayerStatus.PLAYING; 12 + const selectedRendition = useAppSelector(usePlayerSelectedRendition()); 10 13 11 14 useEffect(() => { 12 15 if (isPlaying) { ··· 30 33 return () => clearTimeout(handle); 31 34 }, [isPlaying, resetTime, retryCount]); 32 35 33 - return <React.Fragment key={resetTime}>{props.children}</React.Fragment>; 36 + return ( 37 + <React.Fragment key={`${selectedRendition}-${resetTime}`}> 38 + {props.children} 39 + </React.Fragment> 40 + ); 34 41 }
+6 -3
js/app/components/player/video.native.tsx
··· 6 6 import { srcToUrl } from "./shared"; 7 7 import useWebRTC from "./use-webrtc"; 8 8 import { MediaStream } from "react-native-webrtc"; 9 + import { usePlayerProtocol } from "features/player/playerSlice"; 10 + import { useAppSelector } from "store/hooks"; 9 11 10 12 // export function Player() { 11 13 // return <View f={1}></View>; ··· 14 16 export default function NativeVideo( 15 17 props: PlayerProps & { videoRef: React.RefObject<VideoView> }, 16 18 ) { 17 - if (props.protocol === PROTOCOL_WEBRTC) { 19 + const protocol = useAppSelector(usePlayerProtocol()); 20 + if (protocol === PROTOCOL_WEBRTC) { 18 21 return <NativeWHEP {...props} />; 19 22 } 20 - const { url } = srcToUrl(props); 23 + const { url } = srcToUrl(props, protocol); 21 24 useEffect(() => { 22 25 return () => { 23 26 props.setStatus(PlayerStatus.START); ··· 86 89 } 87 90 88 91 export function NativeWHEP(props: PlayerProps) { 89 - const { url } = srcToUrl(props); 92 + const { url } = srcToUrl(props, PROTOCOL_WEBRTC); 90 93 const [stream, stuck] = useWebRTC(url); 91 94 useEffect(() => { 92 95 if (stuck) {
+6 -4
js/app/components/player/video.tsx
··· 1 1 import streamKey from "components/live-dashboard/stream-key"; 2 2 import { selectStoredKey } from "features/bluesky/blueskySlice"; 3 - import { usePlayer } from "features/player/playerSlice"; 3 + import { usePlayer, usePlayerProtocol } from "features/player/playerSlice"; 4 4 import Hls from "hls.js"; 5 5 import useStreamplaceNode from "hooks/useStreamplaceNode"; 6 6 import { ··· 32 32 export default function WebVideo( 33 33 props: PlayerProps & { videoRef: RefObject<HTMLVideoElement> }, 34 34 ) { 35 - const { url, protocol } = srcToUrl(props); 35 + const inProto = useAppSelector(usePlayerProtocol()); 36 + const { url, protocol } = srcToUrl(props, inProto); 36 37 useEffect(() => { 37 38 if (props.playTime == 0) { 38 39 return; ··· 53 54 } else if (protocol === PROTOCOL_WEBRTC) { 54 55 return <WebRTCPlayer url={url} {...props} />; 55 56 } else { 56 - throw new Error(`unknown playback protocol ${props.protocol}`); 57 + throw new Error(`unknown playback protocol ${inProto}`); 57 58 } 58 59 } 59 60 ··· 192 193 return; 193 194 } 194 195 if (Hls.isSupported()) { 195 - var hls = new Hls(); 196 + // workaround for not having quite the right number of audio frames :( 197 + var hls = new Hls({ maxAudioFramesDrift: 20 }); 196 198 hls.loadSource(props.url); 197 199 try { 198 200 hls.attachMedia(videoRef.current);
+115 -15
js/app/features/player/playerSlice.tsx
··· 1 1 import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api"; 2 2 import { createAction } from "@reduxjs/toolkit"; 3 + import { PROTOCOL_HLS, PROTOCOL_WEBRTC } from "components/player/props"; 3 4 import { StreamplaceState } from "features/streamplace/streamplaceSlice"; 4 5 import { uuidv7 } from "hooks/uuid"; 5 6 import { ··· 9 10 import { createContext, useContext } from "react"; 10 11 import { createAppSlice } from "../../hooks/createSlice"; 11 12 import { Record as ChatMessageRecord } from "../../lexicons/types/place/stream/chat/message"; 12 - import { BlockView, isBlockView } from "../../lexicons/types/place/stream/defs"; 13 - 13 + import { 14 + BlockView, 15 + isBlockView, 16 + isRenditions, 17 + Rendition, 18 + } from "../../lexicons/types/place/stream/defs"; 14 19 import { 15 20 isLivestreamView, 16 21 isViewerCount, ··· 62 67 chatList: MessageViewHydrated[]; 63 68 livestream: LivestreamViewHydrated | null; 64 69 segment: Segment.Record | null; 70 + renditions: Rendition[]; 71 + selectedRendition: string | null; 72 + protocol: string; 65 73 } 66 74 67 75 export interface PlayersState { ··· 72 80 73 81 export const newPlayer = createAction("player/newPlayer", function prepare() { 74 82 return { 75 - payload: { playerId: uuidv7() }, 83 + payload: { playerId: uuidv7(), forceProtocol: PROTOCOL_WEBRTC }, 76 84 }; 77 85 }); 78 86 ··· 134 142 initialState, 135 143 136 144 extraReducers: (builder) => { 137 - builder.addCase(newPlayer, (state, action) => { 138 - state[action.payload.playerId] = { 139 - ingestStarted: null, 140 - ingestStarting: false, 141 - ingestConnectionState: null, 142 - viewers: null, 143 - chat: {}, 144 - chatList: [], 145 - livestream: null, 146 - segment: null, 147 - }; 148 - }); 145 + builder.addCase( 146 + newPlayer, 147 + ( 148 + state, 149 + action: { payload: { playerId: string; forceProtocol: string } }, 150 + ) => { 151 + state[action.payload.playerId] = { 152 + ingestStarted: null, 153 + ingestStarting: false, 154 + ingestConnectionState: null, 155 + viewers: null, 156 + protocol: action.payload.forceProtocol ?? PROTOCOL_WEBRTC, 157 + chat: {}, 158 + chatList: [], 159 + livestream: null, 160 + segment: null, 161 + renditions: [], 162 + selectedRendition: "source", 163 + }; 164 + }, 165 + ); 149 166 }, 150 167 151 168 reducers: (create) => { ··· 240 257 [], 241 258 [block], 242 259 ), 260 + }; 261 + } else if (isRenditions(message)) { 262 + state = { 263 + ...state, 264 + [action.payload.playerId]: { 265 + ...state[action.payload.playerId], 266 + renditions: message.renditions, 267 + }, 243 268 }; 244 269 } 245 270 } ··· 377 402 }, 378 403 }, 379 404 ), 405 + 406 + setSelectedRendition: create.reducer( 407 + ( 408 + state, 409 + action: { 410 + payload: { playerId: string; rendition: string }; 411 + type: string; 412 + }, 413 + ) => { 414 + return { 415 + ...state, 416 + [action.payload.playerId]: { 417 + ...state[action.payload.playerId], 418 + selectedRendition: action.payload.rendition, 419 + }, 420 + }; 421 + }, 422 + ), 423 + 424 + setProtocol: create.reducer( 425 + ( 426 + state, 427 + action: { 428 + payload: { playerId: string; protocol: string }; 429 + type: string; 430 + }, 431 + ) => { 432 + const newPlayer = { 433 + ...state[action.payload.playerId], 434 + protocol: action.payload.protocol, 435 + }; 436 + if (action.payload.protocol === PROTOCOL_HLS) { 437 + newPlayer.selectedRendition = "auto"; 438 + } else { 439 + if (newPlayer.selectedRendition === "auto") { 440 + newPlayer.selectedRendition = "source"; 441 + } 442 + } 443 + return { 444 + ...state, 445 + [action.payload.playerId]: newPlayer, 446 + }; 447 + }, 448 + ), 380 449 }; 381 450 }, 382 451 ··· 393 462 selectSegment: (state, playerId: string) => { 394 463 return state[playerId].segment; 395 464 }, 465 + selectRenditions: (state, playerId: string) => { 466 + return state[playerId].renditions; 467 + }, 468 + selectSelectedRendition: (state, playerId: string) => { 469 + return state[playerId].selectedRendition; 470 + }, 471 + selectProtocol: (state, playerId: string) => { 472 + return state[playerId].protocol; 473 + }, 396 474 }, 397 475 }); 398 476 ··· 418 496 playerSlice.actions.pollSegment({ playerId, user }), 419 497 handleWebSocketMessages: (messages: any[]) => 420 498 playerSlice.actions.handleWebSocketMessages({ playerId, messages }), 499 + setSelectedRendition: (rendition: string) => 500 + playerSlice.actions.setSelectedRendition({ playerId, rendition }), 501 + setProtocol: (protocol: string) => 502 + playerSlice.actions.setProtocol({ playerId, protocol }), 421 503 }; 422 504 }; 423 505 ··· 448 530 const playerId = usePlayerId(); 449 531 return (state) => state.player[playerId].segment; 450 532 }; 533 + export const usePlayerRenditions = (): ((state: { 534 + player: PlayersState; 535 + }) => Rendition[]) => { 536 + const playerId = usePlayerId(); 537 + return (state) => state.player[playerId].renditions; 538 + }; 539 + export const usePlayerSelectedRendition = (): ((state: { 540 + player: PlayersState; 541 + }) => string | null) => { 542 + const playerId = usePlayerId(); 543 + return (state) => state.player[playerId].selectedRendition; 544 + }; 545 + export const usePlayerProtocol = (): ((state: { 546 + player: PlayersState; 547 + }) => string) => { 548 + const playerId = usePlayerId(); 549 + return (state) => state.player[playerId].protocol; 550 + };
+1 -1
js/desktop/package.json
··· 5 5 "description": "Streamplace Desktop Application", 6 6 "main": ".webpack/main", 7 7 "scripts": { 8 - "start": "electron-forge start \"$@\" | cat", 8 + "start": "PORT=38082 electron-forge start \"$@\" | cat", 9 9 "start-with-node": "electron-forge start \"$@\" | cat", 10 10 "package": "electron-forge package", 11 11 "make": "electron-forge make",
+17
lexicons/place/stream/defs.json
··· 15 15 "record": { "type": "ref", "ref": "app.bsky.graph.block" }, 16 16 "indexedAt": { "type": "string", "format": "datetime" } 17 17 } 18 + }, 19 + "renditions": { 20 + "type": "object", 21 + "required": ["renditions"], 22 + "properties": { 23 + "renditions": { 24 + "type": "array", 25 + "items": { "type": "ref", "ref": "#rendition" } 26 + } 27 + } 28 + }, 29 + "rendition": { 30 + "type": "object", 31 + "required": ["name"], 32 + "properties": { 33 + "name": { "type": "string" } 34 + } 18 35 } 19 36 } 20 37 }
+2
lexicons/place/stream/livestream.json
··· 65 65 "#livestreamView", 66 66 "#viewerCount", 67 67 "place.stream.defs#blockView", 68 + "place.stream.defs#renditions", 69 + "place.stream.defs#rendition", 68 70 "place.stream.chat.defs#messageView" 69 71 ] 70 72 }
+17 -1
lexicons/place/stream/segment.json
··· 23 23 "format": "datetime", 24 24 "description": "When this segment started" 25 25 }, 26 + "duration": { 27 + "type": "integer", 28 + "description": "The duration of the segment in nanoseconds" 29 + }, 26 30 "creator": { 27 31 "type": "string", 28 32 "format": "did" ··· 59 63 "properties": { 60 64 "codec": { "type": "string", "enum": ["h264"] }, 61 65 "width": { "type": "integer" }, 62 - "height": { "type": "integer" } 66 + "height": { "type": "integer" }, 67 + "framerate": { 68 + "type": "ref", 69 + "ref": "#framerate" 70 + } 71 + } 72 + }, 73 + "framerate": { 74 + "type": "object", 75 + "required": ["num", "den"], 76 + "properties": { 77 + "num": { "type": "integer" }, 78 + "den": { "type": "integer" } 63 79 } 64 80 } 65 81 }
+34 -6
pkg/api/api.go
··· 28 28 "stream.place/streamplace/pkg/bus" 29 29 "stream.place/streamplace/pkg/config" 30 30 "stream.place/streamplace/pkg/crypto/signers/eip712" 31 + "stream.place/streamplace/pkg/director" 31 32 apierrors "stream.place/streamplace/pkg/errors" 32 33 "stream.place/streamplace/pkg/linking" 33 34 "stream.place/streamplace/pkg/log" ··· 35 36 "stream.place/streamplace/pkg/mist/mistconfig" 36 37 "stream.place/streamplace/pkg/model" 37 38 "stream.place/streamplace/pkg/notifications" 39 + "stream.place/streamplace/pkg/renditions" 38 40 "stream.place/streamplace/pkg/spmetrics" 39 41 "stream.place/streamplace/pkg/streamplace" 40 42 ) ··· 49 51 MediaManager *media.MediaManager 50 52 MediaSigner media.MediaSigner 51 53 // not thread-safe yet 52 - Aliases map[string]string 53 - Bus *bus.Bus 54 - ATSync *atproto.ATProtoSynchronizer 54 + Aliases map[string]string 55 + Bus *bus.Bus 56 + ATSync *atproto.ATProtoSynchronizer 57 + Director *director.Director 55 58 } 56 59 57 - func MakeStreamplaceAPI(cli *config.CLI, mod model.Model, signer *eip712.EIP712Signer, noter notifications.FirebaseNotifier, mm *media.MediaManager, ms media.MediaSigner, bus *bus.Bus, atsync *atproto.ATProtoSynchronizer) (*StreamplaceAPI, error) { 60 + func MakeStreamplaceAPI(cli *config.CLI, mod model.Model, signer *eip712.EIP712Signer, noter notifications.FirebaseNotifier, mm *media.MediaManager, ms media.MediaSigner, bus *bus.Bus, atsync *atproto.ATProtoSynchronizer, d *director.Director) (*StreamplaceAPI, error) { 58 61 updater, err := PrepareUpdater(cli) 59 62 if err != nil { 60 63 return nil, err ··· 69 72 Aliases: map[string]string{}, 70 73 Bus: bus, 71 74 ATSync: atsync, 75 + Director: d, 72 76 } 73 77 a.Mimes, err = updater.GetMimes() 74 78 if err != nil { ··· 95 99 return nil, ErrorIndex 96 100 } 97 101 102 + // api/playback/iame.li/webrtc?rendition=source 103 + // api/playback/iame.li/stream.mp4?rendition=source 104 + // api/playback/iame.li/stream.webm?rendition=source 105 + // api/playback/iame.li/hls/index.m3u8 106 + // api/playback/iame.li/hls/source/stream.m3u8 107 + // api/playback/iame.li/hls/source/000000000000.ts 108 + 98 109 func (a *StreamplaceAPI) Handler(ctx context.Context) (http.Handler, error) { 99 110 router := httprouter.New() 100 111 apiRouter := httprouter.New() ··· 107 118 apiRouter.POST("/api/webrtc/:stream", a.MistProxyHandler(ctx, "/webrtc/%s")) 108 119 apiRouter.OPTIONS("/api/webrtc/:stream", a.MistProxyHandler(ctx, "/webrtc/%s")) 109 120 apiRouter.DELETE("/api/webrtc/:stream", a.MistProxyHandler(ctx, "/webrtc/%s")) 110 - apiRouter.GET("/api/hls/:stream/*resource", a.MistProxyHandler(ctx, "/hls/%s")) 111 121 apiRouter.Handler("POST", "/api/segment", a.HandleSegment(ctx)) 112 122 apiRouter.HandlerFunc("GET", "/api/healthz", a.HandleHealthz(ctx)) 123 + apiRouter.GET("/api/playback/:user/hls/*file", a.HandleHLSPlayback(ctx)) 113 124 apiRouter.GET("/api/playback/:user/stream.mp4", a.HandleMP4Playback(ctx)) 114 125 apiRouter.GET("/api/playback/:user/stream.webm", a.HandleMKVPlayback(ctx)) 115 - apiRouter.GET("/api/playback/:user/hls/:file", a.HandleHLSPlayback(ctx)) 116 126 // they're, uh, not jpegs. but we used this once and i don't wanna break backwards compatibility 117 127 apiRouter.GET("/api/playback/:user/stream.jpg", a.HandleThumbnailPlayback(ctx)) 118 128 // this one is not a lie ··· 736 746 return 737 747 } 738 748 initialBurst <- spSeg 749 + if a.CLI.LivepeerGatewayURL != "" { 750 + renditions, err := renditions.GenerateRenditions(spSeg) 751 + if err != nil { 752 + log.Error(ctx, "could not generate renditions", "error", err) 753 + return 754 + } 755 + outRs := streamplace.Defs_Renditions{ 756 + LexiconTypeID: "place.stream.defs#renditions", 757 + } 758 + outRs.Renditions = []*streamplace.Defs_Rendition{} 759 + for _, r := range renditions { 760 + outRs.Renditions = append(outRs.Renditions, &streamplace.Defs_Rendition{ 761 + LexiconTypeID: "place.stream.defs#rendition", 762 + Name: r.Name, 763 + }) 764 + } 765 + initialBurst <- outRs 766 + } 739 767 }() 740 768 741 769 go func() {
+84 -12
pkg/api/api_internal.go
··· 29 29 "stream.place/streamplace/pkg/mist/misttriggers" 30 30 "stream.place/streamplace/pkg/model" 31 31 notificationpkg "stream.place/streamplace/pkg/notifications" 32 + "stream.place/streamplace/pkg/renditions" 32 33 v0 "stream.place/streamplace/pkg/schema/v0" 33 34 ) 34 35 ··· 79 80 router.Handler("GET", "/debug/pprof/heap", pprof.Handler("heap")) 80 81 router.Handler("GET", "/debug/pprof/threadcreate", pprof.Handler("threadcreate")) 81 82 router.Handler("GET", "/debug/pprof/block", pprof.Handler("block")) 83 + router.Handler("GET", "/debug/pprof/allocs", pprof.Handler("allocs")) 84 + router.Handler("GET", "/debug/pprof/mutex", pprof.Handler("mutex")) 82 85 83 86 router.POST("/gc", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 84 87 runtime.GC() ··· 87 90 88 91 router.Handler("GET", "/metrics", promhttp.Handler()) 89 92 90 - router.GET("/playback/:user/concat", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 93 + router.GET("/playback/:user/:rendition/concat", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 91 94 user := p.ByName("user") 92 95 if user == "" { 93 96 errors.WriteHTTPBadRequest(w, "user required", nil) 94 97 return 95 98 } 99 + rendition := p.ByName("rendition") 100 + if rendition == "" { 101 + errors.WriteHTTPBadRequest(w, "rendition required", nil) 102 + return 103 + } 96 104 user, err := a.NormalizeUser(ctx, user) 97 105 if err != nil { 98 106 errors.WriteHTTPBadRequest(w, "invalid user", err) ··· 102 110 fmt.Fprintf(w, "ffconcat version 1.0\n") 103 111 // intermittent reports that you need two here to make things work properly? shouldn't matter. 104 112 for i := 0; i < 2; i += 1 { 105 - fmt.Fprintf(w, "file '%s/playback/%s/latest.mp4'\n", a.CLI.OwnInternalURL(), user) 113 + fmt.Fprintf(w, "file '%s/playback/%s/%s/latest.mp4'\n", a.CLI.OwnInternalURL(), user, rendition) 106 114 } 107 115 }) 108 116 109 - router.GET("/playback/:user/latest.mp4", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 117 + router.GET("/playback/:user/:rendition/latest.mp4", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 110 118 user := p.ByName("user") 111 119 if user == "" { 112 120 errors.WriteHTTPBadRequest(w, "user required", nil) ··· 117 125 errors.WriteHTTPBadRequest(w, "invalid user", err) 118 126 return 119 127 } 120 - file := <-a.MediaManager.SubscribeSegment(ctx, user) 121 - base := filepath.Base(file) 122 - w.Header().Set("Location", fmt.Sprintf("%s/playback/%s/segment/%s\n", a.CLI.OwnInternalURL(), user, base)) 128 + rendition := p.ByName("rendition") 129 + if rendition == "" { 130 + errors.WriteHTTPBadRequest(w, "rendition required", nil) 131 + return 132 + } 133 + seg := <-a.MediaManager.SubscribeSegment(ctx, user, rendition) 134 + base := filepath.Base(seg.Filepath) 135 + w.Header().Set("Location", fmt.Sprintf("%s/playback/%s/%s/segment/%s\n", a.CLI.OwnInternalURL(), user, rendition, base)) 123 136 w.WriteHeader(301) 124 137 }) 125 138 126 - router.GET("/playback/:user/segment/:file", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 139 + router.GET("/playback/:user/:rendition/segment/:file", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 127 140 user := p.ByName("user") 128 141 if user == "" { 129 142 errors.WriteHTTPBadRequest(w, "user required", nil) ··· 147 160 http.ServeFile(w, r, fullpath) 148 161 }) 149 162 150 - router.GET("/playback/:user/stream.mkv", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 163 + router.GET("/playback/:user/:rendition/stream.mkv", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 151 164 user := p.ByName("user") 152 165 if user == "" { 153 166 errors.WriteHTTPBadRequest(w, "user required", nil) 154 167 return 155 168 } 169 + rendition := p.ByName("rendition") 170 + if rendition == "" { 171 + errors.WriteHTTPBadRequest(w, "rendition required", nil) 172 + return 173 + } 156 174 user, err := a.NormalizeUser(ctx, user) 157 175 if err != nil { 158 176 errors.WriteHTTPBadRequest(w, "invalid user", err) ··· 160 178 } 161 179 w.Header().Set("Content-Type", "video/x-matroska") 162 180 w.WriteHeader(200) 163 - err = a.MediaManager.SegmentToMKVPlusOpus(ctx, user, w) 181 + err = a.MediaManager.SegmentToMKVPlusOpus(ctx, user, rendition, w) 164 182 if err != nil { 165 183 log.Log(ctx, "stream.mkv error", "error", err) 166 184 } 167 185 }) 168 186 169 - router.GET("/playback/:user/stream.mp4", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 187 + router.GET("/playback/:user/:rendition/stream.mp4", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 170 188 user := p.ByName("user") 171 189 if user == "" { 172 190 errors.WriteHTTPBadRequest(w, "user required", nil) 191 + return 192 + } 193 + rendition := p.ByName("rendition") 194 + if rendition == "" { 195 + errors.WriteHTTPBadRequest(w, "rendition required", nil) 173 196 return 174 197 } 175 198 user, err := a.NormalizeUser(ctx, user) ··· 197 220 pr, pw := io.Pipe() 198 221 bufw := bufio.NewWriter(pw) 199 222 g.Go(func() error { 200 - return a.MediaManager.SegmentToMP4(ctx, user, bufw) 223 + return a.MediaManager.SegmentToMP4(ctx, user, rendition, bufw) 201 224 }) 202 225 g.Go(func() error { 203 226 time.Sleep(time.Duration(delayMS) * time.Millisecond) ··· 207 230 g.Wait() 208 231 }) 209 232 210 - router.HEAD("/playback/:user/stream.mkv", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 233 + router.HEAD("/playback/:user/:rendition/stream.mkv", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 211 234 user := p.ByName("user") 212 235 if user == "" { 213 236 errors.WriteHTTPBadRequest(w, "user required", nil) ··· 451 474 } 452 475 453 476 w.WriteHeader(http.StatusNoContent) 477 + }) 478 + 479 + router.POST("/livepeer-auth-webhook-url", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { 480 + var payload struct { 481 + URL string `json:"url"` 482 + } 483 + // urls look like http://127.0.0.1:9999/live/did:plc:dkh4rwafdcda4ko7lewe43ml-uucbv40mdkcfat50/47.mp4 484 + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { 485 + errors.WriteHTTPBadRequest(w, "invalid request body (could not decode)", err) 486 + return 487 + } 488 + parts := strings.Split(payload.URL, "/") 489 + if len(parts) < 5 { 490 + errors.WriteHTTPBadRequest(w, "invalid request body (too few parts)", nil) 491 + return 492 + } 493 + didSession := parts[4] 494 + idParts := strings.Split(didSession, "-") 495 + if len(idParts) != 2 { 496 + errors.WriteHTTPBadRequest(w, "invalid request body (invalid did session)", nil) 497 + return 498 + } 499 + did := idParts[0] 500 + // sessionID := idParts[1] 501 + seg, err := a.Model.LatestSegmentForUser(did) 502 + if err != nil { 503 + errors.WriteHTTPInternalServerError(w, "unable to get latest segment", err) 504 + return 505 + } 506 + spseg, err := seg.ToStreamplaceSegment() 507 + if err != nil { 508 + errors.WriteHTTPInternalServerError(w, "unable to convert segment to streamplace segment", err) 509 + return 510 + } 511 + renditions, err := renditions.GenerateRenditions(spseg) 512 + if err != nil { 513 + errors.WriteHTTPInternalServerError(w, "unable to generate renditions", err) 514 + return 515 + } 516 + out := map[string]any{ 517 + "manifestID": didSession, 518 + "profiles": renditions.ToLivepeerProfiles(), 519 + } 520 + bs, err := json.Marshal(out) 521 + if err != nil { 522 + errors.WriteHTTPInternalServerError(w, "unable to marshal json", err) 523 + return 524 + } 525 + w.Write(bs) 454 526 }) 455 527 456 528 handler := sloghttp.Recovery(router)
+12
pkg/api/api_util.go
··· 1 + package api 2 + 3 + import "net/http" 4 + 5 + // get rendition from query params, defaulting to "source" 6 + func getRendition(r *http.Request) string { 7 + rendition := r.URL.Query().Get("rendition") 8 + if rendition == "" { 9 + rendition = "source" 10 + } 11 + return rendition 12 + }
+12 -8
pkg/api/playback.go
··· 52 52 errors.WriteHTTPBadRequest(w, "user required", nil) 53 53 return 54 54 } 55 + rendition := getRendition(r) 55 56 user, err := a.NormalizeUser(ctx, user) 56 57 if err != nil { 57 58 errors.WriteHTTPBadRequest(w, "invalid user", err) ··· 79 80 pr, pw := io.Pipe() 80 81 bufw := bufio.NewWriter(pw) 81 82 g.Go(func() error { 82 - return a.MediaManager.SegmentToMP4(ctx, user, bufw) 83 + return a.MediaManager.SegmentToMP4(ctx, user, rendition, bufw) 83 84 }) 84 85 g.Go(func() error { 85 86 <-ctx.Done() ··· 103 104 errors.WriteHTTPBadRequest(w, "user required", nil) 104 105 return 105 106 } 107 + rendition := getRendition(r) 106 108 user, err := a.NormalizeUser(ctx, user) 107 109 if err != nil { 108 110 errors.WriteHTTPBadRequest(w, "invalid user", err) ··· 130 132 pr, pw := io.Pipe() 131 133 bufw := bufio.NewWriter(pw) 132 134 g.Go(func() error { 133 - return a.MediaManager.SegmentToMKV(ctx, user, bufw) 135 + return a.MediaManager.SegmentToMKV(ctx, user, rendition, bufw) 134 136 }) 135 137 g.Go(func() error { 136 138 <-ctx.Done() ··· 154 156 errors.WriteHTTPBadRequest(w, "user required", nil) 155 157 return 156 158 } 159 + rendition := getRendition(r) 157 160 user, err := a.NormalizeUser(ctx, user) 158 161 if err != nil { 159 162 errors.WriteHTTPBadRequest(w, "invalid user", err) ··· 165 168 return 166 169 } 167 170 offer := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: string(body)} 168 - answer, err := a.MediaManager.WebRTCPlayback(ctx, user, &offer) 171 + answer, err := a.MediaManager.WebRTCPlayback(ctx, user, rendition, &offer) 169 172 if err != nil { 170 173 errors.WriteHTTPInternalServerError(w, "error playing back", err) 171 174 return ··· 346 349 errors.WriteHTTPBadRequest(w, "file required", nil) 347 350 return 348 351 } 349 - m3u8, err := a.MediaManager.SegmentToHLSOnce(ctx, user) 352 + m3u8, err := a.Director.GetM3U8(ctx, user) 350 353 if err != nil { 351 - errors.WriteHTTPInternalServerError(w, "SegmentToHLSOnce failed", nil) 354 + errors.WriteHTTPNotFound(w, "could not get m3u8", err) 352 355 return 353 356 } 354 357 session := r.URL.Query().Get("session") 355 - buf, err := m3u8.GetSegment(file, session) 358 + rendition := r.URL.Query().Get("rendition") 359 + buf, err := m3u8.GetFile(file, session, rendition) 356 360 if err != nil { 357 361 errors.WriteHTTPNotFound(w, "segment not found", err) 358 362 return 359 363 } 360 364 361 365 if strings.HasSuffix(file, ".m3u8") { 362 - w.Header().Set("Content-Type", "application/x-mpegURL") 366 + w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") 363 367 } else { 364 368 if session != "" { 365 369 spmetrics.SessionSeen(user, session) 366 370 } 367 - w.Header().Set("Content-Type", "video/MP2T") 371 + w.Header().Set("Content-Type", "video/mp2t") 368 372 } 369 373 370 374 http.ServeContent(w, r, file, time.Now(), bytes.NewReader(buf))
+30
pkg/atproto/firehose.go
··· 39 39 } 40 40 41 41 func (atsync *ATProtoSynchronizer) StartFirehose(ctx context.Context) error { 42 + retryCount := 0 43 + retryWindow := time.Now() 44 + 45 + for { 46 + if ctx.Err() != nil { 47 + return nil 48 + } 49 + err := atsync.StartFirehoseRetry(ctx) 50 + if err != nil { 51 + log.Error(ctx, "firehose error", "err", err) 52 + 53 + // Check if we're within the 1-minute window 54 + now := time.Now() 55 + if now.Sub(retryWindow) > time.Minute { 56 + // Reset the counter if more than a minute has passed 57 + retryCount = 1 58 + retryWindow = now 59 + } else { 60 + // Increment retry count if within the window 61 + retryCount++ 62 + if retryCount >= 3 { 63 + log.Error(ctx, "firehose failed 3 times within a minute, crashing", "err", err) 64 + return fmt.Errorf("firehose failed 3 times within a minute: %w", err) 65 + } 66 + } 67 + } 68 + } 69 + } 70 + 71 + func (atsync *ATProtoSynchronizer) StartFirehoseRetry(ctx context.Context) error { 42 72 ctx = log.WithLogValues(ctx, "func", "StartFirehose") 43 73 ctx, cancel := context.WithCancel(ctx) 44 74 defer cancel()
+3 -3
pkg/atproto/sync.go
··· 55 55 // someone we don't know about 56 56 return nil 57 57 } 58 - log.Warn(ctx, "creating block", "userDID", userDID, "subjectDID", rec.Subject) 58 + log.Debug(ctx, "creating block", "userDID", userDID, "subjectDID", rec.Subject) 59 59 block := &model.Block{ 60 60 RKey: rkey.String(), 61 61 RepoDID: userDID, ··· 86 86 if err != nil { 87 87 return fmt.Errorf("failed to sync bluesky repo: %w", err) 88 88 } 89 - log.Warn(ctx, "streamplace.ChatMessage detected", "message", rec.Text, "repo", repo.Handle) 89 + log.Debug(ctx, "streamplace.ChatMessage detected", "message", rec.Text, "repo", repo.Handle) 90 90 block, err := atsync.Model.GetUserBlock(ctx, streamerRepo.DID, userDID) 91 91 if err != nil { 92 92 return fmt.Errorf("failed to get user block: %w", err) 93 93 } 94 94 if block != nil { 95 - log.Warn(ctx, "excluding message from blocked user", "userDID", userDID, "subjectDID", streamerRepo.DID) 95 + log.Debug(ctx, "excluding message from blocked user", "userDID", userDID, "subjectDID", streamerRepo.DID) 96 96 return nil 97 97 } 98 98 mcm := &model.ChatMessage{
+7 -65
pkg/cmd/streamplace.go
··· 1 1 package cmd 2 2 3 3 import ( 4 - "bytes" 5 4 "context" 6 5 "crypto" 7 6 "flag" ··· 13 12 "runtime/pprof" 14 13 "strconv" 15 14 "syscall" 16 - "time" 17 15 18 16 "golang.org/x/term" 19 17 "stream.place/streamplace/pkg/aqhttp" 20 - "stream.place/streamplace/pkg/aqtime" 21 18 "stream.place/streamplace/pkg/atproto" 22 19 "stream.place/streamplace/pkg/bus" 23 20 "stream.place/streamplace/pkg/crypto/signers" 24 21 "stream.place/streamplace/pkg/crypto/signers/eip712" 22 + "stream.place/streamplace/pkg/director" 25 23 "stream.place/streamplace/pkg/log" 26 24 "stream.place/streamplace/pkg/media" 27 25 "stream.place/streamplace/pkg/notifications" ··· 29 27 "stream.place/streamplace/pkg/replication/boring" 30 28 v0 "stream.place/streamplace/pkg/schema/v0" 31 29 "stream.place/streamplace/pkg/spmetrics" 32 - "stream.place/streamplace/pkg/thumbnail" 33 30 34 31 "github.com/ThalesGroup/crypto11" 35 32 _ "github.com/go-gst/go-glib/glib" ··· 131 128 fs.StringVar(&cli.AppBundleID, "app-bundle-id", "", "bundle id of an app that we facilitate oauth login for") 132 129 fs.StringVar(&cli.StreamerName, "streamer-name", "", "name of the person streaming from this streamplace node") 133 130 fs.StringVar(&cli.FrontendProxy, "dev-frontend-proxy", "", "(FOR DEVELOPMENT ONLY) proxy frontend requests to this address instead of using the bundled frontend") 131 + fs.StringVar(&cli.LivepeerGatewayURL, "livepeer-gateway-url", "", "URL of the Livepeer Gateway to use for transcoding") 134 132 fs.BoolVar(&cli.WideOpen, "wide-open", false, "allow ALL streams to be uploaded to this node (not recommended for production)") 135 133 cli.StringSliceFlag(fs, &cli.AllowedStreams, "allowed-streams", "", "if set, only allow these addresses or atproto DIDs to upload to this node") 136 134 cli.StringSliceFlag(fs, &cli.Peers, "peers", "", "other streamplace nodes to replicate to") ··· 179 177 if *version { 180 178 return nil 181 179 } 180 + spmetrics.Version.WithLabelValues(build.Version).Inc() 182 181 183 182 aqhttp.UserAgent = fmt.Sprintf("streamplace/%s", build.Version) 184 183 ··· 299 298 return err 300 299 } 301 300 302 - a, err := api.MakeStreamplaceAPI(&cli, mod, eip712signer, noter, mm, ms, b, atsync) 301 + d := director.NewDirector(mm, mod, &cli, b) 302 + 303 + a, err := api.MakeStreamplaceAPI(&cli, mod, eip712signer, noter, mm, ms, b, atsync, d) 303 304 if err != nil { 304 305 return err 305 306 } ··· 339 340 }) 340 341 341 342 group.Go(func() error { 342 - newSeg := mm.NewSegment() 343 - for { 344 - select { 345 - case <-ctx.Done(): 346 - return nil 347 - case not := <-newSeg: 348 - err := mod.CreateSegment(not.Segment) 349 - if err != nil { 350 - log.Error(ctx, "could not add segment to database", "error", err) 351 - } 352 - spseg, err := not.Segment.ToStreamplaceSegment() 353 - if err != nil { 354 - log.Error(ctx, "could not convert segment to streamplace segment", "error", err) 355 - continue 356 - } 357 - b.Publish(spseg.Creator, spseg) 358 - go func() { 359 - err := func() error { 360 - lock := thumbnail.GetThumbnailLock(not.Segment.RepoDID) 361 - locked := lock.TryLock() 362 - if !locked { 363 - // we're already generating a thumbnail for this user, skip 364 - return nil 365 - } 366 - defer lock.Unlock() 367 - oldThumb, err := mod.LatestThumbnailForUser(not.Segment.RepoDID) 368 - if err != nil { 369 - return err 370 - } 371 - if oldThumb != nil && not.Segment.StartTime.Sub(oldThumb.Segment.StartTime) < time.Minute { 372 - // we have a thumbnail <60sec old, skip generating a new one 373 - return nil 374 - } 375 - r := bytes.NewReader(not.Data) 376 - aqt := aqtime.FromTime(not.Segment.StartTime) 377 - fd, err := cli.SegmentFileCreate(not.Segment.RepoDID, aqt, "jpg") 378 - if err != nil { 379 - return err 380 - } 381 - defer fd.Close() 382 - err = mm.Thumbnail(ctx, r, fd) 383 - if err != nil { 384 - return err 385 - } 386 - thumb := &model.Thumbnail{ 387 - Format: "jpg", 388 - SegmentID: not.Segment.ID, 389 - } 390 - err = mod.CreateThumbnail(thumb) 391 - if err != nil { 392 - return err 393 - } 394 - return nil 395 - }() 396 - if err != nil { 397 - log.Error(ctx, "could not create thumbnail", "error", err) 398 - } 399 - }() 400 - } 401 - } 343 + return d.Start(ctx) 402 344 }) 403 345 404 346 if cli.TestStream {
+1
pkg/config/config.go
··· 84 84 NoFirehose bool 85 85 PrintChat bool 86 86 Color string 87 + LivepeerGatewayURL string 87 88 88 89 dataDirFlags []*string 89 90 }
+95
pkg/director/director.go
··· 1 + package director 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "sync" 7 + 8 + "golang.org/x/sync/errgroup" 9 + "stream.place/streamplace/pkg/bus" 10 + "stream.place/streamplace/pkg/config" 11 + "stream.place/streamplace/pkg/log" 12 + "stream.place/streamplace/pkg/media" 13 + "stream.place/streamplace/pkg/model" 14 + ) 15 + 16 + // director is responsible for managing the lifecycle of a stream, making business 17 + // logic decisions about when to do things like 18 + // - size of the in-memory segment cache 19 + // - transcoding 20 + // - thumbnail generation 21 + 22 + type Director struct { 23 + mm *media.MediaManager 24 + mod model.Model 25 + cli *config.CLI 26 + bus *bus.Bus 27 + streamSessions map[string]*StreamSession 28 + streamSessionsMu sync.Mutex 29 + } 30 + 31 + func NewDirector(mm *media.MediaManager, mod model.Model, cli *config.CLI, bus *bus.Bus) *Director { 32 + return &Director{ 33 + mm: mm, 34 + mod: mod, 35 + cli: cli, 36 + bus: bus, 37 + streamSessions: make(map[string]*StreamSession), 38 + streamSessionsMu: sync.Mutex{}, 39 + } 40 + } 41 + 42 + func (d *Director) Start(ctx context.Context) error { 43 + newSeg := d.mm.NewSegment() 44 + ctx, cancel := context.WithCancel(ctx) 45 + defer cancel() 46 + g, ctx := errgroup.WithContext(ctx) 47 + for { 48 + select { 49 + case <-ctx.Done(): 50 + cancel() 51 + return g.Wait() 52 + case not := <-newSeg: 53 + d.streamSessionsMu.Lock() 54 + ss, ok := d.streamSessions[not.Segment.RepoDID] 55 + if !ok { 56 + ss = &StreamSession{ 57 + hls: nil, 58 + lp: nil, 59 + repoDID: not.Segment.RepoDID, 60 + mm: d.mm, 61 + mod: d.mod, 62 + cli: d.cli, 63 + bus: d.bus, 64 + segmentChan: make(chan struct{}), 65 + } 66 + d.streamSessions[not.Segment.RepoDID] = ss 67 + g.Go(func() error { 68 + err := ss.Start(ctx, not) 69 + if err != nil { 70 + log.Error(ctx, "could not start stream session", "error", err) 71 + } 72 + d.streamSessionsMu.Lock() 73 + delete(d.streamSessions, not.Segment.RepoDID) 74 + d.streamSessionsMu.Unlock() 75 + return nil 76 + }) 77 + } 78 + d.streamSessionsMu.Unlock() 79 + err := ss.NewSegment(ctx, not) 80 + if err != nil { 81 + log.Error(ctx, "could not add segment to stream session", "error", err) 82 + } 83 + } 84 + } 85 + } 86 + 87 + func (d *Director) GetM3U8(ctx context.Context, repoDID string) (*media.M3U8, error) { 88 + d.streamSessionsMu.Lock() 89 + defer d.streamSessionsMu.Unlock() 90 + ss, ok := d.streamSessions[repoDID] 91 + if !ok { 92 + return nil, fmt.Errorf("stream session not found") 93 + } 94 + return ss.hls, nil 95 + }
+251
pkg/director/stream_session.go
··· 1 + package director 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "time" 8 + 9 + "golang.org/x/sync/errgroup" 10 + "stream.place/streamplace/pkg/aqtime" 11 + "stream.place/streamplace/pkg/bus" 12 + "stream.place/streamplace/pkg/config" 13 + "stream.place/streamplace/pkg/livepeer" 14 + "stream.place/streamplace/pkg/log" 15 + "stream.place/streamplace/pkg/media" 16 + "stream.place/streamplace/pkg/media/segchanman" 17 + "stream.place/streamplace/pkg/model" 18 + "stream.place/streamplace/pkg/renditions" 19 + "stream.place/streamplace/pkg/spmetrics" 20 + "stream.place/streamplace/pkg/streamplace" 21 + "stream.place/streamplace/pkg/thumbnail" 22 + ) 23 + 24 + type StreamSession struct { 25 + mm *media.MediaManager 26 + mod model.Model 27 + cli *config.CLI 28 + bus *bus.Bus 29 + hls *media.M3U8 30 + lp *livepeer.LivepeerSession 31 + repoDID string 32 + segmentChan chan struct{} 33 + } 34 + 35 + func (ss *StreamSession) Start(ctx context.Context, not *media.NewSegmentNotification) error { 36 + 37 + sid := livepeer.RandomTrailer(8) 38 + ctx = log.WithLogValues(ctx, "sid", sid) 39 + ctx, cancel := context.WithCancel(ctx) 40 + log.Log(ctx, "starting stream session") 41 + defer cancel() 42 + spseg, err := not.Segment.ToStreamplaceSegment() 43 + if err != nil { 44 + return fmt.Errorf("could not convert segment to streamplace segment: %w", err) 45 + } 46 + var allRenditions renditions.Renditions 47 + 48 + if ss.cli.LivepeerGatewayURL != "" { 49 + allRenditions, err = renditions.GenerateRenditions(spseg) 50 + } else { 51 + allRenditions = []renditions.Rendition{} 52 + } 53 + if err != nil { 54 + return err 55 + } 56 + if spseg.Duration == nil { 57 + return fmt.Errorf("segment duration is required to calculate bitrate") 58 + } 59 + dur := time.Duration(*spseg.Duration) 60 + byteLen := len(not.Data) 61 + bitrate := int(float64(byteLen) / dur.Seconds() * 8) 62 + sourceRendition := renditions.Rendition{ 63 + Name: "source", 64 + Bitrate: bitrate, 65 + Width: spseg.Video[0].Width, 66 + Height: spseg.Video[0].Height, 67 + } 68 + allRenditions = append([]renditions.Rendition{sourceRendition}, allRenditions...) 69 + ss.hls = media.NewM3U8(allRenditions) 70 + 71 + g, ctx := errgroup.WithContext(ctx) 72 + 73 + for _, r := range allRenditions { 74 + g.Go(func() error { 75 + for { 76 + if ctx.Err() != nil { 77 + return nil 78 + } 79 + err := ss.mm.ToHLS(ctx, spseg.Creator, r.Name, ss.hls) 80 + if ctx.Err() != nil { 81 + return nil 82 + } 83 + log.Warn(ctx, "hls failed, retrying in 5 seconds", "error", err) 84 + time.Sleep(time.Second * 5) 85 + } 86 + }) 87 + } 88 + 89 + for { 90 + select { 91 + case <-ss.segmentChan: 92 + // reset timer 93 + case <-ctx.Done(): 94 + return g.Wait() 95 + // case <-time.After(time.Minute * 1): 96 + case <-time.After(time.Second * 60): 97 + log.Log(ctx, "no new segments for 1 minute, shutting down") 98 + cancel() 99 + } 100 + } 101 + } 102 + 103 + func (ss *StreamSession) NewSegment(ctx context.Context, not *media.NewSegmentNotification) error { 104 + if ctx.Err() != nil { 105 + return nil 106 + } 107 + ss.segmentChan <- struct{}{} 108 + aqt := aqtime.FromTime(not.Segment.StartTime) 109 + ctx = log.WithLogValues(ctx, "segID", not.Segment.ID, "repoDID", not.Segment.RepoDID, "timestamp", aqt.FileSafeString()) 110 + err := ss.mod.CreateSegment(not.Segment) 111 + if err != nil { 112 + return fmt.Errorf("could not add segment to database: %w", err) 113 + } 114 + spseg, err := not.Segment.ToStreamplaceSegment() 115 + if err != nil { 116 + return fmt.Errorf("could not convert segment to streamplace segment: %w", err) 117 + } 118 + 119 + ss.bus.Publish(spseg.Creator, spseg) 120 + 121 + go func() { 122 + err := ss.Thumbnail(ctx, spseg.Creator, not) 123 + if err != nil { 124 + log.Error(ctx, "could not create thumbnail", "error", err) 125 + } 126 + }() 127 + 128 + if ss.cli.LivepeerGatewayURL != "" { 129 + go func() { 130 + start := time.Now() 131 + err := ss.Transcode(ctx, spseg, not.Data) 132 + took := time.Since(start) 133 + if err != nil { 134 + log.Error(ctx, "could not transcode", "error", err, "took", took) 135 + } else { 136 + log.Log(ctx, "transcoded segment", "took", took) 137 + } 138 + }() 139 + } 140 + 141 + return nil 142 + } 143 + 144 + func (ss *StreamSession) Thumbnail(ctx context.Context, repoDID string, not *media.NewSegmentNotification) error { 145 + lock := thumbnail.GetThumbnailLock(not.Segment.RepoDID) 146 + locked := lock.TryLock() 147 + if !locked { 148 + // we're already generating a thumbnail for this user, skip 149 + return nil 150 + } 151 + defer lock.Unlock() 152 + oldThumb, err := ss.mod.LatestThumbnailForUser(not.Segment.RepoDID) 153 + if err != nil { 154 + return err 155 + } 156 + if oldThumb != nil && not.Segment.StartTime.Sub(oldThumb.Segment.StartTime) < time.Minute { 157 + // we have a thumbnail <60sec old, skip generating a new one 158 + return nil 159 + } 160 + r := bytes.NewReader(not.Data) 161 + aqt := aqtime.FromTime(not.Segment.StartTime) 162 + fd, err := ss.cli.SegmentFileCreate(not.Segment.RepoDID, aqt, "png") 163 + if err != nil { 164 + return err 165 + } 166 + defer fd.Close() 167 + err = ss.mm.Thumbnail(ctx, r, fd) 168 + if err != nil { 169 + return err 170 + } 171 + thumb := &model.Thumbnail{ 172 + Format: "png", 173 + SegmentID: not.Segment.ID, 174 + } 175 + err = ss.mod.CreateThumbnail(thumb) 176 + if err != nil { 177 + return err 178 + } 179 + return nil 180 + } 181 + 182 + func (ss *StreamSession) Transcode(ctx context.Context, spseg *streamplace.Segment, data []byte) error { 183 + rs, err := renditions.GenerateRenditions(spseg) 184 + if ss.lp == nil { 185 + var err error 186 + ss.lp, err = livepeer.NewLivepeerSession(ctx, spseg.Creator, ss.cli.LivepeerGatewayURL) 187 + if err != nil { 188 + return err 189 + } 190 + 191 + } 192 + spmetrics.TranscodeAttemptsTotal.Inc() 193 + segs, err := ss.lp.PostSegmentToGateway(ctx, data, spseg) 194 + if err != nil { 195 + spmetrics.TranscodeErrorsTotal.Inc() 196 + return err 197 + } 198 + if len(rs) != len(segs) { 199 + spmetrics.TranscodeErrorsTotal.Inc() 200 + return fmt.Errorf("expected %d renditions, got %d", len(rs), len(segs)) 201 + } 202 + spmetrics.TranscodeSuccessesTotal.Inc() 203 + aqt, err := aqtime.FromString(spseg.StartTime) 204 + if err != nil { 205 + return err 206 + } 207 + for i, seg := range segs { 208 + log.Debug(ctx, "publishing segment", "rendition", rs[i]) 209 + fd, err := ss.cli.SegmentFileCreate(spseg.Creator, aqt, fmt.Sprintf("%s.mp4", rs[i].Name)) 210 + if err != nil { 211 + return err 212 + } 213 + defer fd.Close() 214 + fd.Write(seg) 215 + // go ss.TryAddToHLS(ctx, spseg, rs[i].Name, seg) 216 + go ss.mm.PublishSegment(ctx, spseg.Creator, rs[i].Name, &segchanman.Seg{ 217 + Filepath: fd.Name(), 218 + Data: seg, 219 + }) 220 + } 221 + return nil 222 + } 223 + 224 + // func (ss *StreamSession) TryAddToHLS(ctx context.Context, spseg *streamplace.Segment, rendition string, data []byte) { 225 + // ctx = log.WithLogValues(ctx, "rendition", rendition) 226 + // err := ss.AddToHLS(ctx, spseg, rendition, data) 227 + // if err != nil { 228 + // log.Error(ctx, "could not add to hls", "error", err) 229 + // } 230 + // } 231 + 232 + // func (ss *StreamSession) AddToHLS(ctx context.Context, spseg *streamplace.Segment, rendition string, data []byte) error { 233 + // buf := bytes.Buffer{} 234 + // dur, err := media.MP4ToMPEGTS(ctx, bytes.NewReader(data), &buf) 235 + // if err != nil { 236 + // return err 237 + // } 238 + // newSeg := &streamplace.Segment{ 239 + // LexiconTypeID: "place.stream.segment", 240 + // Id: spseg.Id, 241 + // Creator: spseg.Creator, 242 + // StartTime: spseg.StartTime, 243 + // Duration: &dur, 244 + // Audio: spseg.Audio, 245 + // Video: spseg.Video, 246 + // SigningKey: spseg.SigningKey, 247 + // } 248 + // log.Debug(ctx, "transmuxed to mpegts, adding to hls", "rendition", rendition, "size", buf.Len()) 249 + // ss.hls.NewSegment(newSeg, rendition, buf.Bytes()) 250 + // return nil 251 + // }
+1
pkg/gen/gen.go
··· 25 25 streamplace.Segment{}, 26 26 streamplace.Segment_Audio{}, 27 27 streamplace.Segment_Video{}, 28 + streamplace.Segment_Framerate{}, 28 29 streamplace.ChatMessage{}, 29 30 streamplace.RichtextFacet{}, 30 31 streamplace.ChatProfile{},
+111
pkg/livepeer/livepeer.go
··· 1 + package livepeer 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "io" 8 + "math/rand" 9 + "mime" 10 + "mime/multipart" 11 + "net/http" 12 + "strings" 13 + "sync" 14 + "time" 15 + 16 + "golang.org/x/net/context/ctxhttp" 17 + "stream.place/streamplace/pkg/aqhttp" 18 + "stream.place/streamplace/pkg/log" 19 + "stream.place/streamplace/pkg/streamplace" 20 + ) 21 + 22 + type LivepeerSession struct { 23 + SessionID string 24 + Count int 25 + GatewayURL string 26 + SegLock sync.Mutex 27 + } 28 + 29 + // borrowed from catalyst-api 30 + func RandomTrailer(length int) string { 31 + const charset = "abcdefghijklmnopqrstuvwxyz0123456789" 32 + 33 + res := make([]byte, length) 34 + for i := 0; i < length; i++ { 35 + res[i] = charset[rand.Intn(len(charset))] 36 + } 37 + return string(res) 38 + } 39 + 40 + func NewLivepeerSession(ctx context.Context, did string, gatewayURL string) (*LivepeerSession, error) { 41 + sessionID := RandomTrailer(8) 42 + return &LivepeerSession{ 43 + SessionID: fmt.Sprintf("%s-%s", did, sessionID), 44 + Count: 0, 45 + GatewayURL: gatewayURL, 46 + }, nil 47 + } 48 + 49 + func (ls *LivepeerSession) PostSegmentToGateway(ctx context.Context, buf []byte, seg *streamplace.Segment) ([][]byte, error) { 50 + ctx = log.WithLogValues(ctx, "func", "PostSegmentToGateway") 51 + ls.SegLock.Lock() 52 + defer ls.SegLock.Unlock() 53 + ctx, cancel := context.WithTimeout(ctx, time.Minute*5) 54 + defer cancel() 55 + url := fmt.Sprintf("%s/live/%s/%d.mp4", ls.GatewayURL, ls.SessionID, ls.Count) 56 + ls.Count++ 57 + 58 + dur := time.Duration(*seg.Duration) 59 + durationMs := int(dur.Milliseconds()) 60 + log.Debug(ctx, "posting segment to livepeer gateway", "duration_ms", durationMs, "url", url) 61 + 62 + vid := seg.Video[0] 63 + width := int(vid.Width) 64 + height := int(vid.Height) 65 + 66 + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(buf)) 67 + if err != nil { 68 + return nil, fmt.Errorf("failed to create request: %w", err) 69 + } 70 + req.Header.Set("Accept", "multipart/mixed") 71 + req.Header.Set("Content-Duration", fmt.Sprintf("%d", durationMs)) 72 + req.Header.Set("Content-Resolution", fmt.Sprintf("%dx%d", width, height)) 73 + 74 + resp, err := ctxhttp.Do(ctx, &aqhttp.Client, req) 75 + if err != nil { 76 + return nil, fmt.Errorf("failed to send segment to gateway: %w", err) 77 + } 78 + defer resp.Body.Close() 79 + 80 + if resp.StatusCode != http.StatusOK { 81 + errOut, _ := io.ReadAll(resp.Body) 82 + return nil, fmt.Errorf("gateway returned non-OK status: %d, %s", resp.StatusCode, string(errOut)) 83 + } 84 + 85 + var out [][]byte 86 + 87 + mediaType, params, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) 88 + if err != nil { 89 + return nil, fmt.Errorf("failed to parse media type: %w", err) 90 + } 91 + if strings.HasPrefix(mediaType, "multipart/") { 92 + mr := multipart.NewReader(resp.Body, params["boundary"]) 93 + for { 94 + p, err := mr.NextPart() 95 + if err == io.EOF { 96 + break 97 + } 98 + if err != nil { 99 + return nil, fmt.Errorf("failed to get next part: %w", err) 100 + } 101 + bs, err := io.ReadAll(p) 102 + if err != nil { 103 + return nil, fmt.Errorf("failed to read part: %w", err) 104 + } 105 + log.Debug(ctx, "got part back from livepeer gateway", "length", len(bs), "name", p.FileName()) 106 + out = append(out, bs) 107 + } 108 + } 109 + 110 + return out, nil 111 + }
+1 -1
pkg/media/bus_handler.go
··· 26 26 } 27 27 switch msg.Type() { 28 28 case gst.MessageEOS: // When end-of-stream is received flush the pipeline and stop the main loop 29 - log.Log(ctx, "got gst.MessageEOS, exiting") 29 + log.Debug(ctx, "got gst.MessageEOS, exiting") 30 30 return 31 31 case gst.MessageError: // Error messages are always fatal 32 32 err := msg.ParseError()
+17 -27
pkg/media/concat.go
··· 1 1 package media 2 2 3 3 import ( 4 + "bytes" 4 5 "context" 5 6 "errors" 6 7 "fmt" 7 8 "io" 8 - "os" 9 9 "strings" 10 10 "sync" 11 11 12 12 "github.com/go-gst/go-gst/gst" 13 13 "github.com/go-gst/go-gst/gst/app" 14 14 "stream.place/streamplace/pkg/log" 15 + "stream.place/streamplace/pkg/media/segchanman" 15 16 ) 16 17 17 18 type ConcatStreamer interface { 18 - SubscribeSegment(ctx context.Context, user string) <-chan string 19 - UnsubscribeSegment(ctx context.Context, user string, ch <-chan string) 19 + SubscribeSegment(ctx context.Context, user string, rendition string) <-chan *segchanman.Seg 20 + UnsubscribeSegment(ctx context.Context, user string, rendition string, ch <-chan *segchanman.Seg) 20 21 } 21 22 22 23 // This function remains in scope for the duration of a single users' playback 23 - func ConcatStream(ctx context.Context, pipeline *gst.Pipeline, user string, streamer ConcatStreamer) (*gst.Element, <-chan struct{}, error) { 24 + func ConcatStream(ctx context.Context, pipeline *gst.Pipeline, user string, rendition string, streamer ConcatStreamer) (*gst.Element, <-chan struct{}, error) { 24 25 ctx = log.WithLogValues(ctx, "func", "ConcatStream") 25 26 ctx, cancel := context.WithCancel(ctx) 26 27 ··· 34 35 err = pipeline.Add(inputQueue) 35 36 if err != nil { 36 37 return nil, nil, fmt.Errorf("failed to add input multiqueue to pipeline: %w", err) 37 - } 38 - for _, tmpl := range inputQueue.GetPadTemplates() { 39 - log.Warn(ctx, "pad template", "name", tmpl.GetName(), "direction", tmpl.Direction()) 40 38 } 41 39 inputQueuePadVideoSink := inputQueue.GetRequestPad("sink_%u") 42 40 if inputQueuePadVideoSink == nil { ··· 125 123 126 124 // this goroutine will read all the files from the segment queue and buffer 127 125 // them in a pipe so that we don't miss any in between iterations of the output 128 - allFiles := make(chan string, 1024) 126 + allFiles := make(chan []byte, 1024) 129 127 go func() { 130 128 for { 131 - ch := streamer.SubscribeSegment(ctx, user) 129 + ch := streamer.SubscribeSegment(ctx, user, rendition) 132 130 select { 133 131 case <-ctx.Done(): 134 - log.Warn(ctx, "exiting segment reader") 135 - streamer.UnsubscribeSegment(ctx, user, ch) 132 + log.Debug(ctx, "exiting segment reader") 133 + streamer.UnsubscribeSegment(ctx, user, rendition, ch) 136 134 return 137 135 case file := <-ch: 138 - log.Debug(ctx, "got segment", "file", file) 139 - allFiles <- file 140 - if file == "" { 136 + log.Debug(ctx, "got segment", "file", file.Filepath) 137 + allFiles <- file.Data 138 + if len(file.Data) == 0 { 141 139 log.Warn(ctx, "no more segments") 142 140 return 143 141 } ··· 156 154 pr.Close() 157 155 pw.Close() 158 156 return 159 - case fullpath := <-allFiles: 160 - if fullpath == "" { 157 + case bs := <-allFiles: 158 + if len(bs) == 0 { 161 159 log.Warn(ctx, "no more segments") 162 160 cancel() 163 161 return 164 162 } 165 - f, err := os.Open(fullpath) 166 - log.Debug(ctx, "opening segment file", "file", fullpath) 167 - if err != nil { 168 - log.Debug(ctx, "failed to open segment file", "error", err, "file", fullpath) 169 - cancel() 170 - return 171 - } 172 - defer f.Close() 173 - _, err = io.Copy(pw, f) 163 + _, err = io.Copy(pw, bytes.NewReader(bs)) 174 164 if err != nil { 175 - log.Error(ctx, "failed to copy segment file", "error", err, "file", fullpath) 165 + log.Error(ctx, "failed to copy segment file", "error", err) 176 166 cancel() 177 167 return 178 168 } ··· 304 294 done() 305 295 return 306 296 } else { 307 - log.Error(ctx, "failed to read data", "error", err) 297 + log.Debug(ctx, "failed to read data, ending stream", "error", err) 308 298 cancel() 309 299 return 310 300 }
+95
pkg/media/ffmpeg_concat.go
··· 1 + package media 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + 8 + "github.com/livepeer/lpms/ffmpeg" 9 + "golang.org/x/sync/errgroup" 10 + ) 11 + 12 + func (mm *MediaManager) SegmentToMKV(ctx context.Context, user string, rendition string, w io.Writer) error { 13 + muxer := ffmpeg.ComponentOptions{ 14 + Name: "matroska", 15 + } 16 + return mm.SegmentToStream(ctx, user, rendition, muxer, w) 17 + } 18 + 19 + func (mm *MediaManager) SegmentToMKVPlusOpus(ctx context.Context, user string, rendition string, w io.Writer) error { 20 + muxer := ffmpeg.ComponentOptions{ 21 + Name: "matroska", 22 + } 23 + pr, pw := io.Pipe() 24 + g, ctx := errgroup.WithContext(ctx) 25 + g.Go(func() error { 26 + return mm.SegmentToStream(ctx, user, rendition, muxer, pw) 27 + }) 28 + g.Go(func() error { 29 + return AddOpusToMKV(ctx, pr, w) 30 + }) 31 + return g.Wait() 32 + } 33 + 34 + func (mm *MediaManager) SegmentToMP4(ctx context.Context, user string, rendition string, w io.Writer) error { 35 + muxer := ffmpeg.ComponentOptions{ 36 + Name: "mp4", 37 + Opts: map[string]string{ 38 + "movflags": "frag_keyframe+empty_moov", 39 + }, 40 + } 41 + return mm.SegmentToStream(ctx, user, rendition, muxer, w) 42 + } 43 + 44 + func (mm *MediaManager) SegmentToStream(ctx context.Context, user string, rendition string, muxer ffmpeg.ComponentOptions, w io.Writer) error { 45 + tc := ffmpeg.NewTranscoder() 46 + defer tc.StopTranscoder() 47 + ourl, or, odone, err := mm.HTTPPipe() 48 + if err != nil { 49 + return err 50 + } 51 + defer odone() 52 + iname := fmt.Sprintf("%s/playback/%s/%s/concat", mm.cli.OwnInternalURL(), user, rendition) 53 + in := &ffmpeg.TranscodeOptionsIn{ 54 + Fname: iname, 55 + Transmuxing: true, 56 + Profile: ffmpeg.VideoProfile{}, 57 + Loop: -1, 58 + Demuxer: ffmpeg.ComponentOptions{ 59 + Name: "concat", 60 + Opts: map[string]string{ 61 + "safe": "0", 62 + "protocol_whitelist": "file,http,https,tcp,tls", 63 + }, 64 + }, 65 + } 66 + out := []ffmpeg.TranscodeOptions{ 67 + { 68 + Oname: ourl, 69 + VideoEncoder: ffmpeg.ComponentOptions{ 70 + Name: "copy", 71 + }, 72 + AudioEncoder: ffmpeg.ComponentOptions{ 73 + Name: "copy", 74 + }, 75 + Profile: ffmpeg.VideoProfile{Format: ffmpeg.FormatNone}, 76 + Muxer: muxer, 77 + }, 78 + } 79 + g, _ := errgroup.WithContext(ctx) 80 + g.Go(func() error { 81 + <-ctx.Done() 82 + or.Close() 83 + return nil 84 + }) 85 + g.Go(func() error { 86 + _, err := tc.Transcode(in, out) 87 + tc.StopTranscoder() 88 + return err 89 + }) 90 + g.Go(func() error { 91 + _, err := io.Copy(w, or) 92 + return err 93 + }) 94 + return g.Wait() 95 + }
-638
pkg/media/gstreamer.go
··· 15 15 "github.com/go-gst/go-glib/glib" 16 16 "github.com/go-gst/go-gst/gst" 17 17 "github.com/go-gst/go-gst/gst/app" 18 - "github.com/google/uuid" 19 18 "github.com/skip2/go-qrcode" 20 19 "golang.org/x/sync/errgroup" 21 20 "stream.place/streamplace/pkg/aqtime" 22 21 "stream.place/streamplace/pkg/log" 23 - "stream.place/streamplace/pkg/model" 24 22 "stream.place/streamplace/test" 25 23 ) 26 24 ··· 232 230 return nil 233 231 } 234 232 235 - // #EXTM3U 236 - // #EXT-X-VERSION:3 237 - // #EXT-X-MEDIA-SEQUENCE:281 238 - // #EXT-X-TARGETDURATION:1 239 - 240 - // #EXTINF:1, 241 - // segment00281.ts 242 - // #EXTINF:1.0049999952316284, 243 - // segment00282.ts 244 - // #EXTINF:1, 245 - // segment00283.ts 246 - // #EXTINF:1.0010000467300415, 247 - // segment00284.ts 248 - // #EXTINF:1, 249 - // segment00285.ts 250 - // #EXT-X-ENDLIST 251 - 252 - func (mm *MediaManager) ToHLS(ctx context.Context, input io.Reader, m3u8 *M3U8) error { 253 - ctx = log.WithLogValues(ctx, "GStreamerFunc", "ToHLS") 254 - 255 - splitmuxsink, err := gst.NewElementWithProperties("splitmuxsink", map[string]any{ 256 - "name": "mux", 257 - "async-finalize": true, 258 - "sink-factory": "appsink", 259 - "muxer-factory": "mpegtsmux", 260 - "max-size-bytes": 1, 261 - }) 262 - if err != nil { 263 - return err 264 - } 265 - 266 - p := splitmuxsink.GetRequestPad("video") 267 - if p == nil { 268 - return fmt.Errorf("failed to get video pad") 269 - } 270 - p = splitmuxsink.GetRequestPad("audio_%u") 271 - if p == nil { 272 - return fmt.Errorf("failed to get audio pad") 273 - } 274 - 275 - pipelineSlice := []string{ 276 - "appsrc name=appsrc ! matroskademux name=demux", 277 - "demux.video_0 ! queue ! h264parse name=videoparse", 278 - "demux.audio_0 ! queue ! opusdec use-inband-fec=true ! audioresample ! fdkaacenc name=audioenc", 279 - } 280 - 281 - pipeline, err := gst.NewPipelineFromString(strings.Join(pipelineSlice, "\n")) 282 - if err != nil { 283 - return fmt.Errorf("error creating ToHLS pipeline: %w", err) 284 - } 285 - 286 - err = pipeline.Add(splitmuxsink) 287 - if err != nil { 288 - return fmt.Errorf("error adding splitmuxsink to ToHLS pipeline: %w", err) 289 - } 290 - 291 - videoparse, err := pipeline.GetElementByName("videoparse") 292 - if err != nil { 293 - return fmt.Errorf("error getting videoparse from ToHLS pipeline: %w", err) 294 - } 295 - err = videoparse.Link(splitmuxsink) 296 - if err != nil { 297 - return fmt.Errorf("error linking videoparse to splitmuxsink: %w", err) 298 - } 299 - 300 - audioenc, err := pipeline.GetElementByName("audioenc") 301 - if err != nil { 302 - return fmt.Errorf("error getting audioenc from ToHLS pipeline: %w", err) 303 - } 304 - err = audioenc.Link(splitmuxsink) 305 - if err != nil { 306 - return fmt.Errorf("error linking audioenc to splitmuxsink: %w", err) 307 - } 308 - 309 - splitmuxsink.Connect("sink-added", func(split, sinkEle *gst.Element) { 310 - vf, err := m3u8.GetNextSegment(ctx) 311 - if err != nil { 312 - panic(err) 313 - } 314 - appsink := app.SinkFromElement(sinkEle) 315 - appsink.SetCallbacks(&app.SinkCallbacks{ 316 - NewSampleFunc: WriterNewSample(ctx, vf.Buf), 317 - EOSFunc: func(sink *app.Sink) { 318 - m3u8.CloseSegment(ctx, vf) 319 - }, 320 - }) 321 - }) 322 - 323 - appsrc, err := pipeline.GetElementByName("appsrc") 324 - if err != nil { 325 - return err 326 - } 327 - 328 - src := app.SrcFromElement(appsrc) 329 - src.SetCallbacks(&app.SourceCallbacks{ 330 - NeedDataFunc: ReaderNeedData(ctx, input), 331 - }) 332 - 333 - onPadAdded := func(element *gst.Element, pad *gst.Pad) { 334 - caps := pad.GetCurrentCaps() 335 - if caps == nil { 336 - fmt.Println("Unable to get pad caps") 337 - return 338 - } 339 - 340 - fmt.Printf("New pad added: %s\n", pad.GetName()) 341 - fmt.Printf("Caps: %s\n", caps.String()) 342 - 343 - structure := caps.GetStructureAt(0) 344 - if structure == nil { 345 - fmt.Println("Unable to get structure from caps") 346 - return 347 - } 348 - 349 - name := structure.Name() 350 - fmt.Printf("Structure Name: %s\n", name) 351 - 352 - if name[:5] == "video" { 353 - // Get some common video properties 354 - widthVal, _ := structure.GetValue("width") 355 - heightVal, _ := structure.GetValue("height") 356 - 357 - width, ok := widthVal.(int) 358 - if ok { 359 - m3u8.Width = uint64(width) 360 - } 361 - height, ok := heightVal.(int) 362 - if ok { 363 - m3u8.Height = uint64(height) 364 - } 365 - // framerate, ok := framerateVal.(string) 366 - // if ok { 367 - // fmt.Printf(" Framerate: %s\n", framerate) 368 - // } 369 - // pixelAspectRatio, ok := pixelAspectRatioVal.(string) 370 - // if ok { 371 - // fmt.Printf(" Pixel Aspect Ratio: %s\n", pixelAspectRatio) 372 - // } 373 - // if codecVal != nil { 374 - // fmt.Printf(" Has codec data: true\n") 375 - // } 376 - } 377 - 378 - // if name[:5] == "audio" { 379 - // // Get some common audio properties 380 - // rateVal, _ := structure.GetValue("rate") 381 - // channelsVal, _ := structure.GetValue("channels") 382 - // formatVal, err := structure.GetValue("format") 383 - // mpegversion, _ := structure.GetValue("mpegversion") 384 - // log.Log(ctx, "format error", "error", err, "mpegversion", mpegversion) 385 - 386 - // fmt.Printf(" Structure: %s\n", structure.String()) 387 - // rate, ok := rateVal.(int) 388 - // if ok { 389 - // fmt.Printf(" Rate: %d\n", rate) 390 - // } 391 - // channels, ok := channelsVal.(int) 392 - // if ok { 393 - // fmt.Printf(" Channels: %d\n", channels) 394 - // } 395 - // format, ok := formatVal.(int) 396 - // if ok { 397 - // fmt.Printf(" Format: %d\n", format) 398 - // } 399 - 400 - // } 401 - } 402 - 403 - demux, err := pipeline.GetElementByName("demux") 404 - if err != nil { 405 - return err 406 - } 407 - demux.Connect("pad-added", onPadAdded) 408 - 409 - ctx, cancel := context.WithCancel(ctx) 410 - defer cancel() 411 - go func() { 412 - HandleBusMessagesCustom(ctx, pipeline, func(msg *gst.Message) { 413 - switch msg.Type() { 414 - case gst.MessageElement: 415 - structure := msg.GetStructure() 416 - name := structure.Name() 417 - if name == "splitmuxsink-fragment-opened" { 418 - runningTime, err := structure.GetValue("running-time") 419 - if err != nil { 420 - log.Warn(ctx, "splitmuxsink-fragment-opened error", "error", err) 421 - cancel() 422 - } 423 - runningTimeInt, ok := runningTime.(uint64) 424 - if !ok { 425 - log.Warn(ctx, "splitmuxsink-fragment-opened not a uint64") 426 - cancel() 427 - } 428 - m3u8.FragmentOpened(ctx, runningTimeInt) 429 - } 430 - if name == "splitmuxsink-fragment-closed" { 431 - runningTime, err := structure.GetValue("running-time") 432 - if err != nil { 433 - log.Warn(ctx, "splitmuxsink-fragment-closed error", "error", err) 434 - cancel() 435 - } 436 - runningTimeInt, ok := runningTime.(uint64) 437 - if !ok { 438 - log.Warn(ctx, "splitmuxsink-fragment-closed not a uint64") 439 - cancel() 440 - } 441 - m3u8.FragmentClosed(ctx, runningTimeInt) 442 - } 443 - } 444 - }) 445 - cancel() 446 - }() 447 - 448 - // Start the pipeline 449 - pipeline.SetState(gst.StatePlaying) 450 - 451 - <-ctx.Done() 452 - 453 - pipeline.BlockSetState(gst.StateNull) 454 - 455 - return nil 456 - } 457 - 458 233 func (mm *MediaManager) IngestStream(ctx context.Context, input io.Reader, ms MediaSigner) error { 459 234 ctx, cancel := context.WithCancel(ctx) 460 235 defer cancel() ··· 643 418 644 419 return g.Wait() 645 420 } 646 - 647 - // element that takes the input stream, muxes to mp4, and signs the result 648 - func (mm *MediaManager) SegmentAndSignElem(ctx context.Context, ms MediaSigner) (*gst.Element, error) { 649 - // elem, err := gst.NewElement("splitmuxsink name=splitter async-finalize=true sink-factory=appsink muxer-factory=matroskamux max-size-bytes=1") 650 - elem, err := gst.NewElementWithProperties("splitmuxsink", map[string]any{ 651 - "name": "signer", 652 - "async-finalize": true, 653 - "sink-factory": "appsink", 654 - "muxer-factory": "mp4mux", 655 - "max-size-bytes": 1, 656 - }) 657 - if err != nil { 658 - return nil, err 659 - } 660 - 661 - p := elem.GetRequestPad("video") 662 - if p == nil { 663 - return nil, fmt.Errorf("failed to get video pad") 664 - } 665 - p = elem.GetRequestPad("audio_%u") 666 - if p == nil { 667 - return nil, fmt.Errorf("failed to get audio pad") 668 - } 669 - 670 - resetTimer := make(chan struct{}) 671 - 672 - go func() { 673 - for { 674 - select { 675 - case <-ctx.Done(): 676 - return 677 - case <-resetTimer: 678 - continue 679 - case <-time.After(time.Second * 10): 680 - log.Warn(ctx, "no new segment for 10 seconds") 681 - elem.ErrorMessage(gst.DomainCore, gst.CoreErrorFailed, "No new segment for 10 seconds", "No new segment for 10 seconds (debug)") 682 - return 683 - } 684 - } 685 - }() 686 - 687 - elem.Connect("sink-added", func(split, sinkEle *gst.Element) { 688 - buf := &bytes.Buffer{} 689 - appsink := app.SinkFromElement(sinkEle) 690 - if appsink == nil { 691 - panic("appsink should not be nil") 692 - } 693 - appsink.SetCallbacks(&app.SinkCallbacks{ 694 - NewSampleFunc: WriterNewSample(ctx, buf), 695 - EOSFunc: func(sink *app.Sink) { 696 - resetTimer <- struct{}{} 697 - bs, err := ms.SignMP4(ctx, bytes.NewReader(buf.Bytes()), time.Now().UnixMilli()) 698 - if err != nil { 699 - log.Error(ctx, "error signing segment", "error", err) 700 - return 701 - } 702 - err = mm.ValidateMP4(ctx, bytes.NewReader(bs)) 703 - if err != nil { 704 - log.Error(ctx, "error validating segment", "error", err) 705 - return 706 - } 707 - }, 708 - }) 709 - }) 710 - 711 - return elem, nil 712 - } 713 - 714 - func (mm *MediaManager) Thumbnail(ctx context.Context, r io.Reader, w io.Writer) error { 715 - ctx = log.WithLogValues(ctx, "function", "Thumbnail") 716 - ctx, cancel := context.WithCancel(ctx) 717 - defer cancel() 718 - 719 - pipelineSlice := []string{ 720 - "appsrc name=appsrc ! qtdemux ! decodebin ! videoconvert ! videoscale ! video/x-raw,width=[1,720],height=[1,720],pixel-aspect-ratio=1/1 ! pngenc snapshot=true ! appsink name=appsink", 721 - } 722 - 723 - pipeline, err := gst.NewPipelineFromString(strings.Join(pipelineSlice, "\n")) 724 - if err != nil { 725 - return fmt.Errorf("error creating Thumbnail pipeline: %w", err) 726 - } 727 - appsrc, err := pipeline.GetElementByName("appsrc") 728 - if err != nil { 729 - return err 730 - } 731 - 732 - src := app.SrcFromElement(appsrc) 733 - src.SetCallbacks(&app.SourceCallbacks{ 734 - NeedDataFunc: ReaderNeedData(ctx, r), 735 - }) 736 - 737 - appsink, err := pipeline.GetElementByName("appsink") 738 - if err != nil { 739 - return err 740 - } 741 - 742 - go func() { 743 - HandleBusMessages(ctx, pipeline) 744 - cancel() 745 - }() 746 - 747 - sink := app.SinkFromElement(appsink) 748 - sink.SetCallbacks(&app.SinkCallbacks{ 749 - NewSampleFunc: WriterNewSample(ctx, w), 750 - EOSFunc: func(sink *app.Sink) { 751 - cancel() 752 - }, 753 - }) 754 - 755 - pipeline.SetState(gst.StatePlaying) 756 - 757 - <-ctx.Done() 758 - 759 - pipeline.BlockSetState(gst.StateNull) 760 - 761 - return nil 762 - } 763 - 764 - func (mm *MediaManager) MP4Playback(ctx context.Context, user string, w io.Writer) error { 765 - uu, err := uuid.NewV7() 766 - if err != nil { 767 - return err 768 - } 769 - ctx = log.WithLogValues(ctx, "playbackID", uu.String()) 770 - ctx, cancel := context.WithCancel(ctx) 771 - 772 - ctx = log.WithLogValues(ctx, "mediafunc", "MP4Playback") 773 - 774 - pipelineSlice := []string{ 775 - "mp4mux name=muxer fragment-mode=first-moov-then-finalise fragment-duration=1000 streamable=true ! appsink name=mp4sink", 776 - "h264parse name=videoparse ! muxer.", 777 - "opusparse name=audioparse ! muxer.", 778 - } 779 - 780 - pipeline, err := gst.NewPipelineFromString(strings.Join(pipelineSlice, "\n")) 781 - if err != nil { 782 - return fmt.Errorf("failed to create GStreamer pipeline: %w", err) 783 - } 784 - 785 - go func() { 786 - HandleBusMessages(ctx, pipeline) 787 - cancel() 788 - }() 789 - 790 - outputQueue, done, err := ConcatStream(ctx, pipeline, user, mm) 791 - if err != nil { 792 - return fmt.Errorf("failed to get output queue: %w", err) 793 - } 794 - go func() { 795 - select { 796 - case <-ctx.Done(): 797 - return 798 - case <-done: 799 - cancel() 800 - } 801 - }() 802 - 803 - videoParse, err := pipeline.GetElementByName("videoparse") 804 - if err != nil { 805 - return fmt.Errorf("failed to get video sink element from pipeline: %w", err) 806 - } 807 - err = outputQueue.Link(videoParse) 808 - if err != nil { 809 - return fmt.Errorf("failed to link output queue to video parse: %w", err) 810 - } 811 - 812 - audioParse, err := pipeline.GetElementByName("audioparse") 813 - if err != nil { 814 - return fmt.Errorf("failed to get audio parse element from pipeline: %w", err) 815 - } 816 - err = outputQueue.Link(audioParse) 817 - if err != nil { 818 - return fmt.Errorf("failed to link output queue to audio parse: %w", err) 819 - } 820 - 821 - go func() { 822 - ticker := time.NewTicker(time.Second * 1) 823 - for { 824 - select { 825 - case <-ctx.Done(): 826 - return 827 - case <-ticker.C: 828 - state := pipeline.GetCurrentState() 829 - log.Debug(ctx, "pipeline state", "state", state) 830 - } 831 - } 832 - }() 833 - 834 - mp4sinkele, err := pipeline.GetElementByName("mp4sink") 835 - if err != nil { 836 - return fmt.Errorf("failed to get video sink element from pipeline: %w", err) 837 - } 838 - mp4sink := app.SinkFromElement(mp4sinkele) 839 - mp4sink.SetCallbacks(&app.SinkCallbacks{ 840 - NewSampleFunc: WriterNewSample(ctx, w), 841 - EOSFunc: func(sink *app.Sink) { 842 - log.Warn(ctx, "mp4sink EOSFunc") 843 - cancel() 844 - }, 845 - }) 846 - 847 - pipeline.SetState(gst.StatePlaying) 848 - 849 - <-ctx.Done() 850 - 851 - pipeline.BlockSetState(gst.StateNull) 852 - 853 - return nil 854 - } 855 - 856 - func (mm *MediaManager) MKVPlayback(ctx context.Context, user string, w io.Writer) error { 857 - uu, err := uuid.NewV7() 858 - if err != nil { 859 - return err 860 - } 861 - ctx = log.WithLogValues(ctx, "playbackID", uu.String()) 862 - ctx, cancel := context.WithCancel(ctx) 863 - 864 - ctx = log.WithLogValues(ctx, "mediafunc", "MKVPlayback") 865 - 866 - pipelineSlice := []string{ 867 - "matroskamux name=muxer streamable=true ! appsink name=mkvsink", 868 - "h264parse name=videoparse ! muxer.", 869 - "opusparse name=audioparse ! muxer.", 870 - } 871 - 872 - pipeline, err := gst.NewPipelineFromString(strings.Join(pipelineSlice, "\n")) 873 - if err != nil { 874 - return fmt.Errorf("failed to create GStreamer pipeline: %w", err) 875 - } 876 - 877 - go func() { 878 - HandleBusMessages(ctx, pipeline) 879 - cancel() 880 - }() 881 - 882 - outputQueue, done, err := ConcatStream(ctx, pipeline, user, mm) 883 - if err != nil { 884 - return fmt.Errorf("failed to get output queue: %w", err) 885 - } 886 - go func() { 887 - select { 888 - case <-ctx.Done(): 889 - return 890 - case <-done: 891 - cancel() 892 - } 893 - }() 894 - 895 - videoParse, err := pipeline.GetElementByName("videoparse") 896 - if err != nil { 897 - return fmt.Errorf("failed to get video sink element from pipeline: %w", err) 898 - } 899 - err = outputQueue.Link(videoParse) 900 - if err != nil { 901 - return fmt.Errorf("failed to link output queue to video parse: %w", err) 902 - } 903 - 904 - audioParse, err := pipeline.GetElementByName("audioparse") 905 - if err != nil { 906 - return fmt.Errorf("failed to get audio parse element from pipeline: %w", err) 907 - } 908 - err = outputQueue.Link(audioParse) 909 - if err != nil { 910 - return fmt.Errorf("failed to link output queue to audio parse: %w", err) 911 - } 912 - 913 - go func() { 914 - ticker := time.NewTicker(time.Second * 1) 915 - for { 916 - select { 917 - case <-ctx.Done(): 918 - return 919 - case <-ticker.C: 920 - state := pipeline.GetCurrentState() 921 - log.Debug(ctx, "pipeline state", "state", state) 922 - } 923 - } 924 - }() 925 - 926 - mkvsinkele, err := pipeline.GetElementByName("mkvsink") 927 - if err != nil { 928 - return fmt.Errorf("failed to get video sink element from pipeline: %w", err) 929 - } 930 - mkvsink := app.SinkFromElement(mkvsinkele) 931 - mkvsink.SetCallbacks(&app.SinkCallbacks{ 932 - NewSampleFunc: WriterNewSample(ctx, w), 933 - EOSFunc: func(sink *app.Sink) { 934 - log.Warn(ctx, "mp4sink EOSFunc") 935 - cancel() 936 - }, 937 - }) 938 - 939 - pipeline.SetState(gst.StatePlaying) 940 - 941 - <-ctx.Done() 942 - 943 - pipeline.BlockSetState(gst.StateNull) 944 - 945 - return nil 946 - } 947 - 948 - func (mm *MediaManager) ParseSegmentMediaData(ctx context.Context, mp4bs []byte) (*model.SegmentMediaData, error) { 949 - ctx = log.WithLogValues(ctx, "GStreamerFunc", "ParseSegmentMediaData") 950 - ctx, cancel := context.WithCancel(ctx) 951 - defer cancel() 952 - pipelineSlice := []string{ 953 - "appsrc name=appsrc ! qtdemux name=demux ! fakesink", 954 - } 955 - 956 - pipeline, err := gst.NewPipelineFromString(strings.Join(pipelineSlice, "\n")) 957 - if err != nil { 958 - return nil, fmt.Errorf("error creating SegmentMetadata pipeline: %w", err) 959 - } 960 - 961 - var videoMetadata *model.SegmentMediadataVideo 962 - var audioMetadata *model.SegmentMediadataAudio 963 - 964 - appsrc, err := pipeline.GetElementByName("appsrc") 965 - if err != nil { 966 - return nil, fmt.Errorf("error creating SegmentMetadata pipeline: %w", err) 967 - } 968 - 969 - src := app.SrcFromElement(appsrc) 970 - src.SetCallbacks(&app.SourceCallbacks{ 971 - NeedDataFunc: ReaderNeedData(ctx, bytes.NewReader(mp4bs)), 972 - }) 973 - 974 - onPadAdded := func(element *gst.Element, pad *gst.Pad) { 975 - caps := pad.GetCurrentCaps() 976 - if caps == nil { 977 - log.Warn(ctx, "Unable to get pad caps") 978 - cancel() 979 - return 980 - } 981 - 982 - structure := caps.GetStructureAt(0) 983 - if structure == nil { 984 - log.Warn(ctx, "Unable to get structure from caps") 985 - cancel() 986 - return 987 - } 988 - 989 - name := structure.Name() 990 - log.Debug(ctx, "Structure Name", "name", name) 991 - 992 - if name[:5] == "video" { 993 - videoMetadata = &model.SegmentMediadataVideo{} 994 - // Get some common video properties 995 - widthVal, _ := structure.GetValue("width") 996 - heightVal, _ := structure.GetValue("height") 997 - 998 - width, ok := widthVal.(int) 999 - if ok { 1000 - videoMetadata.Width = width 1001 - } 1002 - height, ok := heightVal.(int) 1003 - if ok { 1004 - videoMetadata.Height = height 1005 - } 1006 - framerateVal, _ := structure.GetValue("framerate") 1007 - framerateStr := fmt.Sprintf("%v", framerateVal) 1008 - if framerateStr != "" { 1009 - videoMetadata.Framerate = framerateStr 1010 - } 1011 - } 1012 - 1013 - if name[:5] == "audio" { 1014 - audioMetadata = &model.SegmentMediadataAudio{} 1015 - // Get some common audio properties 1016 - rateVal, _ := structure.GetValue("rate") 1017 - channelsVal, _ := structure.GetValue("channels") 1018 - 1019 - rate, ok := rateVal.(int) 1020 - if ok { 1021 - audioMetadata.Rate = rate 1022 - } 1023 - channels, ok := channelsVal.(int) 1024 - if ok { 1025 - audioMetadata.Channels = channels 1026 - } 1027 - } 1028 - 1029 - if videoMetadata != nil && audioMetadata != nil { 1030 - cancel() 1031 - } 1032 - } 1033 - 1034 - demux, err := pipeline.GetElementByName("demux") 1035 - if err != nil { 1036 - return nil, fmt.Errorf("error creating SegmentMetadata pipeline: %w", err) 1037 - } 1038 - demux.Connect("pad-added", onPadAdded) 1039 - 1040 - go func() { 1041 - HandleBusMessages(ctx, pipeline) 1042 - cancel() 1043 - }() 1044 - 1045 - // Start the pipeline 1046 - pipeline.SetState(gst.StatePlaying) 1047 - 1048 - <-ctx.Done() 1049 - 1050 - meta := &model.SegmentMediaData{ 1051 - Video: []*model.SegmentMediadataVideo{videoMetadata}, 1052 - Audio: []*model.SegmentMediadataAudio{audioMetadata}, 1053 - } 1054 - 1055 - pipeline.BlockSetState(gst.StateNull) 1056 - 1057 - return meta, nil 1058 - }
+123 -116
pkg/media/m3u8.go
··· 6 6 "fmt" 7 7 "math" 8 8 "strings" 9 + "sync" 9 10 "time" 10 11 11 12 "github.com/google/uuid" 12 13 "stream.place/streamplace/pkg/log" 14 + "stream.place/streamplace/pkg/renditions" 13 15 ) 14 16 15 17 // how many segments are served in the live playlist? ··· 17 19 18 20 // how long should we keep old segments around? 19 21 const RETAIN_SEGMENT_SIZE = LIVE_PLAYLIST_SIZE * 3 22 + 23 + const INDEX_M3U8 = "index.m3u8" 20 24 21 25 type Segment struct { 22 - MSN uint64 // media sequence number 23 - Buf *bytes.Buffer 24 - StartTime *uint64 25 - EndTime *uint64 26 - Closed bool 27 - } 28 - 29 - func (s *Segment) Duration() time.Duration { 30 - return time.Duration(*s.EndTime - *s.StartTime) 26 + MSN uint64 // media sequence number 27 + Duration time.Duration 28 + Buf *bytes.Buffer 29 + Time time.Time 30 + Closed bool 31 + StartTS *uint64 32 + EndTS *uint64 31 33 } 32 34 33 35 type M3U8 struct { 34 36 curSeg uint64 35 - segments []*Segment 36 37 pendingSegments []*Segment 37 38 waits []chan struct{} 38 - Bitrate uint64 39 - Width uint64 40 - Height uint64 41 - } 42 - 43 - func NewM3U8() *M3U8 { 44 - return &M3U8{ 45 - curSeg: 0, 46 - } 39 + renditions []*M3U8Rendition 47 40 } 48 41 49 - func (m *M3U8) GetNextSegment(ctx context.Context) (*Segment, error) { 50 - log.Debug(ctx, "next segment") 51 - msn := m.curSeg 52 - m.curSeg += 1 53 - seg := &Segment{ 54 - MSN: msn, 55 - Buf: &bytes.Buffer{}, 56 - } 57 - m.pendingSegments = append(m.pendingSegments, seg) 58 - return seg, nil 59 - } 60 - 61 - func (m *M3U8) CloseSegment(ctx context.Context, seg *Segment) { 62 - log.Debug(ctx, "close segment", "MSN", seg.MSN) 63 - seg.Closed = true 64 - m.checkSegments(ctx) 42 + type M3U8Rendition struct { 43 + Rendition renditions.Rendition 44 + Segments []*Segment 45 + SegmentLock sync.RWMutex 46 + MSN uint64 65 47 } 66 48 67 - func (m *M3U8) FragmentOpened(ctx context.Context, t uint64) error { 68 - log.Debug(ctx, "fragment opened", "time", t) 69 - if len(m.pendingSegments) == 0 { 70 - return fmt.Errorf("no pending segments") 71 - } 72 - for _, seg := range m.pendingSegments { 73 - if seg.StartTime == nil { 74 - seg.StartTime = &t 75 - break 49 + func NewM3U8(renditions renditions.Renditions) *M3U8 { 50 + rends := []*M3U8Rendition{} 51 + for _, r := range renditions { 52 + mr := &M3U8Rendition{ 53 + Rendition: r, 76 54 } 55 + rends = append(rends, mr) 77 56 } 78 - m.checkSegments(ctx) 79 - return nil 57 + return &M3U8{ 58 + curSeg: 0, 59 + renditions: rends, 60 + } 80 61 } 81 62 82 - func (m *M3U8) FragmentClosed(ctx context.Context, t uint64) error { 83 - log.Debug(ctx, "fragment closed", "time", t) 84 - if len(m.pendingSegments) == 0 { 85 - return fmt.Errorf("no pending segments") 86 - } 87 - for _, seg := range m.pendingSegments { 88 - if seg.EndTime == nil { 89 - seg.EndTime = &t 90 - if m.Bitrate == 0 { 91 - dur := seg.Duration() 92 - m.Bitrate = uint64(float64(seg.Buf.Len())/dur.Seconds()) * 8 93 - } 94 - break 95 - } 96 - } 97 - m.checkSegments(ctx) 98 - return nil 63 + func (r *M3U8Rendition) GetMediaLine(session string) string { 64 + // m.waitForStart() 65 + lines := []string{} 66 + lines = append(lines, "#EXTM3U") 67 + lines = append(lines, fmt.Sprintf("#EXT-X-STREAM-INF:BANDWIDTH=%d,RESOLUTION=%dx%d", r.Rendition.Bitrate, r.Rendition.Width, r.Rendition.Height)) 68 + lines = append(lines, fmt.Sprintf("%s/%s?session=%s", r.Rendition.Name, INDEX_M3U8, session)) 69 + return strings.Join(lines, "\n") 99 70 } 100 71 101 - // the tricky piece of the design here is that we need to expect GetNextSegment, 102 - // CloseSegment, FragmentOpened, and FragmentClosed to be called in any order. So 103 - // all of those functions call this one, and it checks if we have the necessary information 104 - // to finalize a segment and add it to our playlist. 105 - func (m *M3U8) checkSegments(ctx context.Context) { 106 - pending := m.pendingSegments[0] 107 - if pending.StartTime != nil && pending.EndTime != nil && pending.Closed { 108 - m.segments = append(m.segments, pending) 109 - m.pendingSegments = m.pendingSegments[1:] 110 - log.Debug(ctx, "finalizing segment", "MSN", pending.MSN) 111 - for _, wait := range m.waits { 112 - go func(wait chan struct{}) { 113 - wait <- struct{}{} 114 - }(wait) 72 + func (r *M3U8Rendition) GetPlaylist(session string) []byte { 73 + if session == "" { 74 + uu, err := uuid.NewV7() 75 + if err != nil { 76 + panic(err) 115 77 } 116 - m.waits = []chan struct{}{} 78 + session = uu.String() 117 79 } 118 - if len(m.segments) > RETAIN_SEGMENT_SIZE { 119 - startWith := len(m.segments) - RETAIN_SEGMENT_SIZE 120 - m.segments = m.segments[startWith:] 121 - } 122 - } 123 - 124 - func (m *M3U8) waitForStart() { 125 - if len(m.segments) == 0 { 126 - // todo: fix concurrent access here 127 - wait := make(chan struct{}) 128 - m.waits = append(m.waits, wait) 129 - <-wait 130 - } 131 - } 132 - 133 - func (m *M3U8) GetPlaylist(session string) []byte { 134 - m.waitForStart() 80 + r.SegmentLock.RLock() 81 + defer r.SegmentLock.RUnlock() 82 + // m.waitForStart() 135 83 lines := []string{} 136 84 lines = append(lines, "#EXTM3U") 137 85 lines = append(lines, "#EXT-X-VERSION:3") 138 - startWith := len(m.segments) - LIVE_PLAYLIST_SIZE 86 + startWith := len(r.Segments) - LIVE_PLAYLIST_SIZE 139 87 if startWith < 0 { 140 88 startWith = 0 141 89 } 142 - firstSeg := m.segments[startWith] 143 - lastSeg := m.segments[len(m.segments)-1] 144 - targetDuration := int64(math.Round(lastSeg.Duration().Seconds())) 90 + if len(r.Segments) == 0 { 91 + return []byte{} 92 + } 93 + firstSeg := r.Segments[startWith] 94 + lastSeg := r.Segments[len(r.Segments)-1] 95 + targetDuration := int64(math.Round(lastSeg.Duration.Seconds())) 145 96 lines = append(lines, fmt.Sprintf("#EXT-X-MEDIA-SEQUENCE:%d", firstSeg.MSN)) 146 - lines = append(lines, fmt.Sprintf("#EXT-X-TARGETDURATION:%d", targetDuration)) 97 + lines = append(lines, fmt.Sprintf("#EXT-X-DISCONTINUITY-SEQUENCE:%d", firstSeg.MSN)) 98 + lines = append(lines, fmt.Sprintf("#EXT-X-TARGETDURATION:%d", targetDuration+1)) 99 + lines = append(lines, "#EXT-X-INDEPENDENT-SEGMENTS") 147 100 lines = append(lines, "") 148 - lastSegments := m.segments[startWith:] 101 + lastSegments := r.Segments[startWith:] 149 102 for _, seg := range lastSegments { 150 - dur := seg.Duration() 103 + dur := seg.Duration 104 + lines = append(lines, "#EXT-X-DISCONTINUITY") 105 + lines = append(lines, fmt.Sprintf("#EXT-X-PROGRAM-DATE-TIME:%s", seg.Time.Format(time.RFC3339Nano))) 151 106 lines = append(lines, fmt.Sprintf("#EXTINF:%f,", dur.Seconds())) 152 107 lines = append(lines, fmt.Sprintf("segment%05d.ts?session=%s", seg.MSN, session)) 153 108 } ··· 155 110 return []byte(strings.Join(lines, "\n")) 156 111 } 157 112 158 - func (m *M3U8) GetMultivariantPlaylist() []byte { 113 + func (r *M3U8Rendition) GetSegment(session string, filename string) []byte { 114 + r.SegmentLock.RLock() 115 + defer r.SegmentLock.RUnlock() 116 + for _, seg := range r.Segments { 117 + if fmt.Sprintf("segment%05d.ts", seg.MSN) == filename { 118 + return seg.Buf.Bytes() 119 + } 120 + } 121 + return nil 122 + } 123 + 124 + func (m *M3U8) GetMultivariantPlaylist(rendition string) []byte { 159 125 uu, err := uuid.NewV7() 160 126 if err != nil { 161 127 panic(err) 162 128 } 163 - m.waitForStart() 129 + // m.waitForStart() 164 130 lines := []string{} 165 131 lines = append(lines, "#EXTM3U") 166 - lines = append(lines, fmt.Sprintf("#EXT-X-STREAM-INF:BANDWIDTH=%d,RESOLUTION=%dx%d", m.Bitrate, m.Width, m.Height)) 167 - lines = append(lines, fmt.Sprintf("media.m3u8?session=%s", uu.String())) 132 + for _, r := range m.renditions { 133 + if rendition == "" || r.Rendition.Name == rendition { 134 + lines = append(lines, r.GetMediaLine(uu.String())) 135 + } 136 + } 168 137 return []byte(strings.Join(lines, "\n")) 169 138 } 170 139 171 - // takes segment00015.ts and returns the corresponding segment 172 - func (m *M3U8) GetSegment(str string, session string) ([]byte, error) { 173 - if str == "stream.m3u8" { 174 - return m.GetMultivariantPlaylist(), nil 140 + // needs to handle: 141 + // - index.m3u8 142 + // - 720p/stream.m3u8 143 + // - 720p/segment00015.ts 144 + func (m *M3U8) GetFile(str string, session string, rendition string) ([]byte, error) { 145 + str = strings.TrimPrefix(str, "/") 146 + if str == INDEX_M3U8 { 147 + return m.GetMultivariantPlaylist(rendition), nil 148 + } 149 + parts := strings.Split(str, "/") 150 + if len(parts) != 2 { 151 + return nil, fmt.Errorf("invalid path") 152 + } 153 + rStr := parts[0] 154 + fStr := parts[1] 155 + rend := m.GetRendition(rStr) 156 + log.Debug(context.Background(), "m3u8 get file", "str", str, "session", session, "rend", rStr, "file", fStr) 157 + if rend == nil { 158 + return nil, fmt.Errorf("rendition not found") 159 + } 160 + if fStr == INDEX_M3U8 { 161 + return rend.GetPlaylist(session), nil 162 + } 163 + seg := rend.GetSegment(session, fStr) 164 + if seg == nil { 165 + return nil, fmt.Errorf("segment not found") 175 166 } 176 - if str == "media.m3u8" { 177 - return m.GetPlaylist(session), nil 167 + return seg, nil 168 + } 169 + 170 + func (r *M3U8Rendition) NewSegment(seg *Segment) error { 171 + r.SegmentLock.Lock() 172 + defer r.SegmentLock.Unlock() 173 + seg.MSN = r.MSN 174 + r.MSN += 1 175 + r.Segments = append(r.Segments, seg) 176 + if len(r.Segments) > RETAIN_SEGMENT_SIZE { 177 + // Calculate how many segments to remove 178 + removeCount := len(r.Segments) - RETAIN_SEGMENT_SIZE 179 + // Remove the oldest segments (from the front of the slice) 180 + r.Segments = r.Segments[removeCount:] 178 181 } 179 - for _, seg := range m.segments { 180 - if fmt.Sprintf("segment%05d.ts", seg.MSN) == str { 181 - return seg.Buf.Bytes(), nil 182 + return nil 183 + } 184 + 185 + func (m *M3U8) GetRendition(rendition string) *M3U8Rendition { 186 + for _, r := range m.renditions { 187 + if r.Rendition.Name == rendition { 188 + return r 182 189 } 183 190 } 184 - return nil, fmt.Errorf("segment not found") 191 + return nil 185 192 }
+9 -241
pkg/media/media.go
··· 1 1 package media 2 2 3 3 import ( 4 - "bytes" 5 4 "context" 6 5 "crypto" 7 6 "encoding/json" 8 7 "errors" 9 8 "fmt" 10 9 "io" 11 - "strings" 12 10 "sync" 13 11 14 12 "github.com/go-gst/go-gst/gst" 15 13 "github.com/google/uuid" 16 - "github.com/livepeer/lpms/ffmpeg" 17 - "golang.org/x/sync/errgroup" 18 14 "stream.place/streamplace/pkg/aqtime" 19 15 "stream.place/streamplace/pkg/atproto" 20 16 "stream.place/streamplace/pkg/bus" 21 17 "stream.place/streamplace/pkg/config" 22 - "stream.place/streamplace/pkg/constants" 23 - "stream.place/streamplace/pkg/crypto/signers" 24 - "stream.place/streamplace/pkg/log" 18 + "stream.place/streamplace/pkg/media/segchanman" 25 19 "stream.place/streamplace/pkg/model" 26 20 27 21 "stream.place/streamplace/pkg/replication" 28 22 29 - "git.stream.place/streamplace/c2pa-go/pkg/c2pa" 30 23 "git.stream.place/streamplace/c2pa-go/pkg/c2pa/generated/manifeststore" 31 24 "github.com/piprate/json-gold/ld" 32 25 ) ··· 38 31 39 32 type MediaManager struct { 40 33 cli *config.CLI 41 - mp4subs map[string][]chan string 42 - mp4subsmut sync.Mutex 34 + segChanMan *segchanman.SegChanMan 43 35 replicator replication.Replicator 44 36 hlsRunning map[string]*M3U8 45 37 hlsRunningMut sync.Mutex ··· 71 63 } 72 64 return &MediaManager{ 73 65 cli: cli, 74 - mp4subs: map[string][]chan string{}, 66 + segChanMan: segchanman.MakeSegChanMan(), 75 67 replicator: rep, 76 68 hlsRunning: map[string]*M3U8{}, 77 69 httpPipes: map[string]io.Writer{}, ··· 116 108 } 117 109 118 110 // subscribe to the latest segments from a given user for livestreaming purposes 119 - func (mm *MediaManager) SubscribeSegment(ctx context.Context, user string) <-chan string { 120 - mm.mp4subsmut.Lock() 121 - defer mm.mp4subsmut.Unlock() 122 - _, ok := mm.mp4subs[user] 123 - if !ok { 124 - mm.mp4subs[user] = []chan string{} 125 - } 126 - c := make(chan string) 127 - mm.mp4subs[user] = append(mm.mp4subs[user], c) 128 - return c 111 + func (mm *MediaManager) SubscribeSegment(ctx context.Context, user string, rendition string) <-chan *segchanman.Seg { 112 + return mm.segChanMan.SubscribeSegment(ctx, user, rendition) 129 113 } 130 114 131 - func (mm *MediaManager) UnsubscribeSegment(ctx context.Context, user string, ch <-chan string) { 132 - mm.mp4subsmut.Lock() 133 - defer mm.mp4subsmut.Unlock() 134 - for i, c := range mm.mp4subs[user] { 135 - if c == ch { 136 - mm.mp4subs[user] = append(mm.mp4subs[user][:i], mm.mp4subs[user][i+1:]...) 137 - break 138 - } 139 - } 115 + func (mm *MediaManager) UnsubscribeSegment(ctx context.Context, user string, rendition string, ch <-chan *segchanman.Seg) { 116 + mm.segChanMan.UnsubscribeSegment(ctx, user, rendition, ch) 140 117 } 141 118 142 119 // subscribe to the latest segments from a given user for livestreaming purposes 143 - func (mm *MediaManager) PublishSegment(ctx context.Context, user, file string) { 144 - mm.mp4subsmut.Lock() 145 - defer mm.mp4subsmut.Unlock() 146 - for _, sub := range mm.mp4subs[user] { 147 - go func() { 148 - sub <- file 149 - }() 150 - } 151 - mm.mp4subs[user] = []chan string{} 152 - } 153 - 154 - func (mm *MediaManager) SegmentToMKV(ctx context.Context, user string, w io.Writer) error { 155 - muxer := ffmpeg.ComponentOptions{ 156 - Name: "matroska", 157 - } 158 - return mm.SegmentToStream(ctx, user, muxer, w) 159 - } 160 - 161 - func (mm *MediaManager) SegmentToMKVPlusOpus(ctx context.Context, user string, w io.Writer) error { 162 - muxer := ffmpeg.ComponentOptions{ 163 - Name: "matroska", 164 - } 165 - pr, pw := io.Pipe() 166 - g, ctx := errgroup.WithContext(ctx) 167 - g.Go(func() error { 168 - return mm.SegmentToStream(ctx, user, muxer, pw) 169 - }) 170 - g.Go(func() error { 171 - return AddOpusToMKV(ctx, pr, w) 172 - }) 173 - return g.Wait() 174 - } 175 - 176 - func (mm *MediaManager) SegmentToHLSOnce(ctx context.Context, user string) (*M3U8, error) { 177 - mm.hlsRunningMut.Lock() 178 - defer mm.hlsRunningMut.Unlock() 179 - hls, ok := mm.hlsRunning[user] 180 - if !ok { 181 - hls = NewM3U8() 182 - mm.hlsRunning[user] = hls 183 - go func() { 184 - err := mm.SegmentToHLS(ctx, user, hls) 185 - if err != nil { 186 - log.Log(ctx, "error in async segmentToHLS code", "error", err) 187 - } 188 - mm.hlsRunningMut.Lock() 189 - defer mm.hlsRunningMut.Unlock() 190 - delete(mm.hlsRunning, user) 191 - }() 192 - } 193 - return hls, nil 194 - } 195 - 196 - func (mm *MediaManager) SegmentToHLS(ctx context.Context, user string, m3u8 *M3U8) error { 197 - muxer := ffmpeg.ComponentOptions{ 198 - Name: "matroska", 199 - } 200 - 201 - pr, pw := io.Pipe() 202 - g, ctx := errgroup.WithContext(ctx) 203 - g.Go(func() error { 204 - return mm.SegmentToStream(ctx, user, muxer, pw) 205 - }) 206 - g.Go(func() error { 207 - return mm.ToHLS(ctx, pr, m3u8) 208 - }) 209 - return g.Wait() 210 - } 211 - 212 - func (mm *MediaManager) SegmentToMP4(ctx context.Context, user string, w io.Writer) error { 213 - muxer := ffmpeg.ComponentOptions{ 214 - Name: "mp4", 215 - Opts: map[string]string{ 216 - "movflags": "frag_keyframe+empty_moov", 217 - }, 218 - } 219 - return mm.SegmentToStream(ctx, user, muxer, w) 220 - } 221 - 222 - func (mm *MediaManager) SegmentToStream(ctx context.Context, user string, muxer ffmpeg.ComponentOptions, w io.Writer) error { 223 - tc := ffmpeg.NewTranscoder() 224 - defer tc.StopTranscoder() 225 - ourl, or, odone, err := mm.HTTPPipe() 226 - if err != nil { 227 - return err 228 - } 229 - defer odone() 230 - iname := fmt.Sprintf("%s/playback/%s/concat", mm.cli.OwnInternalURL(), user) 231 - in := &ffmpeg.TranscodeOptionsIn{ 232 - Fname: iname, 233 - Transmuxing: true, 234 - Profile: ffmpeg.VideoProfile{}, 235 - Loop: -1, 236 - Demuxer: ffmpeg.ComponentOptions{ 237 - Name: "concat", 238 - Opts: map[string]string{ 239 - "safe": "0", 240 - "protocol_whitelist": "file,http,https,tcp,tls", 241 - }, 242 - }, 243 - } 244 - out := []ffmpeg.TranscodeOptions{ 245 - { 246 - Oname: ourl, 247 - VideoEncoder: ffmpeg.ComponentOptions{ 248 - Name: "copy", 249 - }, 250 - AudioEncoder: ffmpeg.ComponentOptions{ 251 - Name: "copy", 252 - }, 253 - Profile: ffmpeg.VideoProfile{Format: ffmpeg.FormatNone}, 254 - Muxer: muxer, 255 - }, 256 - } 257 - g, _ := errgroup.WithContext(ctx) 258 - g.Go(func() error { 259 - <-ctx.Done() 260 - or.Close() 261 - return nil 262 - }) 263 - g.Go(func() error { 264 - _, err := tc.Transcode(in, out) 265 - tc.StopTranscoder() 266 - return err 267 - }) 268 - g.Go(func() error { 269 - _, err := io.Copy(w, or) 270 - return err 271 - }) 272 - return g.Wait() 120 + func (mm *MediaManager) PublishSegment(ctx context.Context, user, rendition string, seg *segchanman.Seg) { 121 + mm.segChanMan.PublishSegment(ctx, user, rendition, seg) 273 122 } 274 123 275 124 type obj map[string]any ··· 342 191 } 343 192 return &out, nil 344 193 } 345 - 346 - func (mm *MediaManager) ValidateMP4(ctx context.Context, input io.Reader) error { 347 - buf, err := io.ReadAll(input) 348 - if err != nil { 349 - return err 350 - } 351 - r := bytes.NewReader(buf) 352 - reader, err := c2pa.FromStream(r, "video/mp4") 353 - if err != nil { 354 - return err 355 - } 356 - mani := reader.GetActiveManifest() 357 - certs := reader.GetProvenanceCertChain() 358 - pub, err := signers.ParseES256KCert([]byte(certs)) 359 - if err != nil { 360 - return err 361 - } 362 - meta, err := ParseSegmentAssertions(mani) 363 - if err != nil { 364 - return err 365 - } 366 - mediaData, err := mm.ParseSegmentMediaData(ctx, buf) 367 - if err != nil { 368 - return err 369 - } 370 - // special case for test signers that are only signed with a key 371 - var repoDID string 372 - var signingKeyDID string 373 - if strings.HasPrefix(meta.Creator, constants.DID_KEY_PREFIX) { 374 - signingKeyDID = meta.Creator 375 - repoDID = meta.Creator 376 - } else { 377 - repo, err := mm.atsync.SyncBlueskyRepoCached(ctx, meta.Creator, mm.model) 378 - if err != nil { 379 - return err 380 - } 381 - signingKey, err := mm.model.GetSigningKey(pub.DIDKey(), repo.DID) 382 - if err != nil { 383 - return err 384 - } 385 - if signingKey == nil { 386 - return fmt.Errorf("no signing key found for %s", pub.DIDKey()) 387 - } 388 - repoDID = repo.DID 389 - signingKeyDID = signingKey.DID 390 - } 391 - 392 - err = mm.cli.StreamIsAllowed(repoDID) 393 - if err != nil { 394 - return fmt.Errorf("got valid segment, but user %s is not allowed: %w", repoDID, err) 395 - } 396 - fd, err := mm.cli.SegmentFileCreate(repoDID, meta.StartTime, "mp4") 397 - if err != nil { 398 - return err 399 - } 400 - defer fd.Close() 401 - go mm.replicator.NewSegment(ctx, buf) 402 - r = bytes.NewReader(buf) 403 - io.Copy(fd, r) 404 - go mm.PublishSegment(ctx, repoDID, fd.Name()) 405 - seg := &model.Segment{ 406 - ID: *mani.Label, 407 - SigningKeyDID: signingKeyDID, 408 - RepoDID: repoDID, 409 - StartTime: meta.StartTime.Time(), 410 - Title: meta.Title, 411 - MediaData: mediaData, 412 - } 413 - mm.newSegmentSubsMutex.RLock() 414 - defer mm.newSegmentSubsMutex.RUnlock() 415 - not := &NewSegmentNotification{ 416 - Segment: seg, 417 - Data: buf, 418 - Metadata: meta, 419 - } 420 - for _, ch := range mm.newSegmentSubs { 421 - go func() { ch <- not }() 422 - } 423 - log.Log(ctx, "successfully ingested segment", "user", repoDID, "signingKey", signingKeyDID, "timestamp", meta.StartTime, "segmentID", *mani.Label) 424 - return nil 425 - }
+140
pkg/media/media_data_parser.go
··· 1 + package media 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "strconv" 8 + "strings" 9 + 10 + "github.com/go-gst/go-gst/gst" 11 + "github.com/go-gst/go-gst/gst/app" 12 + "stream.place/streamplace/pkg/log" 13 + "stream.place/streamplace/pkg/model" 14 + ) 15 + 16 + func (mm *MediaManager) ParseSegmentMediaData(ctx context.Context, mp4bs []byte) (*model.SegmentMediaData, error) { 17 + ctx = log.WithLogValues(ctx, "GStreamerFunc", "ParseSegmentMediaData") 18 + ctx, cancel := context.WithCancel(ctx) 19 + defer cancel() 20 + pipelineSlice := []string{ 21 + "appsrc name=appsrc ! qtdemux name=demux ! fakesink", 22 + } 23 + 24 + pipeline, err := gst.NewPipelineFromString(strings.Join(pipelineSlice, "\n")) 25 + if err != nil { 26 + return nil, fmt.Errorf("error creating SegmentMetadata pipeline: %w", err) 27 + } 28 + 29 + var videoMetadata *model.SegmentMediadataVideo 30 + var audioMetadata *model.SegmentMediadataAudio 31 + 32 + appsrc, err := pipeline.GetElementByName("appsrc") 33 + if err != nil { 34 + return nil, fmt.Errorf("error creating SegmentMetadata pipeline: %w", err) 35 + } 36 + 37 + src := app.SrcFromElement(appsrc) 38 + src.SetCallbacks(&app.SourceCallbacks{ 39 + NeedDataFunc: ReaderNeedData(ctx, bytes.NewReader(mp4bs)), 40 + }) 41 + 42 + onPadAdded := func(element *gst.Element, pad *gst.Pad) { 43 + caps := pad.GetCurrentCaps() 44 + if caps == nil { 45 + log.Warn(ctx, "Unable to get pad caps") 46 + cancel() 47 + return 48 + } 49 + 50 + structure := caps.GetStructureAt(0) 51 + if structure == nil { 52 + log.Warn(ctx, "Unable to get structure from caps") 53 + cancel() 54 + return 55 + } 56 + 57 + name := structure.Name() 58 + 59 + if name[:5] == "video" { 60 + videoMetadata = &model.SegmentMediadataVideo{} 61 + // Get some common video properties 62 + widthVal, _ := structure.GetValue("width") 63 + heightVal, _ := structure.GetValue("height") 64 + 65 + width, ok := widthVal.(int) 66 + if ok { 67 + videoMetadata.Width = width 68 + } 69 + height, ok := heightVal.(int) 70 + if ok { 71 + videoMetadata.Height = height 72 + } 73 + framerateVal, _ := structure.GetValue("framerate") 74 + framerateStr := fmt.Sprintf("%v", framerateVal) 75 + parts := strings.Split(framerateStr, "/") 76 + num := 0 77 + den := 0 78 + if len(parts) == 2 { 79 + num, _ = strconv.Atoi(parts[0]) 80 + den, _ = strconv.Atoi(parts[1]) 81 + } 82 + if num != 0 && den != 0 { 83 + videoMetadata.FPSNum = num 84 + videoMetadata.FPSDen = den 85 + } 86 + } 87 + 88 + if name[:5] == "audio" { 89 + audioMetadata = &model.SegmentMediadataAudio{} 90 + // Get some common audio properties 91 + rateVal, _ := structure.GetValue("rate") 92 + channelsVal, _ := structure.GetValue("channels") 93 + 94 + rate, ok := rateVal.(int) 95 + if ok { 96 + audioMetadata.Rate = rate 97 + } 98 + channels, ok := channelsVal.(int) 99 + if ok { 100 + audioMetadata.Channels = channels 101 + } 102 + } 103 + 104 + // if videoMetadata != nil && audioMetadata != nil { 105 + // cancel() 106 + // } 107 + } 108 + 109 + demux, err := pipeline.GetElementByName("demux") 110 + if err != nil { 111 + return nil, fmt.Errorf("error creating SegmentMetadata pipeline: %w", err) 112 + } 113 + demux.Connect("pad-added", onPadAdded) 114 + 115 + go func() { 116 + HandleBusMessages(ctx, pipeline) 117 + cancel() 118 + }() 119 + 120 + // Start the pipeline 121 + pipeline.SetState(gst.StatePlaying) 122 + 123 + <-ctx.Done() 124 + 125 + meta := &model.SegmentMediaData{ 126 + Video: []*model.SegmentMediadataVideo{videoMetadata}, 127 + Audio: []*model.SegmentMediadataAudio{audioMetadata}, 128 + } 129 + 130 + ok, dur := pipeline.QueryDuration(gst.FormatTime) 131 + if !ok { 132 + return nil, fmt.Errorf("error getting duration") 133 + } else { 134 + meta.Duration = dur 135 + } 136 + 137 + pipeline.BlockSetState(gst.StateNull) 138 + 139 + return meta, nil 140 + }
+198
pkg/media/progressive.go
··· 1 + package media 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "strings" 8 + "time" 9 + 10 + "github.com/go-gst/go-gst/gst" 11 + "github.com/go-gst/go-gst/gst/app" 12 + "github.com/google/uuid" 13 + "stream.place/streamplace/pkg/log" 14 + ) 15 + 16 + func (mm *MediaManager) MP4Playback(ctx context.Context, user string, rendition string, w io.Writer) error { 17 + uu, err := uuid.NewV7() 18 + if err != nil { 19 + return err 20 + } 21 + ctx = log.WithLogValues(ctx, "playbackID", uu.String()) 22 + ctx, cancel := context.WithCancel(ctx) 23 + 24 + ctx = log.WithLogValues(ctx, "mediafunc", "MP4Playback") 25 + 26 + pipelineSlice := []string{ 27 + "mp4mux name=muxer fragment-mode=first-moov-then-finalise fragment-duration=1000 streamable=true ! appsink name=mp4sink", 28 + "h264parse name=videoparse ! muxer.", 29 + "opusparse name=audioparse ! muxer.", 30 + } 31 + 32 + pipeline, err := gst.NewPipelineFromString(strings.Join(pipelineSlice, "\n")) 33 + if err != nil { 34 + return fmt.Errorf("failed to create GStreamer pipeline: %w", err) 35 + } 36 + 37 + go func() { 38 + HandleBusMessages(ctx, pipeline) 39 + cancel() 40 + }() 41 + 42 + outputQueue, done, err := ConcatStream(ctx, pipeline, user, rendition, mm) 43 + if err != nil { 44 + return fmt.Errorf("failed to get output queue: %w", err) 45 + } 46 + go func() { 47 + select { 48 + case <-ctx.Done(): 49 + return 50 + case <-done: 51 + cancel() 52 + } 53 + }() 54 + 55 + videoParse, err := pipeline.GetElementByName("videoparse") 56 + if err != nil { 57 + return fmt.Errorf("failed to get video sink element from pipeline: %w", err) 58 + } 59 + err = outputQueue.Link(videoParse) 60 + if err != nil { 61 + return fmt.Errorf("failed to link output queue to video parse: %w", err) 62 + } 63 + 64 + audioParse, err := pipeline.GetElementByName("audioparse") 65 + if err != nil { 66 + return fmt.Errorf("failed to get audio parse element from pipeline: %w", err) 67 + } 68 + err = outputQueue.Link(audioParse) 69 + if err != nil { 70 + return fmt.Errorf("failed to link output queue to audio parse: %w", err) 71 + } 72 + 73 + go func() { 74 + ticker := time.NewTicker(time.Second * 1) 75 + for { 76 + select { 77 + case <-ctx.Done(): 78 + return 79 + case <-ticker.C: 80 + state := pipeline.GetCurrentState() 81 + log.Debug(ctx, "pipeline state", "state", state) 82 + } 83 + } 84 + }() 85 + 86 + mp4sinkele, err := pipeline.GetElementByName("mp4sink") 87 + if err != nil { 88 + return fmt.Errorf("failed to get video sink element from pipeline: %w", err) 89 + } 90 + mp4sink := app.SinkFromElement(mp4sinkele) 91 + mp4sink.SetCallbacks(&app.SinkCallbacks{ 92 + NewSampleFunc: WriterNewSample(ctx, w), 93 + EOSFunc: func(sink *app.Sink) { 94 + log.Warn(ctx, "mp4sink EOSFunc") 95 + cancel() 96 + }, 97 + }) 98 + 99 + pipeline.SetState(gst.StatePlaying) 100 + 101 + <-ctx.Done() 102 + 103 + pipeline.BlockSetState(gst.StateNull) 104 + 105 + return nil 106 + } 107 + 108 + func (mm *MediaManager) MKVPlayback(ctx context.Context, user string, rendition string, w io.Writer) error { 109 + uu, err := uuid.NewV7() 110 + if err != nil { 111 + return err 112 + } 113 + ctx = log.WithLogValues(ctx, "playbackID", uu.String()) 114 + ctx, cancel := context.WithCancel(ctx) 115 + 116 + ctx = log.WithLogValues(ctx, "mediafunc", "MKVPlayback") 117 + 118 + pipelineSlice := []string{ 119 + "matroskamux name=muxer streamable=true ! appsink name=mkvsink", 120 + "h264parse name=videoparse ! muxer.", 121 + "opusparse name=audioparse ! muxer.", 122 + } 123 + 124 + pipeline, err := gst.NewPipelineFromString(strings.Join(pipelineSlice, "\n")) 125 + if err != nil { 126 + return fmt.Errorf("failed to create GStreamer pipeline: %w", err) 127 + } 128 + 129 + go func() { 130 + HandleBusMessages(ctx, pipeline) 131 + cancel() 132 + }() 133 + 134 + outputQueue, done, err := ConcatStream(ctx, pipeline, user, rendition, mm) 135 + if err != nil { 136 + return fmt.Errorf("failed to get output queue: %w", err) 137 + } 138 + go func() { 139 + select { 140 + case <-ctx.Done(): 141 + return 142 + case <-done: 143 + cancel() 144 + } 145 + }() 146 + 147 + videoParse, err := pipeline.GetElementByName("videoparse") 148 + if err != nil { 149 + return fmt.Errorf("failed to get video sink element from pipeline: %w", err) 150 + } 151 + err = outputQueue.Link(videoParse) 152 + if err != nil { 153 + return fmt.Errorf("failed to link output queue to video parse: %w", err) 154 + } 155 + 156 + audioParse, err := pipeline.GetElementByName("audioparse") 157 + if err != nil { 158 + return fmt.Errorf("failed to get audio parse element from pipeline: %w", err) 159 + } 160 + err = outputQueue.Link(audioParse) 161 + if err != nil { 162 + return fmt.Errorf("failed to link output queue to audio parse: %w", err) 163 + } 164 + 165 + go func() { 166 + ticker := time.NewTicker(time.Second * 1) 167 + for { 168 + select { 169 + case <-ctx.Done(): 170 + return 171 + case <-ticker.C: 172 + state := pipeline.GetCurrentState() 173 + log.Debug(ctx, "pipeline state", "state", state) 174 + } 175 + } 176 + }() 177 + 178 + mkvsinkele, err := pipeline.GetElementByName("mkvsink") 179 + if err != nil { 180 + return fmt.Errorf("failed to get video sink element from pipeline: %w", err) 181 + } 182 + mkvsink := app.SinkFromElement(mkvsinkele) 183 + mkvsink.SetCallbacks(&app.SinkCallbacks{ 184 + NewSampleFunc: WriterNewSample(ctx, w), 185 + EOSFunc: func(sink *app.Sink) { 186 + log.Warn(ctx, "mp4sink EOSFunc") 187 + cancel() 188 + }, 189 + }) 190 + 191 + pipeline.SetState(gst.StatePlaying) 192 + 193 + <-ctx.Done() 194 + 195 + pipeline.BlockSetState(gst.StateNull) 196 + 197 + return nil 198 + }
+76
pkg/media/segchanman/segchanman.go
··· 1 + package segchanman 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "sync" 7 + ) 8 + 9 + // it's a segment channel manager, you see 10 + 11 + type Seg struct { 12 + Filepath string 13 + Data []byte 14 + } 15 + 16 + type SegChanMan struct { 17 + segChans map[string][]chan *Seg 18 + segChansMutex sync.Mutex 19 + } 20 + 21 + func MakeSegChanMan() *SegChanMan { 22 + return &SegChanMan{ 23 + segChans: make(map[string][]chan *Seg), 24 + } 25 + } 26 + 27 + func segChanKey(user string, rendition string) string { 28 + return fmt.Sprintf("%s::%s", user, rendition) 29 + } 30 + 31 + func (s *SegChanMan) SubscribeSegment(ctx context.Context, user string, rendition string) <-chan *Seg { 32 + key := segChanKey(user, rendition) 33 + s.segChansMutex.Lock() 34 + defer s.segChansMutex.Unlock() 35 + chs, ok := s.segChans[key] 36 + if !ok { 37 + chs = []chan *Seg{} 38 + s.segChans[key] = chs 39 + } 40 + ch := make(chan *Seg, 1024) 41 + chs = append(chs, ch) 42 + s.segChans[key] = chs 43 + return ch 44 + } 45 + 46 + func (s *SegChanMan) UnsubscribeSegment(ctx context.Context, user string, rendition string, ch <-chan *Seg) { 47 + key := segChanKey(user, rendition) 48 + s.segChansMutex.Lock() 49 + defer s.segChansMutex.Unlock() 50 + chs, ok := s.segChans[key] 51 + if !ok { 52 + return 53 + } 54 + for i, c := range chs { 55 + if c == ch { 56 + chs = append(chs[:i], chs[i+1:]...) 57 + break 58 + } 59 + } 60 + s.segChans[key] = chs 61 + } 62 + 63 + func (s *SegChanMan) PublishSegment(ctx context.Context, user string, rendition string, seg *Seg) { 64 + key := segChanKey(user, rendition) 65 + s.segChansMutex.Lock() 66 + defer s.segChansMutex.Unlock() 67 + chs, ok := s.segChans[key] 68 + if !ok { 69 + return 70 + } 71 + for _, ch := range chs { 72 + go func(ch chan *Seg) { 73 + ch <- seg 74 + }(ch) 75 + } 76 + }
+131
pkg/media/segment_conv.go
··· 1 + package media 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "strings" 8 + 9 + "github.com/go-gst/go-gst/gst" 10 + "github.com/go-gst/go-gst/gst/app" 11 + "golang.org/x/sync/errgroup" 12 + ) 13 + 14 + // MP4ToMPEGTS converts an MP4 file with H264 video and Opus audio to an MPEG-TS file with H264 video and AAC audio. 15 + // It reads from the provided reader and writes the converted MPEG-TS to the writer. 16 + func MP4ToMPEGTS(ctx context.Context, input io.Reader, output io.Writer) (int64, error) { 17 + pipelineStr := strings.Join([]string{ 18 + "appsrc name=appsrc ! qtdemux name=demux", 19 + "mpegtsmux name=mux ! appsink name=appsink", 20 + "demux.video_0 ! h264parse ! video/x-h264,stream-format=byte-stream ! queue name=videoqueue", 21 + "demux.audio_0 ! opusdec use-inband-fec=true ! audioresample ! fdkaacenc ! aacparse ! queue name=audioqueue", 22 + }, " ") 23 + 24 + pipeline, err := gst.NewPipelineFromString(pipelineStr) 25 + if err != nil { 26 + return 0, err 27 + } 28 + 29 + mux, err := pipeline.GetElementByName("mux") 30 + if err != nil { 31 + return 0, err 32 + } 33 + muxVideoSinkPad := mux.GetRequestPad("sink_%d") 34 + if muxVideoSinkPad == nil { 35 + return 0, fmt.Errorf("failed to get video sink pad") 36 + } 37 + muxAudioSinkPad := mux.GetRequestPad("sink_%d") 38 + if muxAudioSinkPad == nil { 39 + return 0, fmt.Errorf("failed to get audio sink pad") 40 + } 41 + videoQueue, err := pipeline.GetElementByName("videoqueue") 42 + if err != nil { 43 + return 0, err 44 + } 45 + audioQueue, err := pipeline.GetElementByName("audioqueue") 46 + if err != nil { 47 + return 0, err 48 + } 49 + videoQueueSrcPad := videoQueue.GetStaticPad("src") 50 + if videoQueueSrcPad == nil { 51 + return 0, fmt.Errorf("failed to get video queue source pad") 52 + } 53 + audioQueueSrcPad := audioQueue.GetStaticPad("src") 54 + if audioQueueSrcPad == nil { 55 + return 0, fmt.Errorf("failed to get audio queue source pad") 56 + } 57 + 58 + ok := videoQueueSrcPad.Link(muxVideoSinkPad) 59 + if ok != gst.PadLinkOK { 60 + return 0, fmt.Errorf("failed to link video queue source pad to mux video sink pad: %v", ok) 61 + } 62 + ok = audioQueueSrcPad.Link(muxAudioSinkPad) 63 + if ok != gst.PadLinkOK { 64 + return 0, fmt.Errorf("failed to link audio queue source pad to mux audio sink pad: %v", ok) 65 + } 66 + 67 + // Get elements 68 + appsrc, err := pipeline.GetElementByName("appsrc") 69 + if err != nil { 70 + return 0, err 71 + } 72 + appsink, err := pipeline.GetElementByName("appsink") 73 + if err != nil { 74 + return 0, err 75 + } 76 + 77 + source := app.SrcFromElement(appsrc) 78 + sink := app.SinkFromElement(appsink) 79 + 80 + // Set up source callbacks 81 + source.SetCallbacks(&app.SourceCallbacks{ 82 + NeedDataFunc: ReaderNeedData(ctx, input), 83 + EnoughDataFunc: func(self *app.Source) { 84 + // Nothing to do here 85 + }, 86 + SeekDataFunc: func(self *app.Source, offset uint64) bool { 87 + return false // We don't support seeking 88 + }, 89 + }) 90 + 91 + // Set up sink callbacks 92 + sink.SetCallbacks(&app.SinkCallbacks{ 93 + NewSampleFunc: WriterNewSample(ctx, output), 94 + NewPrerollFunc: func(self *app.Sink) gst.FlowReturn { 95 + return gst.FlowOK 96 + }, 97 + }) 98 + 99 + ctx, cancel := context.WithCancel(ctx) 100 + defer cancel() 101 + 102 + // Handle bus messages in a separate goroutine 103 + g, ctx := errgroup.WithContext(ctx) 104 + g.Go(func() error { 105 + HandleBusMessages(ctx, pipeline) 106 + cancel() 107 + return nil 108 + }) 109 + 110 + // Start the pipeline 111 + err = pipeline.SetState(gst.StatePlaying) 112 + if err != nil { 113 + return 0, fmt.Errorf("failed to set pipeline state to playing: %w", err) 114 + } 115 + 116 + // Wait for the pipeline to finish or context to be canceled 117 + <-ctx.Done() 118 + 119 + durOk, dur := pipeline.QueryDuration(gst.FormatTime) 120 + if !durOk { 121 + return 0, fmt.Errorf("failed to query duration") 122 + } 123 + 124 + // Clean up 125 + err = pipeline.SetState(gst.StateNull) 126 + if err != nil { 127 + return 0, fmt.Errorf("failed to set pipeline state to null: %w", err) 128 + } 129 + 130 + return dur, nil 131 + }
+35
pkg/media/segment_conv_test.go
··· 1 + package media 2 + 3 + import ( 4 + "context" 5 + "os" 6 + "testing" 7 + 8 + "github.com/go-gst/go-gst/gst" 9 + "github.com/stretchr/testify/require" 10 + ) 11 + 12 + func TestMP4ToMPEGTS(t *testing.T) { 13 + gst.Init(nil) 14 + 15 + // Open input file 16 + inputFile, err := os.Open(getFixture("sample-segment.mp4")) 17 + require.NoError(t, err) 18 + defer inputFile.Close() 19 + 20 + // Create temporary output file 21 + outputFile, err := os.CreateTemp("", "*.ts") 22 + require.NoError(t, err) 23 + defer os.Remove(outputFile.Name()) 24 + defer outputFile.Close() 25 + 26 + // Convert MP4 to MPEG-TS 27 + dur, err := MP4ToMPEGTS(context.Background(), inputFile, outputFile) 28 + require.NoError(t, err) 29 + require.Greater(t, dur, int64(0), "Duration should be greater than 0") 30 + 31 + // Verify output file has content 32 + info, err := os.Stat(outputFile.Name()) 33 + require.NoError(t, err) 34 + require.Greater(t, info.Size(), int64(0), "Output file should not be empty") 35 + }
+81
pkg/media/segmenter.go
··· 1 + package media 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "time" 8 + 9 + "github.com/go-gst/go-gst/gst" 10 + "github.com/go-gst/go-gst/gst/app" 11 + "stream.place/streamplace/pkg/log" 12 + ) 13 + 14 + // element that takes the input stream, muxes to mp4, and signs the result 15 + func (mm *MediaManager) SegmentAndSignElem(ctx context.Context, ms MediaSigner) (*gst.Element, error) { 16 + // elem, err := gst.NewElement("splitmuxsink name=splitter async-finalize=true sink-factory=appsink muxer-factory=matroskamux max-size-bytes=1") 17 + elem, err := gst.NewElementWithProperties("splitmuxsink", map[string]any{ 18 + "name": "signer", 19 + "async-finalize": true, 20 + "sink-factory": "appsink", 21 + "muxer-factory": "mp4mux", 22 + "max-size-bytes": 1, 23 + }) 24 + if err != nil { 25 + return nil, err 26 + } 27 + 28 + p := elem.GetRequestPad("video") 29 + if p == nil { 30 + return nil, fmt.Errorf("failed to get video pad") 31 + } 32 + p = elem.GetRequestPad("audio_%u") 33 + if p == nil { 34 + return nil, fmt.Errorf("failed to get audio pad") 35 + } 36 + 37 + resetTimer := make(chan struct{}) 38 + 39 + go func() { 40 + for { 41 + select { 42 + case <-ctx.Done(): 43 + return 44 + case <-resetTimer: 45 + continue 46 + case <-time.After(time.Second * 10): 47 + log.Warn(ctx, "no new segment for 10 seconds") 48 + elem.ErrorMessage(gst.DomainCore, gst.CoreErrorFailed, "No new segment for 10 seconds", "No new segment for 10 seconds (debug)") 49 + return 50 + } 51 + } 52 + }() 53 + 54 + elem.Connect("sink-added", func(split, sinkEle *gst.Element) { 55 + buf := &bytes.Buffer{} 56 + appsink := app.SinkFromElement(sinkEle) 57 + if appsink == nil { 58 + panic("appsink should not be nil") 59 + } 60 + appsink.SetCallbacks(&app.SinkCallbacks{ 61 + NewSampleFunc: WriterNewSample(ctx, buf), 62 + EOSFunc: func(sink *app.Sink) { 63 + resetTimer <- struct{}{} 64 + now := time.Now().UnixMilli() 65 + bs, err := ms.SignMP4(ctx, bytes.NewReader(buf.Bytes()), now) 66 + if err != nil { 67 + log.Error(ctx, "error signing segment", "error", err) 68 + return 69 + } 70 + 71 + err = mm.ValidateMP4(ctx, bytes.NewReader(bs)) 72 + if err != nil { 73 + log.Error(ctx, "error validating segment", "error", err) 74 + return 75 + } 76 + }, 77 + }) 78 + }) 79 + 80 + return elem, nil 81 + }
+296
pkg/media/segmenter_hls.go
··· 1 + package media 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "strings" 8 + "sync" 9 + "time" 10 + 11 + "github.com/go-gst/go-gst/gst" 12 + "github.com/go-gst/go-gst/gst/app" 13 + "stream.place/streamplace/pkg/log" 14 + ) 15 + 16 + func (mm *MediaManager) ToHLS(ctx context.Context, user string, rendition string, m3u8 *M3U8) error { 17 + ctx = log.WithLogValues(ctx, "GStreamerFunc", "ToHLS", "rendition", rendition) 18 + 19 + pipelineSlice := []string{ 20 + "h264parse name=videoparse", 21 + "opusdec use-inband-fec=true name=audioparse ! audioresample ! audiorate ! fdkaacenc name=audioenc", 22 + } 23 + 24 + pipeline, err := gst.NewPipelineFromString(strings.Join(pipelineSlice, "\n")) 25 + if err != nil { 26 + return fmt.Errorf("error creating ToHLS pipeline: %w", err) 27 + } 28 + 29 + outputQueue, done, err := ConcatStream(ctx, pipeline, user, rendition, mm) 30 + if err != nil { 31 + return fmt.Errorf("failed to get output queue: %w", err) 32 + } 33 + 34 + videoParse, err := pipeline.GetElementByName("videoparse") 35 + if err != nil { 36 + return fmt.Errorf("failed to get video sink element from pipeline: %w", err) 37 + } 38 + err = outputQueue.Link(videoParse) 39 + if err != nil { 40 + return fmt.Errorf("failed to link output queue to video parse: %w", err) 41 + } 42 + 43 + audioParse, err := pipeline.GetElementByName("audioparse") 44 + if err != nil { 45 + return fmt.Errorf("failed to get audio parse element from pipeline: %w", err) 46 + } 47 + err = outputQueue.Link(audioParse) 48 + if err != nil { 49 + return fmt.Errorf("failed to link output queue to audio parse: %w", err) 50 + } 51 + 52 + splitmuxsink, err := gst.NewElementWithProperties("splitmuxsink", map[string]any{ 53 + "name": "mux", 54 + "async-finalize": true, 55 + "sink-factory": "appsink", 56 + "muxer-factory": "mpegtsmux", 57 + "max-size-bytes": 1, 58 + }) 59 + if err != nil { 60 + return err 61 + } 62 + 63 + r := m3u8.GetRendition(rendition) 64 + defer func() { r = nil }() 65 + ps := NewPendingSegments(r) 66 + defer func() { ps = nil }() 67 + 68 + p := splitmuxsink.GetRequestPad("video") 69 + if p == nil { 70 + return fmt.Errorf("failed to get video pad") 71 + } 72 + p = splitmuxsink.GetRequestPad("audio_%u") 73 + if p == nil { 74 + return fmt.Errorf("failed to get audio pad") 75 + } 76 + 77 + err = pipeline.Add(splitmuxsink) 78 + if err != nil { 79 + return fmt.Errorf("error adding splitmuxsink to ToHLS pipeline: %w", err) 80 + } 81 + 82 + videoparse, err := pipeline.GetElementByName("videoparse") 83 + if err != nil { 84 + return fmt.Errorf("error getting videoparse from ToHLS pipeline: %w", err) 85 + } 86 + err = videoparse.Link(splitmuxsink) 87 + if err != nil { 88 + return fmt.Errorf("error linking videoparse to splitmuxsink: %w", err) 89 + } 90 + 91 + audioenc, err := pipeline.GetElementByName("audioenc") 92 + if err != nil { 93 + return fmt.Errorf("error getting audioenc from ToHLS pipeline: %w", err) 94 + } 95 + err = audioenc.Link(splitmuxsink) 96 + if err != nil { 97 + return fmt.Errorf("error linking audioenc to splitmuxsink: %w", err) 98 + } 99 + 100 + ctx, cancel := context.WithCancel(ctx) 101 + 102 + go func() { 103 + select { 104 + case <-ctx.Done(): 105 + return 106 + case <-done: 107 + cancel() 108 + } 109 + }() 110 + 111 + splitmuxsink.Connect("sink-added", func(split, sinkEle *gst.Element) { 112 + log.Debug(ctx, "hls-check sink-added") 113 + vf, err := ps.GetNextSegment(ctx) 114 + if err != nil { 115 + panic(err) 116 + } 117 + appsink := app.SinkFromElement(sinkEle) 118 + appsink.SetCallbacks(&app.SinkCallbacks{ 119 + NewSampleFunc: WriterNewSample(ctx, vf.Buf), 120 + EOSFunc: func(sink *app.Sink) { 121 + log.Debug(ctx, "hls-check Segment EOS", "buf", vf.Buf.Len()) 122 + ps.CloseSegment(ctx, vf) 123 + }, 124 + }) 125 + }) 126 + 127 + onPadAdded := func(element *gst.Element, pad *gst.Pad) { 128 + caps := pad.GetCurrentCaps() 129 + if caps == nil { 130 + fmt.Println("Unable to get pad caps") 131 + return 132 + } 133 + 134 + log.Debug(ctx, "New pad added", "pad", pad.GetName(), "caps", caps.String()) 135 + 136 + structure := caps.GetStructureAt(0) 137 + if structure == nil { 138 + fmt.Println("Unable to get structure from caps") 139 + return 140 + } 141 + 142 + name := structure.Name() 143 + fmt.Printf("Structure Name: %s\n", name) 144 + 145 + if name[:5] == "video" { 146 + // Get some common video properties 147 + // widthVal, _ := structure.GetValue("width") 148 + // heightVal, _ := structure.GetValue("height") 149 + 150 + // width, ok := widthVal.(int) 151 + // if ok { 152 + // m3u8.Width = uint64(width) 153 + // } 154 + // height, ok := heightVal.(int) 155 + // if ok { 156 + // m3u8.Height = uint64(height) 157 + // } 158 + } 159 + } 160 + 161 + splitmuxsink.Connect("pad-added", onPadAdded) 162 + 163 + defer cancel() 164 + go func() { 165 + HandleBusMessagesCustom(ctx, pipeline, func(msg *gst.Message) { 166 + switch msg.Type() { 167 + case gst.MessageElement: 168 + structure := msg.GetStructure() 169 + name := structure.Name() 170 + if name == "splitmuxsink-fragment-opened" { 171 + runningTime, err := structure.GetValue("running-time") 172 + if err != nil { 173 + log.Debug(ctx, "splitmuxsink-fragment-opened error", "error", err) 174 + cancel() 175 + } 176 + runningTimeInt, ok := runningTime.(uint64) 177 + if !ok { 178 + log.Warn(ctx, "splitmuxsink-fragment-opened not a uint64") 179 + cancel() 180 + } 181 + log.Debug(ctx, "hls-check splitmuxsink-fragment-opened", "runningTime", runningTimeInt) 182 + ps.FragmentOpened(ctx, runningTimeInt) 183 + } 184 + if name == "splitmuxsink-fragment-closed" { 185 + runningTime, err := structure.GetValue("running-time") 186 + if err != nil { 187 + log.Debug(ctx, "splitmuxsink-fragment-closed error", "error", err) 188 + cancel() 189 + } 190 + runningTimeInt, ok := runningTime.(uint64) 191 + if !ok { 192 + log.Warn(ctx, "splitmuxsink-fragment-closed not a uint64") 193 + cancel() 194 + } 195 + log.Debug(ctx, "hls-check splitmuxsink-fragment-closed", "runningTime", runningTimeInt) 196 + ps.FragmentClosed(ctx, runningTimeInt) 197 + } 198 + } 199 + }) 200 + cancel() 201 + }() 202 + 203 + // Start the pipeline 204 + pipeline.SetState(gst.StatePlaying) 205 + 206 + <-ctx.Done() 207 + 208 + pipeline.BlockSetState(gst.StateNull) 209 + 210 + return nil 211 + } 212 + 213 + type PendingSegments struct { 214 + segments []*Segment 215 + lock sync.Mutex 216 + rendition *M3U8Rendition 217 + } 218 + 219 + func NewPendingSegments(rendition *M3U8Rendition) *PendingSegments { 220 + return &PendingSegments{ 221 + segments: []*Segment{}, 222 + lock: sync.Mutex{}, 223 + rendition: rendition, 224 + } 225 + } 226 + 227 + func (ps *PendingSegments) GetNextSegment(ctx context.Context) (*Segment, error) { 228 + ps.lock.Lock() 229 + defer ps.lock.Unlock() 230 + log.Debug(ctx, "next segment") 231 + seg := &Segment{ 232 + Buf: &bytes.Buffer{}, 233 + Time: time.Now(), 234 + Closed: false, 235 + } 236 + ps.segments = append(ps.segments, seg) 237 + return seg, nil 238 + } 239 + 240 + func (ps *PendingSegments) CloseSegment(ctx context.Context, seg *Segment) { 241 + ps.lock.Lock() 242 + defer ps.lock.Unlock() 243 + log.Debug(ctx, "close segment", "MSN", seg.MSN) 244 + seg.Closed = true 245 + ps.checkSegments(ctx) 246 + } 247 + 248 + func (ps *PendingSegments) FragmentOpened(ctx context.Context, t uint64) error { 249 + ps.lock.Lock() 250 + defer ps.lock.Unlock() 251 + log.Debug(ctx, "fragment opened", "time", t) 252 + if len(ps.segments) == 0 { 253 + return fmt.Errorf("no pending segments") 254 + } 255 + for _, seg := range ps.segments { 256 + if seg.StartTS == nil { 257 + seg.StartTS = &t 258 + break 259 + } 260 + } 261 + ps.checkSegments(ctx) 262 + return nil 263 + } 264 + 265 + func (ps *PendingSegments) FragmentClosed(ctx context.Context, t uint64) error { 266 + ps.lock.Lock() 267 + defer ps.lock.Unlock() 268 + log.Debug(ctx, "fragment closed", "time", t) 269 + if len(ps.segments) == 0 { 270 + return fmt.Errorf("no pending segments") 271 + } 272 + for _, seg := range ps.segments { 273 + if seg.EndTS == nil { 274 + seg.EndTS = &t 275 + dur := *seg.EndTS - *seg.StartTS 276 + seg.Duration = time.Duration(dur) 277 + break 278 + } 279 + } 280 + ps.checkSegments(ctx) 281 + return nil 282 + } 283 + 284 + // the tricky piece of the design here is that we need to expect GetNextSegment, 285 + // CloseSegment, FragmentOpened, and FragmentClosed to be called in any order. So 286 + // all of those functions call this one, and it checks if we have the necessary information 287 + // to finalize a segment and add it to our playlist. 288 + // only call if you're holding ps.lock! 289 + func (ps *PendingSegments) checkSegments(ctx context.Context) { 290 + pending := ps.segments[0] 291 + if pending.StartTS != nil && pending.EndTS != nil && pending.Closed { 292 + ps.rendition.NewSegment(pending) 293 + log.Debug(ctx, "finalizing segment", "MSN", pending.MSN) 294 + ps.segments = ps.segments[1:] 295 + } 296 + }
+62
pkg/media/thumbnail.go
··· 1 + package media 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "strings" 8 + 9 + "github.com/go-gst/go-gst/gst" 10 + "github.com/go-gst/go-gst/gst/app" 11 + "stream.place/streamplace/pkg/log" 12 + ) 13 + 14 + func (mm *MediaManager) Thumbnail(ctx context.Context, r io.Reader, w io.Writer) error { 15 + ctx = log.WithLogValues(ctx, "function", "Thumbnail") 16 + ctx, cancel := context.WithCancel(ctx) 17 + defer cancel() 18 + 19 + pipelineSlice := []string{ 20 + "appsrc name=appsrc ! qtdemux ! decodebin ! videoconvert ! videoscale ! video/x-raw,width=[1,720],height=[1,720],pixel-aspect-ratio=1/1 ! pngenc snapshot=true ! appsink name=appsink", 21 + } 22 + 23 + pipeline, err := gst.NewPipelineFromString(strings.Join(pipelineSlice, "\n")) 24 + if err != nil { 25 + return fmt.Errorf("error creating Thumbnail pipeline: %w", err) 26 + } 27 + appsrc, err := pipeline.GetElementByName("appsrc") 28 + if err != nil { 29 + return err 30 + } 31 + 32 + src := app.SrcFromElement(appsrc) 33 + src.SetCallbacks(&app.SourceCallbacks{ 34 + NeedDataFunc: ReaderNeedData(ctx, r), 35 + }) 36 + 37 + appsink, err := pipeline.GetElementByName("appsink") 38 + if err != nil { 39 + return err 40 + } 41 + 42 + go func() { 43 + HandleBusMessages(ctx, pipeline) 44 + cancel() 45 + }() 46 + 47 + sink := app.SinkFromElement(appsink) 48 + sink.SetCallbacks(&app.SinkCallbacks{ 49 + NewSampleFunc: WriterNewSample(ctx, w), 50 + EOSFunc: func(sink *app.Sink) { 51 + cancel() 52 + }, 53 + }) 54 + 55 + pipeline.SetState(gst.StatePlaying) 56 + 57 + <-ctx.Done() 58 + 59 + pipeline.BlockSetState(gst.StateNull) 60 + 61 + return nil 62 + }
+104
pkg/media/validate.go
··· 1 + package media 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "io" 8 + "strings" 9 + 10 + "stream.place/streamplace/pkg/aqtime" 11 + "stream.place/streamplace/pkg/constants" 12 + "stream.place/streamplace/pkg/crypto/signers" 13 + "stream.place/streamplace/pkg/log" 14 + "stream.place/streamplace/pkg/media/segchanman" 15 + "stream.place/streamplace/pkg/model" 16 + 17 + "git.stream.place/streamplace/c2pa-go/pkg/c2pa" 18 + ) 19 + 20 + func (mm *MediaManager) ValidateMP4(ctx context.Context, input io.Reader) error { 21 + buf, err := io.ReadAll(input) 22 + if err != nil { 23 + return err 24 + } 25 + r := bytes.NewReader(buf) 26 + reader, err := c2pa.FromStream(r, "video/mp4") 27 + if err != nil { 28 + return err 29 + } 30 + mani := reader.GetActiveManifest() 31 + certs := reader.GetProvenanceCertChain() 32 + pub, err := signers.ParseES256KCert([]byte(certs)) 33 + if err != nil { 34 + return err 35 + } 36 + meta, err := ParseSegmentAssertions(mani) 37 + if err != nil { 38 + return err 39 + } 40 + mediaData, err := mm.ParseSegmentMediaData(ctx, buf) 41 + if err != nil { 42 + return err 43 + } 44 + // special case for test signers that are only signed with a key 45 + var repoDID string 46 + var signingKeyDID string 47 + if strings.HasPrefix(meta.Creator, constants.DID_KEY_PREFIX) { 48 + signingKeyDID = meta.Creator 49 + repoDID = meta.Creator 50 + } else { 51 + repo, err := mm.atsync.SyncBlueskyRepoCached(ctx, meta.Creator, mm.model) 52 + if err != nil { 53 + return err 54 + } 55 + signingKey, err := mm.model.GetSigningKey(pub.DIDKey(), repo.DID) 56 + if err != nil { 57 + return err 58 + } 59 + if signingKey == nil { 60 + return fmt.Errorf("no signing key found for %s", pub.DIDKey()) 61 + } 62 + repoDID = repo.DID 63 + signingKeyDID = signingKey.DID 64 + } 65 + 66 + err = mm.cli.StreamIsAllowed(repoDID) 67 + if err != nil { 68 + return fmt.Errorf("got valid segment, but user %s is not allowed: %w", repoDID, err) 69 + } 70 + fd, err := mm.cli.SegmentFileCreate(repoDID, meta.StartTime, "mp4") 71 + if err != nil { 72 + return err 73 + } 74 + defer fd.Close() 75 + go mm.replicator.NewSegment(ctx, buf) 76 + r = bytes.NewReader(buf) 77 + io.Copy(fd, r) 78 + scmSeg := &segchanman.Seg{ 79 + Filepath: fd.Name(), 80 + Data: buf, 81 + } 82 + go mm.PublishSegment(ctx, repoDID, "source", scmSeg) 83 + seg := &model.Segment{ 84 + ID: *mani.Label, 85 + SigningKeyDID: signingKeyDID, 86 + RepoDID: repoDID, 87 + StartTime: meta.StartTime.Time(), 88 + Title: meta.Title, 89 + MediaData: mediaData, 90 + } 91 + mm.newSegmentSubsMutex.RLock() 92 + defer mm.newSegmentSubsMutex.RUnlock() 93 + not := &NewSegmentNotification{ 94 + Segment: seg, 95 + Data: buf, 96 + Metadata: meta, 97 + } 98 + for _, ch := range mm.newSegmentSubs { 99 + go func() { ch <- not }() 100 + } 101 + aqt := aqtime.FromTime(meta.StartTime.Time()) 102 + log.Log(ctx, "successfully ingested segment", "user", repoDID, "signingKey", signingKeyDID, "timestamp", aqt.FileSafeString(), "segmentID", *mani.Label) 103 + return nil 104 + }
+5 -7
pkg/media/webrtc.go
··· 24 24 var DEFAULT_DURATION = time.Duration(32 * time.Millisecond) 25 25 26 26 // This function remains in scope for the duration of a single users' playback 27 - func (mm *MediaManager) WebRTCPlayback(ctx context.Context, user string, offer *webrtc.SessionDescription) (*webrtc.SessionDescription, error) { 27 + func (mm *MediaManager) WebRTCPlayback(ctx context.Context, user string, rendition string, offer *webrtc.SessionDescription) (*webrtc.SessionDescription, error) { 28 28 uu, err := uuid.NewV7() 29 29 if err != nil { 30 30 return nil, err ··· 49 49 cancel() 50 50 }() 51 51 52 - outputQueue, done, err := ConcatStream(ctx, pipeline, user, mm) 52 + outputQueue, done, err := ConcatStream(ctx, pipeline, user, rendition, mm) 53 53 if err != nil { 54 54 return nil, fmt.Errorf("failed to get output queue: %w", err) 55 55 } ··· 305 305 }) 306 306 307 307 <-ctx.Done() 308 - log.Warn(ctx, "!!!!!!!!!!!!!!!!!!!!!!! ctx done") 309 308 }() 310 309 select { 311 310 case <-gatherComplete: ··· 391 390 pipelineSlice := []string{ 392 391 "multiqueue name=queue", 393 392 "appsrc format=time is-live=true do-timestamp=true name=videosrc ! capsfilter caps=application/x-rtp ! rtph264depay ! capsfilter caps=video/x-h264,stream-format=byte-stream,alignment=nal ! h264parse ! h264timestamper ! identity ! queue.sink_0", 394 - "appsrc format=time is-live=true do-timestamp=true name=audiosrc ! capsfilter caps=application/x-rtp,media=audio,encoding-name=OPUS,payload=111 ! rtpopusdepay ! queue.sink_1", 393 + "appsrc format=time is-live=true do-timestamp=true name=audiosrc ! capsfilter caps=application/x-rtp,media=audio,encoding-name=OPUS,payload=111 ! rtpopusdepay ! opusdec use-inband-fec=true ! audiorate ! opusenc ! queue.sink_1", 395 394 } 396 395 397 396 pipeline, err := gst.NewPipelineFromString(strings.Join(pipelineSlice, "\n")) ··· 525 524 peerConnection.OnConnectionStateChange(func(s webrtc.PeerConnectionState) { 526 525 log.Log(ctx, "Peer Connection State has changed", "state", s.String()) 527 526 528 - if s == webrtc.PeerConnectionStateFailed { 527 + if s == webrtc.PeerConnectionStateFailed || s == webrtc.PeerConnectionStateDisconnected { 529 528 // Wait until PeerConnection has had no network activity for 30 seconds or another failure. It may be reconnected using an ICE Restart. 530 529 // Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout. 531 530 // Note that the PeerConnection may come back from PeerConnectionStateDisconnected. 532 - log.Log(ctx, "Peer Connection has gone to failed exiting") 531 + log.Log(ctx, "Peer Connection has ended, exiting", "state", s.String()) 533 532 cancel() 534 533 } 535 534 }) ··· 614 613 }) 615 614 616 615 <-ctx.Done() 617 - log.Warn(ctx, "!!!!!!!!! context done, exiting") 618 616 }() 619 617 select { 620 618 case <-gatherComplete:
+13 -5
pkg/model/segment.go
··· 13 13 ) 14 14 15 15 type SegmentMediadataVideo struct { 16 - Width int `json:"width"` 17 - Height int `json:"height"` 18 - Framerate string `json:"framerate"` 16 + Width int `json:"width"` 17 + Height int `json:"height"` 18 + FPSNum int `json:"fpsNum"` 19 + FPSDen int `json:"fpsDen"` 19 20 } 20 21 21 22 type SegmentMediadataAudio struct { ··· 24 25 } 25 26 26 27 type SegmentMediaData struct { 27 - Video []*SegmentMediadataVideo `json:"video"` 28 - Audio []*SegmentMediadataAudio `json:"audio"` 28 + Video []*SegmentMediadataVideo `json:"video"` 29 + Audio []*SegmentMediadataAudio `json:"audio"` 30 + Duration int64 `json:"duration"` 29 31 } 30 32 31 33 // Scan scan value into Jsonb, implements sql.Scanner interface ··· 68 70 if len(s.MediaData.Audio) == 0 || s.MediaData.Audio[0] == nil { 69 71 return nil, fmt.Errorf("audio data is nil") 70 72 } 73 + duration := s.MediaData.Duration 71 74 return &streamplace.Segment{ 72 75 LexiconTypeID: "place.stream.segment", 73 76 Creator: s.RepoDID, 74 77 Id: s.ID, 75 78 SigningKey: s.SigningKeyDID, 76 79 StartTime: string(aqt), 80 + Duration: &duration, 77 81 Video: []*streamplace.Segment_Video{ 78 82 { 79 83 Codec: "h264", 80 84 Width: int64(s.MediaData.Video[0].Width), 81 85 Height: int64(s.MediaData.Video[0].Height), 86 + Framerate: &streamplace.Segment_Framerate{ 87 + Num: int64(s.MediaData.Video[0].FPSNum), 88 + Den: int64(s.MediaData.Video[0].FPSDen), 89 + }, 82 90 }, 83 91 }, 84 92 Audio: []*streamplace.Segment_Audio{
+210
pkg/renditions/renditions.go
··· 1 + package renditions 2 + 3 + import ( 4 + "fmt" 5 + "math" 6 + 7 + "stream.place/streamplace/pkg/streamplace" 8 + ) 9 + 10 + type FPS struct { 11 + Passthrough bool 12 + Num uint 13 + Den uint 14 + } 15 + 16 + type Rendition struct { 17 + Width int64 18 + Height int64 19 + Bitrate int 20 + Framerate FPS 21 + Profile string 22 + Name string 23 + Parent *Rendition 24 + } 25 + 26 + type JsonProfile struct { 27 + Name string `json:"name,omitempty"` 28 + Width int `json:"width,omitempty"` 29 + Height int `json:"height,omitempty"` 30 + Bitrate int `json:"bitrate,omitempty"` 31 + FPS uint `json:"fps,omitempty"` 32 + FPSDen uint `json:"fpsDen,omitempty"` 33 + Profile string `json:"profile,omitempty"` 34 + GOP string `json:"gop,omitempty"` 35 + Encoder string `json:"encoder,omitempty"` 36 + Quality uint `json:"quality,omitempty"` 37 + } 38 + 39 + func (r Rendition) ToLivepeerProfile() JsonProfile { 40 + p := JsonProfile{ 41 + Name: r.Name, 42 + Bitrate: r.Bitrate, 43 + FPS: r.Framerate.Num, 44 + FPSDen: r.Framerate.Den, 45 + Profile: r.Profile, 46 + } 47 + if r.Parent == nil { 48 + p.Width = int(r.Width) 49 + p.Height = int(r.Height) 50 + } else { 51 + // We want to set the dimension that is the same as the parent 52 + if r.Width < r.Height { 53 + if r.Parent.Width == r.Height { 54 + p.Height = int(r.Parent.Width) 55 + } else { 56 + p.Width = int(r.Parent.Height) 57 + } 58 + } else { 59 + if r.Parent.Height == r.Height { 60 + p.Height = int(r.Parent.Height) 61 + } else { 62 + p.Width = int(r.Parent.Width) 63 + } 64 + } 65 + } 66 + return p 67 + } 68 + 69 + type Renditions []Rendition 70 + 71 + func (rs Renditions) ToLivepeerProfiles() []JsonProfile { 72 + profiles := make([]JsonProfile, len(rs)) 73 + for i, r := range rs { 74 + profiles[i] = r.ToLivepeerProfile() 75 + } 76 + return profiles 77 + } 78 + 79 + var DesiredRenditions = []Rendition{ 80 + { 81 + Name: "1080p", 82 + Width: 1920, 83 + Height: 1080, 84 + Bitrate: 6_000_000, 85 + Framerate: FPS{ 86 + Num: 60, 87 + Den: 1, 88 + }, 89 + Profile: "h264constrainedhigh", 90 + }, 91 + { 92 + Name: "720p", 93 + Width: 1280, 94 + Height: 720, 95 + Bitrate: 3_000_000, 96 + Framerate: FPS{ 97 + Num: 60, 98 + Den: 1, 99 + }, 100 + Profile: "h264constrainedhigh", 101 + }, 102 + { 103 + Name: "360p", 104 + Width: 640, 105 + Height: 360, 106 + Bitrate: 1_000_000, 107 + Framerate: FPS{ 108 + Num: 30, 109 + Den: 1, 110 + }, 111 + Profile: "h264constrainedhigh", 112 + }, 113 + { 114 + Name: "240p", 115 + Width: 426, 116 + Height: 240, 117 + Bitrate: 500_000, 118 + Framerate: FPS{ 119 + Num: 30, 120 + Den: 1, 121 + }, 122 + Profile: "h264constrainedhigh", 123 + }, 124 + { 125 + Name: "160p", 126 + Width: 284, 127 + Height: 160, 128 + Bitrate: 250_000, 129 + Framerate: FPS{ 130 + Num: 30, 131 + Den: 1, 132 + }, 133 + Profile: "h264baseline", 134 + }, 135 + } 136 + 137 + // GenerateRenditions generates renditions for a given spseg 138 + func GenerateRenditions(spseg *streamplace.Segment) (Renditions, error) { 139 + vid := spseg.Video[0] 140 + if vid == nil { 141 + return nil, fmt.Errorf("no video stream found") 142 + } 143 + rs := []Rendition{} 144 + for _, r := range DesiredRenditions { 145 + vidWidth := int64(vid.Width) 146 + vidHeight := int64(vid.Height) 147 + vertical := vid.Height > vid.Width 148 + // do all the math as if it's horizontal then flip at the end 149 + if vertical { 150 + vidWidth, vidHeight = vidHeight, vidWidth 151 + } 152 + if vidWidth <= r.Width && vidHeight <= r.Height { 153 + continue 154 + } 155 + rAspectRatio := float64(r.Width) / float64(r.Height) 156 + vidAspectRatio := float64(vidWidth) / float64(vidHeight) 157 + if vidAspectRatio > rAspectRatio { 158 + // vid is wider than r 159 + // scale down to r.Width 160 + scale := float64(r.Width) / float64(vidWidth) 161 + vidWidth = r.Width 162 + vidHeight = int64(math.Round(float64(vidHeight) * scale)) 163 + } else { 164 + // vid is taller than r 165 + // scale down to r.Height 166 + scale := float64(r.Height) / float64(vidHeight) 167 + vidHeight = r.Height 168 + vidWidth = int64(math.Round(float64(vidWidth) * scale)) 169 + } 170 + outR := Rendition{ 171 + Name: r.Name, 172 + Parent: &r, 173 + Profile: r.Profile, 174 + } 175 + if vertical { 176 + outR.Width = vidHeight 177 + outR.Height = vidWidth 178 + } else { 179 + outR.Width = vidWidth 180 + outR.Height = vidHeight 181 + } 182 + 183 + // if vertical { 184 + // ratio := float64(r.Height) / float64(vid.Height) 185 + // outR.Height = int64(float64(vid.Width) * (16.0 / 9.0) * ratio) 186 + // outR.Width = r.Height 187 + // } else { 188 + // ratio := float64(r.Width) / float64(vid.Width) 189 + // outR.Width = r.Width 190 + // outR.Height = int64(float64(vid.Width) * (9.0 / 16.0) * ratio) 191 + // } 192 + if vid.Framerate.Den > 0 { 193 + vidFPS := float64(vid.Framerate.Num) / float64(vid.Framerate.Den) 194 + rFPS := float64(r.Framerate.Num) / float64(r.Framerate.Den) 195 + delta := rFPS / vidFPS 196 + 197 + if rFPS < vidFPS { 198 + if delta < 0.75 { 199 + outR.Framerate.Num = uint(vid.Framerate.Num) 200 + outR.Framerate.Den = uint(vid.Framerate.Den * 2) 201 + } 202 + } 203 + } 204 + 205 + outR.Bitrate = r.Bitrate 206 + outR.Profile = r.Profile 207 + rs = append(rs, outR) 208 + } 209 + return rs, nil 210 + }
+323
pkg/renditions/renditions_test.go
··· 1 + package renditions 2 + 3 + import ( 4 + "encoding/json" 5 + "testing" 6 + 7 + "github.com/stretchr/testify/require" 8 + "stream.place/streamplace/pkg/streamplace" 9 + ) 10 + 11 + func seg(width int, height int, fpsNum int, fpsDen int) *streamplace.Segment { 12 + return &streamplace.Segment{ 13 + Video: []*streamplace.Segment_Video{ 14 + { 15 + Width: int64(width), 16 + Height: int64(height), 17 + Framerate: &streamplace.Segment_Framerate{ 18 + Num: int64(fpsNum), 19 + Den: int64(fpsDen), 20 + }, 21 + }, 22 + }, 23 + } 24 + } 25 + 26 + var cases = []struct { 27 + name string 28 + spseg *streamplace.Segment 29 + lp string 30 + }{ 31 + { 32 + name: "4K 60fps", 33 + spseg: seg(3840, 2160, 60, 1), 34 + lp: ` 35 + [ 36 + { 37 + "name": "1080p", 38 + "height": 1080, 39 + "bitrate": 6000000, 40 + "profile": "h264constrainedhigh" 41 + }, 42 + { 43 + "name": "720p", 44 + "height": 720, 45 + "bitrate": 3000000, 46 + "profile": "h264constrainedhigh" 47 + }, 48 + { 49 + "name": "360p", 50 + "height": 360, 51 + "bitrate": 1000000, 52 + "profile": "h264constrainedhigh", 53 + "fps": 60, 54 + "fpsDen": 2 55 + }, 56 + { 57 + "name": "240p", 58 + "height": 240, 59 + "bitrate": 500000, 60 + "profile": "h264constrainedhigh", 61 + "fps": 60, 62 + "fpsDen": 2 63 + }, 64 + { 65 + "name": "160p", 66 + "height": 160, 67 + "bitrate": 250000, 68 + "profile": "h264baseline", 69 + "fps": 60, 70 + "fpsDen": 2 71 + } 72 + ] 73 + `, 74 + }, 75 + { 76 + name: "2K with fractional framerate", 77 + spseg: seg(2160, 1440, 60000, 1001), 78 + lp: ` 79 + [ 80 + { 81 + "name": "1080p", 82 + "height": 1080, 83 + "bitrate": 6000000, 84 + "profile": "h264constrainedhigh" 85 + }, 86 + { 87 + "name": "720p", 88 + "height": 720, 89 + "bitrate": 3000000, 90 + "profile": "h264constrainedhigh" 91 + }, 92 + { 93 + "name": "360p", 94 + "height": 360, 95 + "bitrate": 1000000, 96 + "profile": "h264constrainedhigh", 97 + "fps": 60000, 98 + "fpsDen": 2002 99 + }, 100 + { 101 + "name": "240p", 102 + "height": 240, 103 + "bitrate": 500000, 104 + "profile": "h264constrainedhigh", 105 + "fps": 60000, 106 + "fpsDen": 2002 107 + }, 108 + { 109 + "name": "160p", 110 + "height": 160, 111 + "bitrate": 250000, 112 + "profile": "h264baseline", 113 + "fps": 60000, 114 + "fpsDen": 2002 115 + } 116 + ] 117 + `, 118 + }, 119 + { 120 + name: "720p 50fps", 121 + spseg: seg(1280, 720, 50, 1), 122 + lp: ` 123 + [ 124 + { 125 + "name": "360p", 126 + "height": 360, 127 + "bitrate": 1000000, 128 + "profile": "h264constrainedhigh", 129 + "fps": 50, 130 + "fpsDen": 2 131 + }, 132 + { 133 + "name": "240p", 134 + "height": 240, 135 + "bitrate": 500000, 136 + "profile": "h264constrainedhigh", 137 + "fps": 50, 138 + "fpsDen": 2 139 + }, 140 + { 141 + "name": "160p", 142 + "height": 160, 143 + "bitrate": 250000, 144 + "profile": "h264baseline", 145 + "fps": 50, 146 + "fpsDen": 2 147 + } 148 + ] 149 + `, 150 + }, 151 + { 152 + name: "720p 30fps", 153 + spseg: seg(1280, 720, 30, 1), 154 + lp: ` 155 + [ 156 + { 157 + "name": "360p", 158 + "height": 360, 159 + "bitrate": 1000000, 160 + "profile": "h264constrainedhigh" 161 + }, 162 + { 163 + "name": "240p", 164 + "height": 240, 165 + "bitrate": 500000, 166 + "profile": "h264constrainedhigh" 167 + }, 168 + { 169 + "name": "160p", 170 + "height": 160, 171 + "bitrate": 250000, 172 + "profile": "h264baseline" 173 + } 174 + ] 175 + `, 176 + }, 177 + { 178 + name: "720p 25fps", 179 + spseg: seg(1280, 720, 25, 1), 180 + lp: ` 181 + [ 182 + { 183 + "name": "360p", 184 + "height": 360, 185 + "bitrate": 1000000, 186 + "profile": "h264constrainedhigh" 187 + }, 188 + { 189 + "name": "240p", 190 + "height": 240, 191 + "bitrate": 500000, 192 + "profile": "h264constrainedhigh" 193 + }, 194 + { 195 + "name": "160p", 196 + "height": 160, 197 + "bitrate": 250000, 198 + "profile": "h264baseline" 199 + } 200 + ] 201 + `, 202 + }, 203 + { 204 + name: "Vertical video 60fps", 205 + spseg: seg(480, 640, 60, 1), 206 + lp: ` 207 + [ 208 + { 209 + "name": "360p", 210 + "width": 360, 211 + "bitrate": 1000000, 212 + "profile": "h264constrainedhigh", 213 + "fps": 60, 214 + "fpsDen": 2 215 + }, 216 + { 217 + "name": "240p", 218 + "width": 240, 219 + "bitrate": 500000, 220 + "profile": "h264constrainedhigh", 221 + "fps": 60, 222 + "fpsDen": 2 223 + }, 224 + { 225 + "name": "160p", 226 + "width": 160, 227 + "bitrate": 250000, 228 + "profile": "h264baseline", 229 + "fps": 60, 230 + "fpsDen": 2 231 + } 232 + ] 233 + `, 234 + }, 235 + } 236 + 237 + func TestRenditions(t *testing.T) { 238 + for _, c := range cases { 239 + t.Run(c.name, func(t *testing.T) { 240 + rends, err := GenerateRenditions(c.spseg) 241 + require.NoError(t, err) 242 + lp := rends.ToLivepeerProfiles() 243 + bs, err := json.Marshal(lp) 244 + require.NoError(t, err) 245 + require.JSONEq(t, c.lp, string(bs)) 246 + }) 247 + } 248 + } 249 + 250 + var singleCases = []struct { 251 + name string 252 + spseg *streamplace.Segment 253 + lp string 254 + dimensions []int 255 + }{ 256 + { 257 + name: "Nearly-Square Landscape 4K 60fps", 258 + spseg: seg(3840, 3830, 60, 1), 259 + dimensions: []int{1083, 1080}, 260 + lp: ` 261 + { 262 + "name": "1080p", 263 + "height": 1080, 264 + "bitrate": 6000000, 265 + "profile": "h264constrainedhigh" 266 + } 267 + `, 268 + }, 269 + { 270 + name: "Nearly-Square Portrait 4K 60fps", 271 + spseg: seg(3830, 3840, 60, 1), 272 + dimensions: []int{1080, 1083}, 273 + lp: ` 274 + { 275 + "name": "1080p", 276 + "width": 1080, 277 + "bitrate": 6000000, 278 + "profile": "h264constrainedhigh" 279 + } 280 + `, 281 + }, 282 + { 283 + name: "Stupidly-Wide Landscape 4K 60fps", 284 + spseg: seg(5000, 2160, 60, 1), 285 + dimensions: []int{1920, 829}, 286 + lp: ` 287 + { 288 + "name": "1080p", 289 + "width": 1920, 290 + "bitrate": 6000000, 291 + "profile": "h264constrainedhigh" 292 + } 293 + `, 294 + }, 295 + { 296 + name: "Stupidly-Tall Portrait 4K 60fps", 297 + spseg: seg(2160, 5000, 60, 1), 298 + dimensions: []int{829, 1920}, 299 + lp: ` 300 + { 301 + "name": "1080p", 302 + "height": 1920, 303 + "bitrate": 6000000, 304 + "profile": "h264constrainedhigh" 305 + } 306 + `, 307 + }, 308 + } 309 + 310 + func TestSingleRendition(t *testing.T) { 311 + for _, c := range singleCases { 312 + t.Run(c.name, func(t *testing.T) { 313 + rends, err := GenerateRenditions(c.spseg) 314 + require.NoError(t, err) 315 + first := rends[0] 316 + require.Equal(t, c.dimensions, []int{int(first.Width), int(first.Height)}) 317 + lp := rends.ToLivepeerProfiles() 318 + bs, err := json.Marshal(lp[0]) 319 + require.NoError(t, err) 320 + require.JSONEq(t, c.lp, string(bs)) 321 + }) 322 + } 323 + }
+20
pkg/spmetrics/spmetrics.go
··· 27 27 Help: "total number of viewers", 28 28 }) 29 29 30 + var TranscodeAttemptsTotal = promauto.NewCounter(prometheus.CounterOpts{ 31 + Name: "streamplace_transcode_attempts_total", 32 + Help: "total number of transcode attempts", 33 + }) 34 + 35 + var TranscodeSuccessesTotal = promauto.NewCounter(prometheus.CounterOpts{ 36 + Name: "streamplace_transcode_successes_total", 37 + Help: "total number of transcode successes", 38 + }) 39 + 40 + var TranscodeErrorsTotal = promauto.NewCounter(prometheus.CounterOpts{ 41 + Name: "streamplace_transcode_errors_total", 42 + Help: "total number of transcode errors", 43 + }) 44 + 45 + var Version = promauto.NewCounterVec(prometheus.CounterOpts{ 46 + Name: "streamplace_version", 47 + Help: "version of streamplace", 48 + }, []string{"version"}) 49 + 30 50 func ViewerInc(user string) { 31 51 go func() { 32 52 viewersLock.Lock()
+281 -3
pkg/streamplace/cbor_gen.go
··· 456 456 } 457 457 458 458 cw := cbg.NewCborWriter(w) 459 - fieldCount := 7 459 + fieldCount := 8 460 460 461 461 if t.Audio == nil { 462 + fieldCount-- 463 + } 464 + 465 + if t.Duration == nil { 462 466 fieldCount-- 463 467 } 464 468 ··· 591 595 } 592 596 if _, err := cw.WriteString(string(t.Creator)); err != nil { 593 597 return err 598 + } 599 + 600 + // t.Duration (int64) (int64) 601 + if t.Duration != nil { 602 + 603 + if len("duration") > 1000000 { 604 + return xerrors.Errorf("Value in field \"duration\" was too long") 605 + } 606 + 607 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("duration"))); err != nil { 608 + return err 609 + } 610 + if _, err := cw.WriteString(string("duration")); err != nil { 611 + return err 612 + } 613 + 614 + if t.Duration == nil { 615 + if _, err := cw.Write(cbg.CborNull); err != nil { 616 + return err 617 + } 618 + } else { 619 + if *t.Duration >= 0 { 620 + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.Duration)); err != nil { 621 + return err 622 + } 623 + } else { 624 + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.Duration-1)); err != nil { 625 + return err 626 + } 627 + } 628 + } 629 + 594 630 } 595 631 596 632 // t.StartTime (string) (string) ··· 813 849 814 850 t.Creator = string(sval) 815 851 } 852 + // t.Duration (int64) (int64) 853 + case "duration": 854 + { 855 + 856 + b, err := cr.ReadByte() 857 + if err != nil { 858 + return err 859 + } 860 + if b != cbg.CborNull[0] { 861 + if err := cr.UnreadByte(); err != nil { 862 + return err 863 + } 864 + maj, extra, err := cr.ReadHeader() 865 + if err != nil { 866 + return err 867 + } 868 + var extraI int64 869 + switch maj { 870 + case cbg.MajUnsignedInt: 871 + extraI = int64(extra) 872 + if extraI < 0 { 873 + return fmt.Errorf("int64 positive overflow") 874 + } 875 + case cbg.MajNegativeInt: 876 + extraI = int64(extra) 877 + if extraI < 0 { 878 + return fmt.Errorf("int64 negative overflow") 879 + } 880 + extraI = -1 - extraI 881 + default: 882 + return fmt.Errorf("wrong type for int64 field: %d", maj) 883 + } 884 + 885 + t.Duration = (*int64)(&extraI) 886 + } 887 + } 816 888 // t.StartTime (string) (string) 817 889 case "startTime": 818 890 ··· 1050 1122 } 1051 1123 1052 1124 cw := cbg.NewCborWriter(w) 1125 + fieldCount := 4 1053 1126 1054 - if _, err := cw.Write([]byte{163}); err != nil { 1127 + if t.Framerate == nil { 1128 + fieldCount-- 1129 + } 1130 + 1131 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 1055 1132 return err 1056 1133 } 1057 1134 ··· 1122 1199 } 1123 1200 } 1124 1201 1202 + // t.Framerate (streamplace.Segment_Framerate) (struct) 1203 + if t.Framerate != nil { 1204 + 1205 + if len("framerate") > 1000000 { 1206 + return xerrors.Errorf("Value in field \"framerate\" was too long") 1207 + } 1208 + 1209 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("framerate"))); err != nil { 1210 + return err 1211 + } 1212 + if _, err := cw.WriteString(string("framerate")); err != nil { 1213 + return err 1214 + } 1215 + 1216 + if err := t.Framerate.MarshalCBOR(cw); err != nil { 1217 + return err 1218 + } 1219 + } 1125 1220 return nil 1126 1221 } 1127 1222 ··· 1150 1245 1151 1246 n := extra 1152 1247 1153 - nameBuf := make([]byte, 6) 1248 + nameBuf := make([]byte, 9) 1154 1249 for i := uint64(0); i < n; i++ { 1155 1250 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 1156 1251 if err != nil { ··· 1228 1323 } 1229 1324 1230 1325 t.Height = int64(extraI) 1326 + } 1327 + // t.Framerate (streamplace.Segment_Framerate) (struct) 1328 + case "framerate": 1329 + 1330 + { 1331 + 1332 + b, err := cr.ReadByte() 1333 + if err != nil { 1334 + return err 1335 + } 1336 + if b != cbg.CborNull[0] { 1337 + if err := cr.UnreadByte(); err != nil { 1338 + return err 1339 + } 1340 + t.Framerate = new(Segment_Framerate) 1341 + if err := t.Framerate.UnmarshalCBOR(cr); err != nil { 1342 + return xerrors.Errorf("unmarshaling t.Framerate pointer: %w", err) 1343 + } 1344 + } 1345 + 1346 + } 1347 + 1348 + default: 1349 + // Field doesn't exist on this type, so ignore it 1350 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 1351 + return err 1352 + } 1353 + } 1354 + } 1355 + 1356 + return nil 1357 + } 1358 + func (t *Segment_Framerate) MarshalCBOR(w io.Writer) error { 1359 + if t == nil { 1360 + _, err := w.Write(cbg.CborNull) 1361 + return err 1362 + } 1363 + 1364 + cw := cbg.NewCborWriter(w) 1365 + 1366 + if _, err := cw.Write([]byte{162}); err != nil { 1367 + return err 1368 + } 1369 + 1370 + // t.Den (int64) (int64) 1371 + if len("den") > 1000000 { 1372 + return xerrors.Errorf("Value in field \"den\" was too long") 1373 + } 1374 + 1375 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("den"))); err != nil { 1376 + return err 1377 + } 1378 + if _, err := cw.WriteString(string("den")); err != nil { 1379 + return err 1380 + } 1381 + 1382 + if t.Den >= 0 { 1383 + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Den)); err != nil { 1384 + return err 1385 + } 1386 + } else { 1387 + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Den-1)); err != nil { 1388 + return err 1389 + } 1390 + } 1391 + 1392 + // t.Num (int64) (int64) 1393 + if len("num") > 1000000 { 1394 + return xerrors.Errorf("Value in field \"num\" was too long") 1395 + } 1396 + 1397 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("num"))); err != nil { 1398 + return err 1399 + } 1400 + if _, err := cw.WriteString(string("num")); err != nil { 1401 + return err 1402 + } 1403 + 1404 + if t.Num >= 0 { 1405 + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Num)); err != nil { 1406 + return err 1407 + } 1408 + } else { 1409 + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Num-1)); err != nil { 1410 + return err 1411 + } 1412 + } 1413 + 1414 + return nil 1415 + } 1416 + 1417 + func (t *Segment_Framerate) UnmarshalCBOR(r io.Reader) (err error) { 1418 + *t = Segment_Framerate{} 1419 + 1420 + cr := cbg.NewCborReader(r) 1421 + 1422 + maj, extra, err := cr.ReadHeader() 1423 + if err != nil { 1424 + return err 1425 + } 1426 + defer func() { 1427 + if err == io.EOF { 1428 + err = io.ErrUnexpectedEOF 1429 + } 1430 + }() 1431 + 1432 + if maj != cbg.MajMap { 1433 + return fmt.Errorf("cbor input should be of type map") 1434 + } 1435 + 1436 + if extra > cbg.MaxLength { 1437 + return fmt.Errorf("Segment_Framerate: map struct too large (%d)", extra) 1438 + } 1439 + 1440 + n := extra 1441 + 1442 + nameBuf := make([]byte, 3) 1443 + for i := uint64(0); i < n; i++ { 1444 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 1445 + if err != nil { 1446 + return err 1447 + } 1448 + 1449 + if !ok { 1450 + // Field doesn't exist on this type, so ignore it 1451 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 1452 + return err 1453 + } 1454 + continue 1455 + } 1456 + 1457 + switch string(nameBuf[:nameLen]) { 1458 + // t.Den (int64) (int64) 1459 + case "den": 1460 + { 1461 + maj, extra, err := cr.ReadHeader() 1462 + if err != nil { 1463 + return err 1464 + } 1465 + var extraI int64 1466 + switch maj { 1467 + case cbg.MajUnsignedInt: 1468 + extraI = int64(extra) 1469 + if extraI < 0 { 1470 + return fmt.Errorf("int64 positive overflow") 1471 + } 1472 + case cbg.MajNegativeInt: 1473 + extraI = int64(extra) 1474 + if extraI < 0 { 1475 + return fmt.Errorf("int64 negative overflow") 1476 + } 1477 + extraI = -1 - extraI 1478 + default: 1479 + return fmt.Errorf("wrong type for int64 field: %d", maj) 1480 + } 1481 + 1482 + t.Den = int64(extraI) 1483 + } 1484 + // t.Num (int64) (int64) 1485 + case "num": 1486 + { 1487 + maj, extra, err := cr.ReadHeader() 1488 + if err != nil { 1489 + return err 1490 + } 1491 + var extraI int64 1492 + switch maj { 1493 + case cbg.MajUnsignedInt: 1494 + extraI = int64(extra) 1495 + if extraI < 0 { 1496 + return fmt.Errorf("int64 positive overflow") 1497 + } 1498 + case cbg.MajNegativeInt: 1499 + extraI = int64(extra) 1500 + if extraI < 0 { 1501 + return fmt.Errorf("int64 negative overflow") 1502 + } 1503 + extraI = -1 - extraI 1504 + default: 1505 + return fmt.Errorf("wrong type for int64 field: %d", maj) 1506 + } 1507 + 1508 + t.Num = int64(extraI) 1231 1509 } 1232 1510 1233 1511 default:
+16
pkg/streamplace/streamdefs.go
··· 19 19 Record *appbskytypes.GraphBlock `json:"record" cborgen:"record"` 20 20 Uri string `json:"uri" cborgen:"uri"` 21 21 } 22 + 23 + // Defs_Rendition is a "rendition" in the place.stream.defs schema. 24 + // 25 + // RECORDTYPE: Defs_Rendition 26 + type Defs_Rendition struct { 27 + LexiconTypeID string `json:"$type,const=place.stream.defs#rendition" cborgen:"$type,const=place.stream.defs#rendition"` 28 + Name string `json:"name" cborgen:"name"` 29 + } 30 + 31 + // Defs_Renditions is a "renditions" in the place.stream.defs schema. 32 + // 33 + // RECORDTYPE: Defs_Renditions 34 + type Defs_Renditions struct { 35 + LexiconTypeID string `json:"$type,const=place.stream.defs#renditions" cborgen:"$type,const=place.stream.defs#renditions"` 36 + Renditions []*Defs_Rendition `json:"renditions" cborgen:"renditions"` 37 + }
+16
pkg/streamplace/streamlivestream.go
··· 50 50 Livestream_LivestreamView *Livestream_LivestreamView 51 51 Livestream_ViewerCount *Livestream_ViewerCount 52 52 Defs_BlockView *Defs_BlockView 53 + Defs_Renditions *Defs_Renditions 54 + Defs_Rendition *Defs_Rendition 53 55 ChatDefs_MessageView *ChatDefs_MessageView 54 56 } 55 57 ··· 66 68 t.Defs_BlockView.LexiconTypeID = "place.stream.defs#blockView" 67 69 return json.Marshal(t.Defs_BlockView) 68 70 } 71 + if t.Defs_Renditions != nil { 72 + t.Defs_Renditions.LexiconTypeID = "place.stream.defs#renditions" 73 + return json.Marshal(t.Defs_Renditions) 74 + } 75 + if t.Defs_Rendition != nil { 76 + t.Defs_Rendition.LexiconTypeID = "place.stream.defs#rendition" 77 + return json.Marshal(t.Defs_Rendition) 78 + } 69 79 if t.ChatDefs_MessageView != nil { 70 80 t.ChatDefs_MessageView.LexiconTypeID = "place.stream.chat.defs#messageView" 71 81 return json.Marshal(t.ChatDefs_MessageView) ··· 88 98 case "place.stream.defs#blockView": 89 99 t.Defs_BlockView = new(Defs_BlockView) 90 100 return json.Unmarshal(b, t.Defs_BlockView) 101 + case "place.stream.defs#renditions": 102 + t.Defs_Renditions = new(Defs_Renditions) 103 + return json.Unmarshal(b, t.Defs_Renditions) 104 + case "place.stream.defs#rendition": 105 + t.Defs_Rendition = new(Defs_Rendition) 106 + return json.Unmarshal(b, t.Defs_Rendition) 91 107 case "place.stream.chat.defs#messageView": 92 108 t.ChatDefs_MessageView = new(ChatDefs_MessageView) 93 109 return json.Unmarshal(b, t.ChatDefs_MessageView)
+12 -3
pkg/streamplace/streamsegment.go
··· 16 16 LexiconTypeID string `json:"$type,const=place.stream.segment" cborgen:"$type,const=place.stream.segment"` 17 17 Audio []*Segment_Audio `json:"audio,omitempty" cborgen:"audio,omitempty"` 18 18 Creator string `json:"creator" cborgen:"creator"` 19 + // duration: The duration of the segment in nanoseconds 20 + Duration *int64 `json:"duration,omitempty" cborgen:"duration,omitempty"` 19 21 // id: Unique identifier for the segment 20 22 Id string `json:"id" cborgen:"id"` 21 23 // signingKey: The DID of the signing key used for this segment ··· 32 34 Rate int64 `json:"rate" cborgen:"rate"` 33 35 } 34 36 37 + // Segment_Framerate is a "framerate" in the place.stream.segment schema. 38 + type Segment_Framerate struct { 39 + Den int64 `json:"den" cborgen:"den"` 40 + Num int64 `json:"num" cborgen:"num"` 41 + } 42 + 35 43 // Segment_Video is a "video" in the place.stream.segment schema. 36 44 type Segment_Video struct { 37 - Codec string `json:"codec" cborgen:"codec"` 38 - Height int64 `json:"height" cborgen:"height"` 39 - Width int64 `json:"width" cborgen:"width"` 45 + Codec string `json:"codec" cborgen:"codec"` 46 + Framerate *Segment_Framerate `json:"framerate,omitempty" cborgen:"framerate,omitempty"` 47 + Height int64 `json:"height" cborgen:"height"` 48 + Width int64 `json:"width" cborgen:"width"` 40 49 }