From fbfb9a771229fdf209b5b5c232b93134fec10407 Mon Sep 17 00:00:00 2001
From: jb-alvarado <jb@pixelcrusher.de>
Date: Sun, 7 Jun 2020 20:08:54 +0200
Subject: [PATCH] modularize output

---
 ffplayout.py                 | 117 +++--------------------------------
 ffplayout.yml                |  27 ++++----
 ffplayout/filters.py         | 107 ++++++++++++++++++++------------
 ffplayout/output/__init__.py |   0
 ffplayout/output/desktop.py  | 104 +++++++++++++++++++++++++++++++
 ffplayout/output/hls.py      |  72 +++++++++++++++++++++
 ffplayout/output/stream.py   | 111 +++++++++++++++++++++++++++++++++
 ffplayout/playlist.py        |   2 +-
 ffplayout/utils.py           |  43 ++++++-------
 9 files changed, 404 insertions(+), 179 deletions(-)
 create mode 100644 ffplayout/output/__init__.py
 create mode 100644 ffplayout/output/desktop.py
 create mode 100644 ffplayout/output/hls.py
 create mode 100644 ffplayout/output/stream.py

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..aaf5f0cf 100644
--- a/ffplayout.yml
+++ b/ffplayout.yml
@@ -31,16 +31,18 @@ logging:
     log_level: "DEBUG"
     ffmpeg_level: "ERROR"
 
-pre_compress:
-    helptext: Settings for the pre-compression. All clips get prepared in that way,
+pre_process:
+    helptext: Settings for the pre_process. 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!]
+        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.
     width: 1024
     height: 576
     aspect: 1.778
@@ -54,6 +56,7 @@ pre_compress:
     loud_I: -18
     loud_TP: -1.5
     loud_LRA: 11
+    output_count: 1
 
 playlist:
     helptext: Set 'playlist_mode' to 'False' if you want to play clips from the [STORAGE]
@@ -84,18 +87,21 @@ 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.
+    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
@@ -108,5 +114,4 @@ out:
         -ar 44100
         -b:a 128k
         -flags +global_header
-        -f flv
-    out_addr: "rtmp://localhost/live/stream"
+        -f flv "rtmp://localhost/live/stream"
diff --git a/ffplayout/filters.py b/ffplayout/filters.py
index 3a93a97a..1d057f90 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 _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,45 @@ 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 split_filter(filter_type):
+    map_node = []
+    filter_prefix = ''
+
+    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 +261,16 @@ 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_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_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_split = split_filter('a')
+    audio_map = ['-map', '[aout1]']
+    audio_filter = [
+        '-filter_complex', '{}{}'.format(','.join(audio_chain), 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..7efe4eba
--- /dev/null
+++ b/ffplayout/output/hls.py
@@ -0,0 +1,72 @@
+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 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', '-re', '-thread_queue_size', '256'
+                    ] + src_cmd + [
+                        '-metadata', 'service_name=' + _playout.name,
+                        '-metadata', 'service_provider=' + _playout.provider,
+                        '-metadata', 'year={}'.format(year)
+                    ] + _playout.ffmpeg_param
+
+                _ff.encoder = Popen(cmd, stdin=PIPE, stderr=PIPE)
+
+                enc_thread = Thread(target=ffmpeg_stderr_reader,
+                                    args=(_ff.encoder.stderr, True))
+                enc_thread.daemon = True
+                enc_thread.start()
+                enc_thread.join()
+
+        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..200aa1dc
--- /dev/null
+++ b/ffplayout/output/stream.py
@@ -0,0 +1,111 @@
+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, 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..143bedcc 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()
@@ -178,15 +178,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['pre_process']['add_logo']
+    _pre.logo = cfg['pre_process']['logo']
+    _pre.logo_scale = cfg['pre_process']['logo_scale']
+    _pre.logo_filter = cfg['pre_process']['logo_filter']
+    _pre.logo_opacity = cfg['pre_process']['logo_opacity']
+    _pre.add_loudnorm = cfg['pre_process']['add_loudnorm']
+    _pre.loud_i = cfg['pre_process']['loud_I']
+    _pre.loud_tp = cfg['pre_process']['loud_TP']
+    _pre.loud_lra = cfg['pre_process']['loud_LRA']
+    _pre.output_count = cfg['pre_process']['output_count']
 
     _playlist.mode = cfg['playlist']['playlist_mode']
     _playlist.path = cfg['playlist']['path']
@@ -199,6 +200,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 +211,17 @@ 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['pre_process']['width']
+        _pre.h = cfg['pre_process']['height']
+        _pre.aspect = cfg['pre_process']['aspect']
+        _pre.fps = cfg['pre_process']['fps']
+        _pre.v_bitrate = cfg['pre_process']['width'] * 50
+        _pre.v_bufsize = cfg['pre_process']['width'] * 50 / 2
 
-        _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(' ')
 
         _init.load = False
 
@@ -782,7 +783,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 +990,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: