describe use with OBS Studio, determine stream start by the mtime of init.mp4 instead of writing it explicitly in start.txt, determine if the stream is online from stream.m3u8 alone instead of checking for stream.sh's PID
このコミットが含まれているのは:
コミット
52c346c031
110
README.md
110
README.md
|
@ -13,7 +13,7 @@ This works on Linux, and should work on macOS and Windows with some tweaking. Lo
|
|||
|
||||
## Dependencies
|
||||
* Tor
|
||||
* FFmpeg
|
||||
* FFmpeg or OBS Studio
|
||||
* [Flask](https://github.com/pallets/flask)
|
||||
* [captcha](https://github.com/lepture/captcha)
|
||||
* [Flask-HTTPAuth](https://github.com/miguelgrinberg/Flask-HTTPAuth) (to identify the broadcaster in chat)
|
||||
|
@ -50,61 +50,85 @@ This works on Linux, and should work on macOS and Windows with some tweaking. Lo
|
|||
* Flask creates a website interface for the stream
|
||||
* tor makes the website accessible at an onion address
|
||||
|
||||
## Explanation of the FFmpeg command in `stream.sh`
|
||||
|
||||
The FFmpeg command in `stream.sh` was based on [this series of articles by Martin Riedl](https://www.martin-riedl.de/2020/04/17/using-ffmpeg-as-a-hls-streaming-server-overview/).
|
||||
|
||||
### video input (differs between OSs)
|
||||
`-thread_queue_size 2048 -video_size "$BOX_WIDTH"x"$BOX_HEIGHT" -framerate $FRAMERATE -f x11grab -i :0.0+$BOX_OFFSET_X,$BOX_OFFSET_Y`
|
||||
* `-thread_queue_size 2048` prevents ffmpeg from giving some warnings
|
||||
* `-video_size "$BOX_WIDTH"x"$BOX_HEIGHT"` sets the size of the video
|
||||
* `-framerate $FRAMERATE` sets the framerate of the video
|
||||
* `-f x11grab` tells ffmpeg to use the `x11grab` device, used for recording the screen on Linux
|
||||
* `-i :0.0+$BOX_OFFSET_X,$BOX_OFFSET_Y` sets the x- and y-offset for the screen recording
|
||||
|
||||
### audio input (differs between OSs)
|
||||
`-thread_queue_size 2048 -f pulse -i default`
|
||||
|
||||
### video encoding
|
||||
`-c:v libx264 -b:v "$VIDEO_BITRATE"k -tune zerolatency -preset slower -g $FRAMERATE -sc_threshold 0 -pix_fmt yuv420p`
|
||||
|
||||
### video filters
|
||||
`-filter:v scale=$VIDEO_WIDTH:$VIDEO_HEIGHT,"drawtext=fontfile=/usr/share/fonts/truetype/freefont/FreeMonoBold.ttf:text='%{gmtime}':fontcolor=white@0.75:box=1:boxborderw=2:boxcolor=black@0.5:fontsize=24:x=8:y=6"`
|
||||
* `scale=$VIDEO_WIDTH:$VIDEO_HEIGHT` scales the video to the desired size
|
||||
* `drawtext...` draws the date and time in the top left
|
||||
* you might need to change the font `/usr/share/fonts/truetype/freefont/FreeMonoBold.ttf` if you're on macOS and definitely if you're on Windows
|
||||
|
||||
### audio encoding
|
||||
`-c:a aac -b:a "$AUDIO_BITRATE"k -ac $AUDIO_CHANNELS`
|
||||
|
||||
### HLS configuration
|
||||
`-f hls -hls_init_time 0 -hls_time $HLS_TIME -hls_list_size $HLS_LIST_SIZE -hls_flags delete_segments -hls_segment_type fmp4`
|
||||
|
||||
### strip all metadata
|
||||
`-map_metadata -1 -fflags +bitexact -flags:v +bitexact -flags:a +bitexact`
|
||||
|
||||
### output
|
||||
`stream/stream.m3u8`
|
||||
<details>
|
||||
<summary>Explanation of the FFmpeg command in `stream.sh`
|
||||
</summary>
|
||||
<div>The FFmpeg command in `stream.sh` was based on [this series of articles by Martin Riedl](https://www.martin-riedl.de/2020/04/17/using-ffmpeg-as-a-hls-streaming-server-overview/).
|
||||
</div>
|
||||
<br>
|
||||
<div><b>video input (differs between OSs)</b></div>
|
||||
<div>• `-thread_queue_size 2048 -video_size "$BOX_WIDTH"x"$BOX_HEIGHT" -framerate $FRAMERATE -f x11grab -i :0.0+$BOX_OFFSET_X,$BOX_OFFSET_Y`</div>
|
||||
<div>• `-thread_queue_size 2048` prevents ffmpeg from giving some warnings</div>
|
||||
<div>• `-video_size "$BOX_WIDTH"x"$BOX_HEIGHT"` sets the size of the video</div>
|
||||
<div>• `-framerate $FRAMERATE` sets the framerate of the video</div>
|
||||
<div>• `-f x11grab` tells ffmpeg to use the `x11grab` device, used for recording the screen on Linux</div>
|
||||
<div>• `-i :0.0+$BOX_OFFSET_X,$BOX_OFFSET_Y` sets the x- and y-offset for the screen recording</div>
|
||||
<br>
|
||||
<div><b>audio input (differs between OSs)</b></div>
|
||||
<div>`-thread_queue_size 2048 -f pulse -i default`</div>
|
||||
<br>
|
||||
<div><b>video encoding</b></div>
|
||||
<div>`-c:v libx264 -b:v "$VIDEO_BITRATE"k -tune zerolatency -preset slower -g $FRAMERATE -sc_threshold 0 -pix_fmt yuv420p`</div>
|
||||
<br>
|
||||
<div><b>video filters</b></div>
|
||||
<div>• `-filter:v scale=$VIDEO_WIDTH:$VIDEO_HEIGHT,"drawtext=fontfile=/usr/share/fonts/truetype/freefont/FreeMonoBold.ttf:text='%{gmtime}':fontcolor=white@0.75:box=1:boxborderw=2:boxcolor=black@0.5:fontsize=24:x=8:y=6"`</div>
|
||||
<div>• `scale=$VIDEO_WIDTH:$VIDEO_HEIGHT` scales the video to the desired size</div>
|
||||
<div>• `drawtext...` draws the date and time in the top left</div>
|
||||
<div>• you might need to change the font `/usr/share/fonts/truetype/freefont/FreeMonoBold.ttf` if you're on macOS and definitely if you're on Windows</div>
|
||||
<br>
|
||||
<div><b>audio encoding</b></div>
|
||||
<div>`-c:a aac -b:a "$AUDIO_BITRATE"k -ac $AUDIO_CHANNELS`</div>
|
||||
<br>
|
||||
<div><b>HLS configuration</b></div>
|
||||
<div>`-f hls -hls_init_time 0 -hls_time $HLS_TIME -hls_list_size $HLS_LIST_SIZE -hls_flags delete_segments -hls_segment_type fmp4`</div>
|
||||
<br>
|
||||
<div><b>strip all metadata</b></div>
|
||||
<div>`-map_metadata -1 -fflags +bitexact -flags:v +bitexact -flags:a +bitexact`
|
||||
</div>
|
||||
<br>
|
||||
<div><b>output</b></div>
|
||||
<div>`stream/stream.m3u8`</div>
|
||||
</details>
|
||||
|
||||
## Tutorial
|
||||
|
||||
To run this yourself, get this source code. As the project currently exists you might need to change some things:
|
||||
You can either use [OBS Studio](https://obsproject.com/download), or you can use the FFmpeg command in `stream.sh`. OBS Studio is easier.
|
||||
|
||||
### Option 1: OBS Studio
|
||||
Skip the auto-configuration wizard if it appears because you won't be streaming to any third party service.
|
||||
Click Settings > Output and change `Output Mode` to `Advanced`. Then go to the Recording tab and apply these settings:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `Type` | `Custom Output (FFmpeg)` |
|
||||
| `FFmpeg Output Type` | `Output to File` |
|
||||
| `File path or URL` | `$PROJECT_ROOT/stream` where `$PROJECT_ROOT` is the root folder of this project |
|
||||
| `Container Format` | `hls` |
|
||||
| `Muxer Settings (if any)` | `hls_init_time=0 hls_time=2 hls_list_size=120 hls_flags=delete_segments hls_segment_type=fmp4` |
|
||||
| `Video bitrate` | `300 Kbps` (or whatever you want) |
|
||||
| `Keyframe interval (frames)` | `30` (the same as your framerate, or exactly half) |
|
||||
| `Video Encoder` | either leave it default or select an H.264 hardware encoder that matches your graphics card (e.g. `nvenc_h264` for Nvidia, [see here](https://trac.ffmpeg.org/wiki/HWAccelIntro)) |
|
||||
| `Audio Bitrate` | `96 Kbps` (or whatever you want) |
|
||||
| `Audio Encoder` | `aac` |
|
||||
|
||||
Next go to Settings > Advanced > Recording. In `Filename Formatting` type `stream` and check `Overwrite if file exists`.
|
||||
That's it.
|
||||
|
||||
### Option 2: modify `stream.sh` yourself
|
||||
`stream.sh` as it exists in this repo is set up to record your screen and system audio on Linux. See https://trac.ffmpeg.org/wiki/Capture/Desktop for the syntax for different OSs.
|
||||
|
||||
* If you're on Windows `stream.sh` will be wrong for you and so will all the fonts in `config.json`. `stream.sh` uses `$$` to get its process ID, you'll have to use the Windows equivalent.
|
||||
* If you're on macOS `stream.sh` might need to be changed a bit and you might not have the fonts in `config.json`.
|
||||
* If you're on Linux `stream.sh` will probably be alright but you might not have all the fonts in `config.json`.
|
||||
|
||||
As an aside: you can change the command in stream.sh to record anything you want, it doesn't have to be just your screen and system audio. If you want to change stuff around, just know that all that's required is: (1) `stream/pid.txt` contains `stream.sh`'s process ID, (2) `stream/start.txt` contains the time the stream started, (3) HLS segments appear as `stream/stream*.m4s`, (4) `stream/init.mp4` is the inital HLS segment, and (5) `stream/stream.m3u8` is the HLS playlist. (All this is taken care of in `stream.sh` by default.)
|
||||
As an aside: you can change the command in stream.sh to record anything you want, it doesn't have to be just your screen and system audio. If you want to change stuff around, just know that all that's required is: (1) HLS segments appear as `stream/stream*.m4s`, (2) `stream/init.mp4` is the inital HLS segment, and (3) `stream/stream.m3u8` is the HLS playlist. (All this is taken care of in `stream.sh` by default.)
|
||||
|
||||
Assuming your FFmpeg command is working, this is what you have to do.
|
||||
|
||||
### Start streaming
|
||||
|
||||
Before you start streaming you need to edit `config.toml`. That file has a list of fonts used by the captcha, and you might not have them. Replace them with some fonts you do have.
|
||||
|
||||
#### FFmpeg
|
||||
|
||||
Go to the project root and type `sh stream.sh`. This starts the livestream.
|
||||
If you're using OBS Studio, click Start Recording (**not** Start Streaming). If you're using the script instead, then go to the project root and type `sh stream.sh`.
|
||||
This starts the livestream.
|
||||
|
||||
#### Flask
|
||||
Go to the project root and type `flask run`. This starts the websever.
|
||||
|
|
|
@ -20,9 +20,6 @@ HLS_LIST_SIZE=$(echo $DELETION_THRESHOLD / $HLS_TIME | bc)
|
|||
mkdir -p stream
|
||||
rm stream/*
|
||||
|
||||
# This shell script's process ID, so we can tell if the stream is online or not
|
||||
echo $$ > stream/pid.txt
|
||||
|
||||
# This exists so we can corrupt video streams of viewers who are too delayed
|
||||
ffmpeg -f lavfi -i color=size="$BOX_WIDTH"x"$BOX_HEIGHT":rate=$FRAMERATE:color=black \
|
||||
-f lavfi -i anullsrc=channel_layout=stereo:sample_rate=48000 \
|
||||
|
@ -33,9 +30,6 @@ ffmpeg -f lavfi -i color=size="$BOX_WIDTH"x"$BOX_HEIGHT":rate=$FRAMERATE:color=b
|
|||
rm stream/corrupt.m3u8 stream/init.mp4
|
||||
mv stream/corrupt0.m4s stream/corrupt.m4s
|
||||
|
||||
# The current time, so we know when the stream started
|
||||
date +%s > stream/start.txt
|
||||
|
||||
# This is the command you should edit
|
||||
ffmpeg -thread_queue_size 2048 -video_size "$BOX_WIDTH"x"$BOX_HEIGHT" -framerate $FRAMERATE -f x11grab -i :0.0+$BOX_OFFSET_X,$BOX_OFFSET_Y \
|
||||
-thread_queue_size 2048 -f pulse -i default \
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import os
|
||||
import time
|
||||
from website.constants import CONFIG, SEGMENTS_DIR, SEGMENT_INIT, VIEW_COUNTING_PERIOD
|
||||
from website.utils.stream import _is_segment, _segment_number, _get_segments
|
||||
from website.utils.stream import _is_segment, _segment_number, get_segments, is_online
|
||||
|
||||
SEGMENT = 'stream{number}.m4s'
|
||||
CORRUPTING_SEGMENT = 'corrupt.m4s'
|
||||
|
@ -11,7 +11,7 @@ def resolve_segment_offset(segment_offset=1):
|
|||
'''
|
||||
Returns the number of the segment at `segment_offset` (1 is most recent segment)
|
||||
'''
|
||||
segments = _get_segments(sort=True)
|
||||
segments = get_segments()
|
||||
try:
|
||||
segment = segments[-min(segment_offset, len(segments))]
|
||||
except IndexError:
|
||||
|
@ -19,10 +19,12 @@ def resolve_segment_offset(segment_offset=1):
|
|||
return _segment_number(segment)
|
||||
|
||||
def get_next_segment(after, start_segment):
|
||||
if not is_online():
|
||||
raise SegmentUnavailable(f'stream went offline')
|
||||
start = time.time()
|
||||
while True:
|
||||
time.sleep(1)
|
||||
segments = _get_segments(sort=True)
|
||||
segments = get_segments()
|
||||
if after == None:
|
||||
try:
|
||||
if os.path.getsize(os.path.join(SEGMENTS_DIR, SEGMENT_INIT)) > 0: # FFmpeg creates an empty init.mp4 and only writes to it when the first segment exists
|
||||
|
@ -41,7 +43,7 @@ def get_next_segment(after, start_segment):
|
|||
|
||||
if time.time() - start >= STREAM_TIMEOUT():
|
||||
if after == None:
|
||||
raise SegmentUnavailable('timeout waiting for initial segment {SEGMENT_INIT}')
|
||||
raise SegmentUnavailable(f'timeout waiting for initial segment {SEGMENT_INIT}')
|
||||
elif after == SEGMENT_INIT:
|
||||
raise SegmentUnavailable(f'timeout waiting for start segment {start_segment}')
|
||||
else:
|
||||
|
|
|
@ -7,8 +7,6 @@ ROOT = os.path.dirname(current_app.root_path)
|
|||
SEGMENTS_DIR = os.path.join(ROOT, 'stream')
|
||||
SEGMENTS_M3U8 = os.path.join(SEGMENTS_DIR, 'stream.m3u8')
|
||||
STREAM_TITLE = os.path.join(ROOT, 'title.txt')
|
||||
STREAM_START = os.path.join(SEGMENTS_DIR, 'start.txt')
|
||||
STREAM_PIDFILE = os.path.join(SEGMENTS_DIR, 'pid.txt')
|
||||
|
||||
DIR_STATIC = os.path.join(ROOT, 'website', 'static')
|
||||
DIR_STATIC_EXTERNAL = os.path.join(DIR_STATIC, 'external')
|
||||
|
@ -22,6 +20,7 @@ with open(CONFIG_FILE) as fp:
|
|||
# these two are accessed through `CONFIG`; they're just here for completeness
|
||||
#CAPTCHA_FONTS = CONFIG['captcha']['fonts']
|
||||
#HLS_TIME = CONFIG['stream']['hls_time'] # seconds per segment
|
||||
# TODO: always read hls_time from stream.m3u8
|
||||
|
||||
VIEW_COUNTING_PERIOD = 30 # count views from the last x seconds
|
||||
CHAT_TIMEOUT = 5 # seconds between chat messages
|
||||
|
@ -47,6 +46,11 @@ SEGMENT_INIT = 'init.mp4'
|
|||
|
||||
VIDEOJS_ENABLED_BY_DEFAULT = False
|
||||
|
||||
# if stream.m3u8 is not modified for this duration, consider the stream offline
|
||||
# even if #EXT-X-ENDLIST is not present in the file. if this happens something
|
||||
# has gone wrong in FFmpeg so we should turn off the stream.
|
||||
STALE_PLAYLIST_THRESHOLD = 30
|
||||
|
||||
# notes: messages that can appear in the comment box
|
||||
N_NONE = 0
|
||||
N_TOKEN_EMPTY = 1
|
||||
|
|
|
@ -67,10 +67,11 @@ def playlist():
|
|||
viewership.made_request(token)
|
||||
|
||||
try:
|
||||
token_playlist = stream.TokenPlaylist(token)
|
||||
token_playlist = stream.token_playlist(token)
|
||||
except FileNotFoundError:
|
||||
return abort(404)
|
||||
response = send_file(token_playlist, mimetype='application/x-mpegURL', add_etags=False)
|
||||
response = make_response(token_playlist)
|
||||
response.mimetype = 'application/x-mpegURL'
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
return response
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ import os
|
|||
import re
|
||||
import time
|
||||
from flask import abort
|
||||
from website.constants import SEGMENTS_DIR, SEGMENTS_M3U8, SEGMENT_INIT, STREAM_PIDFILE, STREAM_START, STREAM_TITLE
|
||||
from website.constants import CONFIG, SEGMENTS_DIR, SEGMENTS_M3U8, SEGMENT_INIT, STREAM_TITLE, STALE_PLAYLIST_THRESHOLD
|
||||
|
||||
RE_SEGMENT_OR_INIT = re.compile(r'\b(stream(?P<number>\d+)\.m4s|init\.mp4)\b')
|
||||
RE_SEGMENT = re.compile(r'stream(?P<number>\d+)\.m4s')
|
||||
|
@ -14,45 +14,42 @@ def _segment_number(fn):
|
|||
def _is_segment(fn):
|
||||
return bool(RE_SEGMENT.fullmatch(fn))
|
||||
|
||||
def _get_segments(sort=False):
|
||||
try:
|
||||
m3u8 = [line.rstrip() for line in open(SEGMENTS_M3U8).readlines() if _is_segment(line.rstrip())]
|
||||
except FileNotFoundError:
|
||||
def get_segments():
|
||||
if playlist_is_stale():
|
||||
return []
|
||||
|
||||
if sort:
|
||||
m3u8.sort(key=_segment_number)
|
||||
m3u8 = []
|
||||
try:
|
||||
with open(SEGMENTS_M3U8) as fp:
|
||||
for line in fp.readlines():
|
||||
line = line.rstrip()
|
||||
if _is_segment(line):
|
||||
m3u8.append(line)
|
||||
# the stream has ended, return an empty list
|
||||
elif line == '#EXT-X-ENDLIST':
|
||||
m3u8.clear()
|
||||
break
|
||||
except FileNotFoundError:
|
||||
m3u8.clear()
|
||||
m3u8.sort(key=_segment_number)
|
||||
return m3u8
|
||||
|
||||
def _is_available(fn, m3u8):
|
||||
return fn in m3u8
|
||||
|
||||
def current_segment():
|
||||
if is_online():
|
||||
segments = _get_segments()
|
||||
if len(segments) == 0:
|
||||
return None
|
||||
last_segment = max(segments, key=_segment_number)
|
||||
return _segment_number(last_segment)
|
||||
else:
|
||||
return None
|
||||
segments = get_segments()
|
||||
if segments:
|
||||
return _segment_number(segments[-1])
|
||||
return None
|
||||
|
||||
def playlist_is_stale():
|
||||
try:
|
||||
return time.time() - os.path.getmtime(SEGMENTS_M3U8) >= STALE_PLAYLIST_THRESHOLD
|
||||
except FileNotFoundError:
|
||||
return True
|
||||
|
||||
def is_online():
|
||||
# If the pidfile doesn't exist, return False
|
||||
try:
|
||||
pid = open(STREAM_PIDFILE).read()
|
||||
pid = int(pid)
|
||||
except (FileNotFoundError, ValueError):
|
||||
return False
|
||||
|
||||
# If the process ID doesn't exist, return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
# Otherwise return True
|
||||
return True
|
||||
return bool(get_segments())
|
||||
|
||||
def get_title():
|
||||
try:
|
||||
|
@ -61,11 +58,18 @@ def get_title():
|
|||
return ''
|
||||
|
||||
def get_start(absolute=True, relative=False):
|
||||
try:
|
||||
start = open(STREAM_START).read()
|
||||
start = int(start)
|
||||
except (FileNotFoundError, ValueError):
|
||||
start = None
|
||||
start = None
|
||||
# if segments exist
|
||||
if is_online():
|
||||
try:
|
||||
# then the stream start at the mtime of init.mp4
|
||||
start = os.path.getmtime(os.path.join(SEGMENTS_DIR, SEGMENT_INIT))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
else:
|
||||
# minus the length of 1 segment
|
||||
# (because init.mp4 is written to when the first segment is created)
|
||||
start = int(start) - CONFIG['stream']['hls_time']
|
||||
|
||||
diff = None if start == None else int(time.time()) - start
|
||||
|
||||
|
@ -76,32 +80,19 @@ def get_start(absolute=True, relative=False):
|
|||
elif relative:
|
||||
return diff
|
||||
|
||||
|
||||
class TokenPlaylist:
|
||||
def token_playlist(token):
|
||||
'''
|
||||
Append '?token={token}' to each segment in the playlist
|
||||
'''
|
||||
def __init__(self, token):
|
||||
self.token = token
|
||||
self.fp = open(SEGMENTS_M3U8)
|
||||
self.leftover = b''
|
||||
|
||||
def read(self, n):
|
||||
if self.token == None:
|
||||
return self.fp.read(n)
|
||||
|
||||
leftover = self.leftover
|
||||
chunk = b''
|
||||
while True:
|
||||
line = self.fp.readline()
|
||||
if len(line) == 0:
|
||||
break
|
||||
injected_line = RE_SEGMENT_OR_INIT.sub(lambda match: f'{match.group()}?token={self.token}', line)
|
||||
chunk += injected_line.encode()
|
||||
if len(chunk) >= n:
|
||||
chunk, self.leftover = chunk[:n], chunk[n:]
|
||||
break
|
||||
return leftover + chunk
|
||||
|
||||
def close(self):
|
||||
self.fp.close()
|
||||
if playlist_is_stale():
|
||||
return []
|
||||
m3u8 = []
|
||||
with open(SEGMENTS_M3U8) as fp:
|
||||
for line in fp.readlines():
|
||||
line = line.rstrip()
|
||||
line = RE_SEGMENT_OR_INIT.sub(lambda match: f'{match.group()}?token={token}', line)
|
||||
m3u8.append(line)
|
||||
# the stream has ended, pretend this file doesn't exist
|
||||
if line == '#EXT-X-ENDLIST':
|
||||
raise FileNotFoundError
|
||||
return '\n'.join(m3u8)
|
||||
|
|
読み込み中…
新しいイシューから参照