Merge branch 'dev'
This commit is contained in:
commit
6283a8729d
1
.gitignore
vendored
1
.gitignore
vendored
@ -6,6 +6,7 @@
|
||||
__pycache__/
|
||||
*-orig.*
|
||||
*.json
|
||||
test/
|
||||
tests/
|
||||
.pytest_cache/
|
||||
venv/
|
||||
|
26
README.md
26
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
|
||||
@ -36,10 +37,15 @@ Features
|
||||
- **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:`.
|
||||
|
117
ffplayout.py
117
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__':
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
a_speed = realtime_filter(out - seek, 'a')
|
||||
a_split = split_filter('a')
|
||||
audio_map = ['-map', '[aout1]']
|
||||
audio_filter = [
|
||||
'-filter_complex', '{}[a]'.format(','.join(audio_chain))]
|
||||
audio_map = ['-map', '[a]']
|
||||
else:
|
||||
audio_filter = []
|
||||
audio_map = ['-map', '0:a']
|
||||
'-filter_complex', '{}{}{}'.format(','.join(audio_chain),
|
||||
a_speed, a_split)]
|
||||
|
||||
if probe.video[0]:
|
||||
return video_filter + audio_filter + video_map + audio_map
|
||||
|
0
ffplayout/output/__init__.py
Normal file
0
ffplayout/output/__init__.py
Normal file
104
ffplayout/output/desktop.py
Normal file
104
ffplayout/output/desktop.py
Normal file
@ -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()
|
105
ffplayout/output/hls.py
Normal file
105
ffplayout/output/hls.py
Normal file
@ -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()
|
112
ffplayout/output/stream.py
Normal file
112
ffplayout/output/stream.py
Normal file
@ -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()
|
@ -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
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user