diff --git a/.gitignore b/.gitignore index f3857de9..822f5bed 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ __pycache__/ *-orig.* *.json +test/ tests/ .pytest_cache/ venv/ diff --git a/README.md b/README.md index f8e7a4a5..cb1ac067 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ **ffplayout-engine** ================ + [![made-with-python](https://img.shields.io/badge/Made%20with-Python-1f425f.svg)](https://www.python.org/) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) The purpose with ffplayout is to provide a 24/7 broadcasting solution that plays a *json* playlist for every day, while keeping the current playlist editable. -#### Check [ffplayout-gui](https://github.com/ffplayout/ffplayout-gui): web-based GUI for ffplayout. +**Check [ffplayout-gui](https://github.com/ffplayout/ffplayout-gui): web-based GUI for ffplayout** -Features +**Features** ----- - have all values in a separate config file @@ -29,17 +30,22 @@ Features - on posix systems ffplayout can reload config with *SIGHUP* - logging to files, or colored output to console - add filters to input, if is necessary to match output stream: - - **yadif** (deinterlacing) - - **pad** (letterbox or pillarbox to fit aspect) - - **fps** (change fps) - - **scale** (fit target resolution) - - **aevalsrc** (if video have no audio) - - **apad** (add silence if audio duration is to short) - - **tpad** (add black frames if video duration is to short) - + - **yadif** (deinterlacing) + - **pad** (letterbox or pillarbox to fit aspect) + - **fps** (change fps) + - **scale** (fit target resolution) + - **aevalsrc** (if video have no audio) + - **apad** (add silence if audio duration is to short) + - **tpad** (add black frames if video duration is to short) +- different types of [output](https://github.com/ffplayout/ffplayout-engine/wiki/Outputs): + - **stream** + - **desktop** + - **HLS** + - **custom** Requirements ----- + - python version 3.6+ - python module **watchdog** (only when `playlist_mode: False`) - python module **colorama** if you are on windows @@ -81,11 +87,14 @@ JSON Playlist Example } ``` -#### Warning: +**Warning** +----- + (Endless) streaming over multiple days will only work when config have **day_start** value and the **length** value is **24 hours**. If you need only some hours for every day, use a *cron* job, or something similar. Remote source from URL ----- + You can use sources from remote URL in that way: ```json @@ -97,17 +106,21 @@ You can use sources from remote URL in that way: "source": "https://example.org/big_buck_bunny.webm" } ``` + But be careful with it, better test it multiple times! More informations in [Wiki](https://github.com/ffplayout/ffplayout-engine/wiki/Remote-URL-Source) Installation ----- + Check [INSTALL.md](docs/INSTALL.md) Start with Arguments ----- + ffplayout also allows the passing of parameters: + - `-c, --config` use given config file - `-d, --desktop` preview on desktop - `-f, --folder` use folder for playing @@ -119,10 +132,11 @@ ffplayout also allows the passing of parameters: You can run the command like: -``` +```SHELL ./ffplayout.py -l none -p ~/playlist.json -d -s now -t none ``` Play on Desktop ----- -For playing on desktop use `-d` argument or set `preview: True` in config under `out:`. + +For playing on desktop use `-d` argument or set `mode: 'desktop'` in config under `out:`. diff --git a/ffplayout.py b/ffplayout.py index 2b1199a9..10111845 100755 --- a/ffplayout.py +++ b/ffplayout.py @@ -19,15 +19,9 @@ # ------------------------------------------------------------------------------ import os -from subprocess import PIPE, Popen -from threading import Thread +from pydoc import locate -from ffplayout.folder import GetSourceFromFolder, MediaStore, MediaWatcher -from ffplayout.playlist import GetSourceFromPlaylist -from ffplayout.utils import (_ff, _log, _playlist, _playout, _pre_comp, _text, - ffmpeg_stderr_reader, get_date, messenger, - pre_audio_codec, stdin_args, terminate_processes, - validate_ffmpeg_libs) +from ffplayout.utils import _playout, validate_ffmpeg_libs try: if os.name != 'posix': @@ -36,9 +30,6 @@ try: except ImportError: print('colorama import failed, no colored console output on windows...') -_WINDOWS = os.name == 'nt' -COPY_BUFSIZE = 1024 * 1024 if _WINDOWS else 65424 - # ------------------------------------------------------------------------------ # main functions @@ -49,105 +40,15 @@ def main(): pipe ffmpeg pre-process to final ffmpeg post-process, or play with ffplay """ - year = get_date(False).split('-')[0] - overlay = [] - ff_pre_settings = [ - '-pix_fmt', 'yuv420p', '-r', str(_pre_comp.fps), - '-c:v', 'mpeg2video', '-intra', - '-b:v', '{}k'.format(_pre_comp.v_bitrate), - '-minrate', '{}k'.format(_pre_comp.v_bitrate), - '-maxrate', '{}k'.format(_pre_comp.v_bitrate), - '-bufsize', '{}k'.format(_pre_comp.v_bufsize) - ] + pre_audio_codec() + ['-f', 'mpegts', '-'] + for output in os.listdir('ffplayout/output'): + if os.path.isfile(os.path.join('ffplayout/output', output)) \ + and output != '__init__.py': + mode = os.path.splitext(output)[0] + if mode == _playout.mode: + output = locate('ffplayout.output.{}.output'.format(mode)) - if _text.add_text: - messenger.info('Using drawtext node, listening on address: {}'.format( - _text.address - )) - overlay = [ - '-vf', - "null,zmq=b=tcp\\\\://'{}',drawtext=text='':fontfile='{}'".format( - _text.address.replace(':', '\\:'), _text.fontfile) - ] - - try: - if _playout.preview or stdin_args.desktop: - # preview playout to player - _ff.encoder = Popen([ - 'ffplay', '-hide_banner', '-nostats', '-i', 'pipe:0' - ] + overlay, stderr=PIPE, stdin=PIPE, stdout=None) - else: - _ff.encoder = Popen([ - 'ffmpeg', '-v', _log.ff_level.lower(), '-hide_banner', - '-nostats', '-re', '-thread_queue_size', '256', '-i', 'pipe:0' - ] + overlay + [ - '-metadata', 'service_name=' + _playout.name, - '-metadata', 'service_provider=' + _playout.provider, - '-metadata', 'year={}'.format(year) - ] + _playout.post_comp_param + [_playout.out_addr], - stdin=PIPE, stderr=PIPE) - - enc_err_thread = Thread(target=ffmpeg_stderr_reader, - args=(_ff.encoder.stderr, False)) - enc_err_thread.daemon = True - enc_err_thread.start() - - if _playlist.mode and not stdin_args.folder: - watcher = None - get_source = GetSourceFromPlaylist() - else: - messenger.info('Start folder mode') - media = MediaStore() - watcher = MediaWatcher(media) - get_source = GetSourceFromFolder(media) - - try: - for src_cmd in get_source.next(): - messenger.debug('src_cmd: "{}"'.format(src_cmd)) - if src_cmd[0] == '-i': - current_file = src_cmd[1] - else: - current_file = src_cmd[3] - - messenger.info('Play: "{}"'.format(current_file)) - - with Popen([ - 'ffmpeg', '-v', _log.ff_level.lower(), '-hide_banner', - '-nostats'] + src_cmd + ff_pre_settings, - stdout=PIPE, stderr=PIPE) as _ff.decoder: - - dec_err_thread = Thread(target=ffmpeg_stderr_reader, - args=(_ff.decoder.stderr, True)) - dec_err_thread.daemon = True - dec_err_thread.start() - - while True: - buf = _ff.decoder.stdout.read(COPY_BUFSIZE) - if not buf: - break - _ff.encoder.stdin.write(buf) - - except BrokenPipeError: - messenger.error('Broken Pipe!') - terminate_processes(watcher) - - except SystemExit: - messenger.info('Got close command') - terminate_processes(watcher) - - except KeyboardInterrupt: - messenger.warning('Program terminated') - terminate_processes(watcher) - - # close encoder when nothing is to do anymore - if _ff.encoder.poll() is None: - _ff.encoder.terminate() - - finally: - if _ff.encoder.poll() is None: - _ff.encoder.terminate() - _ff.encoder.wait() + output() if __name__ == '__main__': diff --git a/ffplayout.yml b/ffplayout.yml index b8227814..eee6b81e 100644 --- a/ffplayout.yml +++ b/ffplayout.yml @@ -31,16 +31,19 @@ logging: log_level: "DEBUG" ffmpeg_level: "ERROR" -pre_compress: - helptext: Settings for the pre-compression. All clips get prepared in that way, - so the input for the final compression is unique. 'aspect' must be a float - number. 'logo' is only used if the path exist. 'logo_scale' scale the logo to - target size, leave it blank when no scaling is needed, format is 'number:number', - for example '100:-1' for proportional scaling. With 'logo_opacity' logo can - become transparent. With 'logo_filter' 'overlay=W-w-12:12' you can modify - the logo position. With 'use_loudnorm' you can activate single pass EBU R128 - loudness normalization. 'loud_*' can adjust the loudnorm filter. [Output is - always progressive!] +processing: + helptext: Default processing, for all clips that they get prepared in that way, + so the output is unique. 'aspect' must be a float number. 'logo' is only used + if the path exist. 'logo_scale' scale the logo to target size, leave it blank + when no scaling is needed, format is 'number:number', for example '100:-1' + for proportional scaling. With 'logo_opacity' logo can become transparent. + With 'logo_filter' 'overlay=W-w-12:12' you can modify the logo position. + With 'use_loudnorm' you can activate single pass EBU R128 loudness normalization. + 'loud_*' can adjust the loudnorm filter. 'output_count' sets the outputs for + the filtering, > 1 gives the option to use the same filters for multiple outputs. + This outputs can be taken in 'ffmpeg_param', names will be vout2, vout3; + aout2, aout2 etc.'use_realtime' is realtime filter, it works not in all scenarios, + but for example is necessary for hls output. width: 1024 height: 576 aspect: 1.778 @@ -54,9 +57,11 @@ pre_compress: loud_I: -18 loud_TP: -1.5 loud_LRA: 11 + output_count: 1 + use_realtime: false playlist: - helptext: Set 'playlist_mode' to 'False' if you want to play clips from the [STORAGE] + helptext: Set 'playlist_mode' to 'False' if you want to play clips from the 'storage' section. Put only the root path here, for example '/playlists' subfolders are readed by the script. Subfolders needs this structur '/playlists/2018/01' (/playlists/year/month). 'day_start' means at which time the playlist should @@ -70,9 +75,8 @@ playlist: storage: helptext: Play ordered or ramdomly files from path. 'filler_clip' is for fill - the end to reach 24 hours, it will loop when is necessary extensions search - only files with this extension, can be a list. Set 'shuffle' to 'True' to - pick files randomly. + the end to reach 24 hours, it will loop when is necessary. 'extensions' search + only files with this extension. Set 'shuffle' to 'True' to pick files randomly. path: "/mediaStorage" filler_clip: "/mediaStorage/filler/filler.mp4" extensions: @@ -84,18 +88,24 @@ text: helptext: Overlay text in combination with libzmq for remote text manipulation. On windows fontfile path need to be like this 'C\:/WINDOWS/fonts/DejaVuSans.ttf'. In a standard environment the filter drawtext node is Parsed_drawtext_2. + 'over_pre' if True text will be overlay in pre processing. Continue same text + over multiple files is in that mode not possible. add_text: False + over_pre: False bind_address: "127.0.0.1:5555" fontfile: "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" out: - helptext: The final playout post compression. Set the settings to your needs. - 'preview' works only on a desktop system with ffplay!! Set it to 'True', if - you need it. - preview: False + helptext: The final playout compression. Set the settings to your needs. + 'mode' has the standard options 'desktop', 'hls', 'stream'. Self made outputs + can be define, by adding script in output folder with an 'output' function inside. + 'stream_output' is for streaming output, two ffmpeg instances are fired up, for + pre- and post-processing. 'hls_output' is for direct output to hls playlist, + without pre- and post-processing, mode must be 'hls'. + mode: 'stream' service_name: "Live Stream" service_provider: "example.org" - post_ffmpeg_param: >- + ffmpeg_param: >- -c:v libx264 -crf 23 -x264-params keyint=50:min-keyint=25:scenecut=-1 @@ -107,6 +117,14 @@ out: -c:a aac -ar 44100 -b:a 128k + stream_output: >- -flags +global_header - -f flv - out_addr: "rtmp://localhost/live/stream" + -f flv "rtmp://localhost/live/stream" + hls_output: >- + -flags +cgop + -f hls + -hls_time 6 + -hls_list_size 600 + -hls_delete_threshold 30 + -hls_flags append_list+delete_segments+omit_endlist+program_date_time + /var/www/srs/live/stream.m3u8 diff --git a/ffplayout/filters.py b/ffplayout/filters.py index 3a93a97a..d854fa84 100644 --- a/ffplayout/filters.py +++ b/ffplayout/filters.py @@ -21,13 +21,25 @@ import math import os import re -from .utils import _pre_comp +from .utils import _global, _pre, _text # ------------------------------------------------------------------------------ # building filters, # when is needed add individuell filters to match output format # ------------------------------------------------------------------------------ + +def text_filter(): + filter_chain = [] + + if _text.add_text and _text.over_pre: + filter_chain = [ + "null,zmq=b=tcp\\\\://'{}',drawtext=text='':fontfile='{}'".format( + _text.address.replace(':', '\\:'), _text.fontfile)] + + return filter_chain + + def deinterlace_filter(probe): """ when material is interlaced, @@ -50,15 +62,15 @@ def pad_filter(probe): filter_chain = [] if not math.isclose(probe.video[0]['aspect'], - _pre_comp.aspect, abs_tol=0.03): - if probe.video[0]['aspect'] < _pre_comp.aspect: + _pre.aspect, abs_tol=0.03): + if probe.video[0]['aspect'] < _pre.aspect: filter_chain.append( - 'pad=ih*{}/{}/sar:ih:(ow-iw)/2:(oh-ih)/2'.format(_pre_comp.w, - _pre_comp.h)) - elif probe.video[0]['aspect'] > _pre_comp.aspect: + 'pad=ih*{}/{}/sar:ih:(ow-iw)/2:(oh-ih)/2'.format(_pre.w, + _pre.h)) + elif probe.video[0]['aspect'] > _pre.aspect: filter_chain.append( - 'pad=iw:iw*{}/{}/sar:(ow-iw)/2:(oh-ih)/2'.format(_pre_comp.h, - _pre_comp.w)) + 'pad=iw:iw*{}/{}/sar:(ow-iw)/2:(oh-ih)/2'.format(_pre.h, + _pre.w)) return filter_chain @@ -69,8 +81,8 @@ def fps_filter(probe): """ filter_chain = [] - if probe.video[0]['fps'] != _pre_comp.fps: - filter_chain.append('fps={}'.format(_pre_comp.fps)) + if probe.video[0]['fps'] != _pre.fps: + filter_chain.append('fps={}'.format(_pre.fps)) return filter_chain @@ -82,13 +94,13 @@ def scale_filter(probe): """ filter_chain = [] - if int(probe.video[0]['width']) != _pre_comp.w or \ - int(probe.video[0]['height']) != _pre_comp.h: - filter_chain.append('scale={}:{}'.format(_pre_comp.w, _pre_comp.h)) + if int(probe.video[0]['width']) != _pre.w or \ + int(probe.video[0]['height']) != _pre.h: + filter_chain.append('scale={}:{}'.format(_pre.w, _pre.h)) if not math.isclose(probe.video[0]['aspect'], - _pre_comp.aspect, abs_tol=0.03): - filter_chain.append('setdar=dar={}'.format(_pre_comp.aspect)) + _pre.aspect, abs_tol=0.03): + filter_chain.append('setdar=dar={}'.format(_pre.aspect)) return filter_chain @@ -115,27 +127,27 @@ def overlay_filter(duration, ad, ad_last, ad_next): when ad is comming next fade logo out, when clip before was an ad fade logo in """ - logo_filter = '[v]null[logo]' + logo_filter = '[v]null' scale_filter = '' - if _pre_comp.add_logo and os.path.isfile(_pre_comp.logo) and not ad: + if _pre.add_logo and os.path.isfile(_pre.logo) and not ad: logo_chain = [] - if _pre_comp.logo_scale and \ - re.match(r'\d+:-?\d+', _pre_comp.logo_scale): - scale_filter = 'scale={},'.format(_pre_comp.logo_scale) + if _pre.logo_scale and \ + re.match(r'\d+:-?\d+', _pre.logo_scale): + scale_filter = 'scale={},'.format(_pre.logo_scale) logo_extras = 'format=rgba,{}colorchannelmixer=aa={}'.format( - scale_filter, _pre_comp.logo_opacity) + scale_filter, _pre.logo_opacity) loop = 'loop=loop=-1:size=1:start=0' logo_chain.append( - 'movie={},{},{}'.format(_pre_comp.logo, loop, logo_extras)) + 'movie={},{},{}'.format(_pre.logo, loop, logo_extras)) if ad_last: logo_chain.append('fade=in:st=0:d=1.0:alpha=1') if ad_next: logo_chain.append('fade=out:st={}:d=1.0:alpha=1'.format( duration - 1)) - logo_filter = '{}[l];[v][l]{}:shortest=1[logo]'.format( - ','.join(logo_chain), _pre_comp.logo_filter) + logo_filter = '{}[l];[v][l]{}:shortest=1'.format( + ','.join(logo_chain), _pre.logo_filter) return logo_filter @@ -161,9 +173,9 @@ def add_loudnorm(probe): """ loud_filter = [] - if probe.audio and _pre_comp.add_loudnorm: + if probe.audio and _pre.add_loudnorm: loud_filter = [('loudnorm=I={}:TP={}:LRA={}').format( - _pre_comp.loud_i, _pre_comp.loud_tp, _pre_comp.loud_lra)] + _pre.loud_i, _pre.loud_tp, _pre.loud_lra)] return loud_filter @@ -175,7 +187,7 @@ def extend_audio(probe, duration): pad_filter = [] if probe.audio and 'duration' in probe.audio[0] and \ - duration > float(probe.audio[0]['duration']) + 0.3: + duration > float(probe.audio[0]['duration']) + 0.1: pad_filter.append('apad=whole_dur={}'.format(duration)) return pad_filter @@ -189,25 +201,61 @@ def extend_video(probe, duration, target_duration): if 'duration' in probe.video[0] and \ target_duration < duration > float( - probe.video[0]['duration']) + 0.3: + probe.video[0]['duration']) + 0.1: pad_filter.append('tpad=stop_mode=add:stop_duration={}'.format( duration - float(probe.video[0]['duration']))) return pad_filter +def realtime_filter(duration, track=''): + speed_filter = '' + + if _pre.realtime: + speed_filter = ',{}realtime=speed=1'.format(track) + + if _global.time_delta < 0: + speed = duration / (duration + _global.time_delta) + + if speed < 1.1: + speed_filter = ',{}realtime=speed={}'.format(track, speed) + + return speed_filter + + +def split_filter(filter_type): + map_node = [] + filter_prefix = '' + _filter = '' + + if filter_type == 'a': + filter_prefix = 'a' + + if _pre.output_count > 1: + for num in range(_pre.output_count): + map_node.append('[{}out{}]'.format(filter_type, num + 1)) + + _filter = ',{}split={}{}'.format(filter_prefix, _pre.output_count, + ''.join(map_node)) + + else: + _filter = '[{}out1]'.format(filter_type) + + return _filter + + def build_filtergraph(duration, seek, out, ad, ad_last, ad_next, probe, msg): """ build final filter graph, with video and audio chain """ video_chain = [] audio_chain = [] - video_map = ['-map', '[logo]'] if out > duration: seek = 0 if probe.video[0]: + video_chain += text_filter() video_chain += deinterlace_filter(probe) video_chain += pad_filter(probe) video_chain += fps_filter(probe) @@ -229,17 +277,19 @@ def build_filtergraph(duration, seek, out, ad, ad_last, ad_next, probe, msg): video_filter = 'null[v]' logo_filter = overlay_filter(out - seek, ad, ad_last, ad_next) + v_speed = realtime_filter(out - seek) + v_split = split_filter('v') + video_map = ['-map', '[vout1]'] video_filter = [ - '-filter_complex', '[0:v]{};{}'.format( - video_filter, logo_filter)] + '-filter_complex', '[0:v]{};{}{}{}'.format( + video_filter, logo_filter, v_speed, v_split)] - if audio_chain: - audio_filter = [ - '-filter_complex', '{}[a]'.format(','.join(audio_chain))] - audio_map = ['-map', '[a]'] - else: - audio_filter = [] - audio_map = ['-map', '0:a'] + a_speed = realtime_filter(out - seek, 'a') + a_split = split_filter('a') + audio_map = ['-map', '[aout1]'] + audio_filter = [ + '-filter_complex', '{}{}{}'.format(','.join(audio_chain), + a_speed, a_split)] if probe.video[0]: return video_filter + audio_filter + video_map + audio_map diff --git a/ffplayout/output/__init__.py b/ffplayout/output/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ffplayout/output/desktop.py b/ffplayout/output/desktop.py new file mode 100644 index 00000000..efdc7f34 --- /dev/null +++ b/ffplayout/output/desktop.py @@ -0,0 +1,104 @@ +import os +from subprocess import PIPE, Popen +from threading import Thread + +from ffplayout.folder import GetSourceFromFolder, MediaStore, MediaWatcher +from ffplayout.playlist import GetSourceFromPlaylist +from ffplayout.utils import (_ff, _log, _playlist, _pre, _text, + ffmpeg_stderr_reader, messenger, pre_audio_codec, + stdin_args, terminate_processes) + +_WINDOWS = os.name == 'nt' +COPY_BUFSIZE = 1024 * 1024 if _WINDOWS else 65424 + + +def output(): + """ + this output is for playing on desktop with ffplay + """ + overlay = [] + + ff_pre_settings = [ + '-pix_fmt', 'yuv420p', '-r', str(_pre.fps), + '-c:v', 'mpeg2video', '-intra', + '-b:v', '{}k'.format(_pre.v_bitrate), + '-minrate', '{}k'.format(_pre.v_bitrate), + '-maxrate', '{}k'.format(_pre.v_bitrate), + '-bufsize', '{}k'.format(_pre.v_bufsize) + ] + pre_audio_codec() + ['-f', 'mpegts', '-'] + + if _text.add_text and not _text.over_pre: + messenger.info('Using drawtext node, listening on address: {}'.format( + _text.address + )) + overlay = [ + '-vf', + "null,zmq=b=tcp\\\\://'{}',drawtext=text='':fontfile='{}'".format( + _text.address.replace(':', '\\:'), _text.fontfile) + ] + + try: + _ff.encoder = Popen([ + 'ffplay', '-hide_banner', '-nostats', '-i', 'pipe:0' + ] + overlay, stderr=PIPE, stdin=PIPE, stdout=None) + + enc_err_thread = Thread(target=ffmpeg_stderr_reader, + args=(_ff.encoder.stderr, False)) + enc_err_thread.daemon = True + enc_err_thread.start() + + if _playlist.mode and not stdin_args.folder: + watcher = None + get_source = GetSourceFromPlaylist() + else: + messenger.info('Start folder mode') + media = MediaStore() + watcher = MediaWatcher(media) + get_source = GetSourceFromFolder(media) + + try: + for src_cmd in get_source.next(): + messenger.debug('src_cmd: "{}"'.format(src_cmd)) + if src_cmd[0] == '-i': + current_file = src_cmd[1] + else: + current_file = src_cmd[3] + + messenger.info('Play: "{}"'.format(current_file)) + + with Popen([ + 'ffmpeg', '-v', _log.ff_level.lower(), '-hide_banner', + '-nostats'] + src_cmd + ff_pre_settings, + stdout=PIPE, stderr=PIPE) as _ff.decoder: + + dec_err_thread = Thread(target=ffmpeg_stderr_reader, + args=(_ff.decoder.stderr, True)) + dec_err_thread.daemon = True + dec_err_thread.start() + + while True: + buf = _ff.decoder.stdout.read(COPY_BUFSIZE) + if not buf: + break + _ff.encoder.stdin.write(buf) + + except BrokenPipeError: + messenger.error('Broken Pipe!') + terminate_processes(watcher) + + except SystemExit: + messenger.info('Got close command') + terminate_processes(watcher) + + except KeyboardInterrupt: + messenger.warning('Program terminated') + terminate_processes(watcher) + + # close encoder when nothing is to do anymore + if _ff.encoder.poll() is None: + _ff.encoder.terminate() + + finally: + if _ff.encoder.poll() is None: + _ff.encoder.terminate() + _ff.encoder.wait() diff --git a/ffplayout/output/hls.py b/ffplayout/output/hls.py new file mode 100644 index 00000000..25ec4b04 --- /dev/null +++ b/ffplayout/output/hls.py @@ -0,0 +1,105 @@ +import os +import re +from glob import iglob +from subprocess import PIPE, Popen +from threading import Thread + +from ffplayout.folder import GetSourceFromFolder, MediaStore, MediaWatcher +from ffplayout.playlist import GetSourceFromPlaylist +from ffplayout.utils import (_ff, _log, _playlist, _playout, + ffmpeg_stderr_reader, get_date, messenger, + stdin_args, terminate_processes) + + +def clean_ts(): + """ + this function get all *.m3u8 playlists from config, + read lines from them until it founds first *.ts file, + then it checks if files on harddrive are older then this first *.ts + and if so delete them + """ + playlists = [p for p in _playout.hls_output if 'm3u8' in p] + + for playlist in playlists: + messenger.debug('cleanup *.ts files from: "{}"'.format(playlist)) + test_num = 0 + hls_path = os.path.dirname(playlist) + with open(playlist, 'r') as m3u8: + for line in m3u8: + if '.ts' in line: + test_num = int(re.findall(r'(\d+).ts', line)[0]) + break + + for ts_file in iglob(os.path.join(hls_path, '*.ts')): + ts_num = int(re.findall(r'(\d+).ts', ts_file)[0]) + + if test_num > ts_num: + os.remove(ts_file) + + +def output(): + """ + this output is hls output, no preprocess is needed. + """ + year = get_date(False).split('-')[0] + + try: + if _playlist.mode and not stdin_args.folder: + watcher = None + get_source = GetSourceFromPlaylist() + else: + messenger.info('Start folder mode') + media = MediaStore() + watcher = MediaWatcher(media) + get_source = GetSourceFromFolder(media) + + try: + for src_cmd in get_source.next(): + messenger.debug('src_cmd: "{}"'.format(src_cmd)) + if src_cmd[0] == '-i': + current_file = src_cmd[1] + else: + current_file = src_cmd[3] + + messenger.info('Play: "{}"'.format(current_file)) + cmd = [ + 'ffmpeg', '-v', _log.ff_level.lower(), '-hide_banner', + '-nostats' + ] + src_cmd + [ + '-metadata', 'service_name=' + _playout.name, + '-metadata', 'service_provider=' + _playout.provider, + '-metadata', 'year={}'.format(year) + ] + _playout.ffmpeg_param + _playout.hls_output + + _ff.encoder = Popen(cmd, stdin=PIPE, stderr=PIPE) + + stderr_reader_thread = Thread(target=ffmpeg_stderr_reader, + args=(_ff.encoder.stderr, False)) + stderr_reader_thread.daemon = True + stderr_reader_thread.start() + stderr_reader_thread.join() + + ts_cleaning_thread = Thread(target=clean_ts) + ts_cleaning_thread.daemon = True + ts_cleaning_thread.start() + + except BrokenPipeError: + messenger.error('Broken Pipe!') + terminate_processes(watcher) + + except SystemExit: + messenger.info('Got close command') + terminate_processes(watcher) + + except KeyboardInterrupt: + messenger.warning('Program terminated') + terminate_processes(watcher) + + # close encoder when nothing is to do anymore + if _ff.encoder.poll() is None: + _ff.encoder.terminate() + + finally: + if _ff.encoder.poll() is None: + _ff.encoder.terminate() + _ff.encoder.wait() diff --git a/ffplayout/output/stream.py b/ffplayout/output/stream.py new file mode 100644 index 00000000..551bcb03 --- /dev/null +++ b/ffplayout/output/stream.py @@ -0,0 +1,112 @@ +import os +from subprocess import PIPE, Popen +from threading import Thread + +from ffplayout.folder import GetSourceFromFolder, MediaStore, MediaWatcher +from ffplayout.playlist import GetSourceFromPlaylist +from ffplayout.utils import (_ff, _log, _playlist, _playout, _pre, _text, + ffmpeg_stderr_reader, get_date, messenger, + pre_audio_codec, stdin_args, terminate_processes) + +_WINDOWS = os.name == 'nt' +COPY_BUFSIZE = 1024 * 1024 if _WINDOWS else 65424 + + +def output(): + """ + this output is for streaming to a target address, + like rtmp, rtp, svt, etc. + """ + year = get_date(False).split('-')[0] + overlay = [] + + ff_pre_settings = [ + '-pix_fmt', 'yuv420p', '-r', str(_pre.fps), + '-c:v', 'mpeg2video', '-intra', + '-b:v', '{}k'.format(_pre.v_bitrate), + '-minrate', '{}k'.format(_pre.v_bitrate), + '-maxrate', '{}k'.format(_pre.v_bitrate), + '-bufsize', '{}k'.format(_pre.v_bufsize) + ] + pre_audio_codec() + ['-f', 'mpegts', '-'] + + if _text.add_text and not _text.over_pre: + messenger.info('Using drawtext node, listening on address: {}'.format( + _text.address + )) + overlay = [ + '-vf', + "null,zmq=b=tcp\\\\://'{}',drawtext=text='':fontfile='{}'".format( + _text.address.replace(':', '\\:'), _text.fontfile) + ] + + try: + _ff.encoder = Popen([ + 'ffmpeg', '-v', _log.ff_level.lower(), '-hide_banner', + '-nostats', '-re', '-thread_queue_size', '256', '-i', 'pipe:0' + ] + overlay + [ + '-metadata', 'service_name=' + _playout.name, + '-metadata', 'service_provider=' + _playout.provider, + '-metadata', 'year={}'.format(year) + ] + _playout.ffmpeg_param + _playout.stream_output, + stdin=PIPE, stderr=PIPE) + + enc_err_thread = Thread(target=ffmpeg_stderr_reader, + args=(_ff.encoder.stderr, False)) + enc_err_thread.daemon = True + enc_err_thread.start() + + if _playlist.mode and not stdin_args.folder: + watcher = None + get_source = GetSourceFromPlaylist() + else: + messenger.info('Start folder mode') + media = MediaStore() + watcher = MediaWatcher(media) + get_source = GetSourceFromFolder(media) + + try: + for src_cmd in get_source.next(): + messenger.debug('src_cmd: "{}"'.format(src_cmd)) + if src_cmd[0] == '-i': + current_file = src_cmd[1] + else: + current_file = src_cmd[3] + + messenger.info('Play: "{}"'.format(current_file)) + + with Popen([ + 'ffmpeg', '-v', _log.ff_level.lower(), '-hide_banner', + '-nostats'] + src_cmd + ff_pre_settings, + stdout=PIPE, stderr=PIPE) as _ff.decoder: + + dec_err_thread = Thread(target=ffmpeg_stderr_reader, + args=(_ff.decoder.stderr, True)) + dec_err_thread.daemon = True + dec_err_thread.start() + + while True: + buf = _ff.decoder.stdout.read(COPY_BUFSIZE) + if not buf: + break + _ff.encoder.stdin.write(buf) + + except BrokenPipeError: + messenger.error('Broken Pipe!') + terminate_processes(watcher) + + except SystemExit: + messenger.info('Got close command') + terminate_processes(watcher) + + except KeyboardInterrupt: + messenger.warning('Program terminated') + terminate_processes(watcher) + + # close encoder when nothing is to do anymore + if _ff.encoder.poll() is None: + _ff.encoder.terminate() + + finally: + if _ff.encoder.poll() is None: + _ff.encoder.terminate() + _ff.encoder.wait() diff --git a/ffplayout/playlist.py b/ffplayout/playlist.py index cec5dca2..f666ace4 100644 --- a/ffplayout/playlist.py +++ b/ffplayout/playlist.py @@ -224,7 +224,7 @@ class GetSourceFromPlaylist: if self.clip_nodes is None: self.eof_handling( - 'No valid playlist:\n{}'.format(self.json_file), True, 300) + 'No valid playlist:\n{}'.format(self.json_file), True, 30) yield self.src_cmd + self.filtergraph continue diff --git a/ffplayout/utils.py b/ffplayout/utils.py index b737b9a0..5064cbec 100644 --- a/ffplayout/utils.py +++ b/ffplayout/utils.py @@ -113,7 +113,7 @@ def get_time(time_format): _general = SimpleNamespace() _mail = SimpleNamespace() _log = SimpleNamespace() -_pre_comp = SimpleNamespace() +_pre = SimpleNamespace() _playlist = SimpleNamespace() _storage = SimpleNamespace() _text = SimpleNamespace() @@ -121,6 +121,7 @@ _playout = SimpleNamespace() _init = SimpleNamespace(load=True) _ff = SimpleNamespace(decoder=None, encoder=None) +_global = SimpleNamespace(time_delta=0) def str_to_sec(s): @@ -178,15 +179,16 @@ def load_config(): _mail.recip = cfg['mail']['recipient'] _mail.level = cfg['mail']['mail_level'] - _pre_comp.add_logo = cfg['pre_compress']['add_logo'] - _pre_comp.logo = cfg['pre_compress']['logo'] - _pre_comp.logo_scale = cfg['pre_compress']['logo_scale'] - _pre_comp.logo_filter = cfg['pre_compress']['logo_filter'] - _pre_comp.logo_opacity = cfg['pre_compress']['logo_opacity'] - _pre_comp.add_loudnorm = cfg['pre_compress']['add_loudnorm'] - _pre_comp.loud_i = cfg['pre_compress']['loud_I'] - _pre_comp.loud_tp = cfg['pre_compress']['loud_TP'] - _pre_comp.loud_lra = cfg['pre_compress']['loud_LRA'] + _pre.add_logo = cfg['processing']['add_logo'] + _pre.logo = cfg['processing']['logo'] + _pre.logo_scale = cfg['processing']['logo_scale'] + _pre.logo_filter = cfg['processing']['logo_filter'] + _pre.logo_opacity = cfg['processing']['logo_opacity'] + _pre.add_loudnorm = cfg['processing']['add_loudnorm'] + _pre.loud_i = cfg['processing']['loud_I'] + _pre.loud_tp = cfg['processing']['loud_TP'] + _pre.loud_lra = cfg['processing']['loud_LRA'] + _pre.output_count = cfg['processing']['output_count'] _playlist.mode = cfg['playlist']['playlist_mode'] _playlist.path = cfg['playlist']['path'] @@ -199,6 +201,7 @@ def load_config(): _storage.shuffle = cfg['storage']['shuffle'] _text.add_text = cfg['text']['add_text'] + _text.over_pre = cfg['text']['over_pre'] _text.address = cfg['text']['bind_address'] _text.fontfile = cfg['text']['fontfile'] @@ -209,18 +212,20 @@ def load_config(): _log.level = cfg['logging']['log_level'] _log.ff_level = cfg['logging']['ffmpeg_level'] - _pre_comp.w = cfg['pre_compress']['width'] - _pre_comp.h = cfg['pre_compress']['height'] - _pre_comp.aspect = cfg['pre_compress']['aspect'] - _pre_comp.fps = cfg['pre_compress']['fps'] - _pre_comp.v_bitrate = cfg['pre_compress']['width'] * 50 - _pre_comp.v_bufsize = cfg['pre_compress']['width'] * 50 / 2 + _pre.w = cfg['processing']['width'] + _pre.h = cfg['processing']['height'] + _pre.aspect = cfg['processing']['aspect'] + _pre.fps = cfg['processing']['fps'] + _pre.v_bitrate = cfg['processing']['width'] * 50 + _pre.v_bufsize = cfg['processing']['width'] * 50 / 2 + _pre.realtime = cfg['processing']['use_realtime'] - _playout.preview = cfg['out']['preview'] + _playout.mode = cfg['out']['mode'] _playout.name = cfg['out']['service_name'] _playout.provider = cfg['out']['service_provider'] - _playout.post_comp_param = cfg['out']['post_ffmpeg_param'].split(' ') - _playout.out_addr = cfg['out']['out_addr'] + _playout.ffmpeg_param = cfg['out']['ffmpeg_param'].split(' ') + _playout.stream_output = cfg['out']['stream_output'].split(' ') + _playout.hls_output = cfg['out']['hls_output'].split(' ') _init.load = False @@ -672,6 +677,11 @@ def check_sync(delta): """ check that we are in tolerance time """ + + if _playlist.mode and _playlist.start and _playlist.length: + # save time delta to global variable for syncing + _global.time_delta = delta + if _general.stop and abs(delta) > _general.threshold: messenger.error( 'Sync tolerance value exceeded with {0:.2f} seconds,\n' @@ -782,7 +792,7 @@ def gen_dummy(duration): return [ '-f', 'lavfi', '-i', 'color=c={}:s={}x{}:d={}:r={},format=pix_fmts=yuv420p'.format( - color, _pre_comp.w, _pre_comp.h, duration, _pre_comp.fps + color, _pre.w, _pre.h, duration, _pre.fps ), '-f', 'lavfi', '-i', 'anoisesrc=d={}:c=pink:r=48000:a=0.05'.format( duration) @@ -989,7 +999,7 @@ def pre_audio_codec(): s302m has higher quality, but is experimental and works not well together with the loudnorm filter """ - if _pre_comp.add_loudnorm: + if _pre.add_loudnorm: acodec = 'libtwolame' if 'libtwolame' in FF_LIBS['libs'] else 'mp2' return ['-c:a', acodec, '-b:a', '384k', '-ar', '48000', '-ac', '2'] else: diff --git a/requirements.txt b/requirements.txt index 2448a3b8..26dda6cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ colorama==0.4.3 pathtools==0.1.2 -PyYAML==5.3 -watchdog==0.10.1 +PyYAML==5.3.1 +watchdog==0.10.3