├── Dockerfile ├── README.md ├── default_config.json └── live_transcoder.py /Dockerfile: -------------------------------------------------------------------------------- 1 | # ffmpeg live transcoder 2 | # 3 | # VERSION 2.4.3 4 | # 5 | # From https://trac.ffmpeg.org/wiki/CompilationGuide/Centos 6 | # 7 | FROM jrottenberg/ffmpeg:2.4.3 8 | MAINTAINER Dragos Dascalita Haut 9 | 10 | RUN yum install -y python-configobj python-urllib2 python-argparse 11 | COPY live_transcoder.py /usr/local/live-transcoder/ 12 | COPY default_config.json /etc/live-transcoder/ 13 | RUN mkdir -p /var/log/streamkit/ 14 | 15 | # forward request and error logs to docker log collector 16 | RUN ln -sf /dev/stdout /var/log/streamkit/* 17 | 18 | VOLUME /var/log/streamkit/ 19 | 20 | ENTRYPOINT ["python", "/usr/local/live-transcoder/live_transcoder.py"] 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ffmpeg live-transcoder 2 | ====================== 3 | 4 | 5 | Docker image using ffmpeg to encode a live stream from a source and push it to another target endpoint. 6 | The image is based on: https://github.com/jrottenberg/ffmpeg. 7 | 8 | Status 9 | ====== 10 | 11 | Under development. 12 | 13 | Usage 14 | ===== 15 | 16 | The Docker image expects an argument `--user-config-json` to be passed to the `ENTRYPOINT` which is a python script. 17 | 18 | For an example of the `json` object you can check `default_config.json`. 19 | 20 | Usage: 21 | 22 | ``` 23 | docker run ddragosd/ffmpeg-live-transcoder:2.4.3-1.1 \ 24 | --user-config-json "`cat /usr/local/live-transcoder-config.json`" 25 | 26 | ``` 27 | 28 | To specify a log file: 29 | 30 | ``` 31 | docker run -v /var/log/streamkit:/var/log/streamkit ddragosd/ffmpeg-live-transcoder:2.4.3-1.1 \ 32 | --user-config-json "`cat /usr/local/live-transcoder-config.json`" \ 33 | --log-file "/var/log/streamkit/ffmpeg-live-transcoding.log" 34 | ``` 35 | 36 | 37 | SSH into the Docker container 38 | ============================= 39 | 40 | ``` 41 | docker run -ti --entrypoint='bash' ddragosd/ffmpeg-live-transcoder:2.4.3-1.1 42 | ``` 43 | 44 | -------------------------------------------------------------------------------- /default_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": "rtmp://path/to/source/stream", 3 | "target_host": "rtmp://path/to/target/stream", 4 | "target_app": "target_app", 5 | "target_stream": "demo_stream_$width_$height_$bitrate_kbps", 6 | "sd_streams": [ 7 | { 8 | "width": 1440, 9 | "height": 1080, 10 | "bitrate": 2200, 11 | "audio_codec": "copy", 12 | "video_codec": "libx264" 13 | }, 14 | { 15 | "width": 960, 16 | "height": 720, 17 | "bitrate": 1000, 18 | "audio_codec": "copy", 19 | "video_codec": "libx264" 20 | }, 21 | { 22 | "width": 640, 23 | "height": 480, 24 | "bitrate": 600, 25 | "audio_codec": "copy", 26 | "video_codec": "libx264" 27 | }, 28 | { 29 | "width": 480, 30 | "height": 360, 31 | "bitrate": 300, 32 | "audio_codec": "libfdk_aac", 33 | "audio_bitrate": 96, 34 | "video_codec": "libx264" 35 | }, 36 | { 37 | "width": 320, 38 | "height": 240, 39 | "bitrate": 150, 40 | "audio_codec": "libfdk_aac", 41 | "audio_bitrate": 96, 42 | "video_codec": "libx264" 43 | }, 44 | { 45 | "width": 214, 46 | "height": 160, 47 | "bitrate": 64, 48 | "audio_codec": "libfdk_aac", 49 | "audio_bitrate": 48, 50 | "video_codec": "libx264" 51 | } 52 | ], 53 | "hd_streams": [ 54 | { 55 | "width": 1920, 56 | "height": 1080, 57 | "bitrate": 2200, 58 | "audio_codec": "copy", 59 | "video_codec": "libx264" 60 | }, 61 | { 62 | "width": 1280, 63 | "height": 720, 64 | "bitrate": 1200, 65 | "audio_codec": "copy", 66 | "video_codec": "libx264" 67 | }, 68 | { 69 | "width": 854, 70 | "height": 480, 71 | "bitrate": 600, 72 | "audio_codec": "copy", 73 | "video_codec": "libx264" 74 | }, 75 | { 76 | "width": 640, 77 | "height": 360, 78 | "bitrate": 300, 79 | "audio_codec": "libfdk_aac", 80 | "audio_bitrate": 96, 81 | "video_codec": "libx264" 82 | }, 83 | { 84 | "width": 426, 85 | "height": 240, 86 | "bitrate": 150, 87 | "audio_codec": "libfdk_aac", 88 | "audio_bitrate": 96, 89 | "video_codec": "libx264" 90 | }, 91 | { 92 | "width": 284, 93 | "height": 160, 94 | "bitrate": 10, 95 | "audio_codec": "libfdk_aac", 96 | "audio_bitrate": 48, 97 | "video_codec": "libx264" 98 | } 99 | ], 100 | "max_retries": 90, 101 | "max_retries_delay_sec": 10 102 | } -------------------------------------------------------------------------------- /live_transcoder.py: -------------------------------------------------------------------------------- 1 | __author__ = 'ddragosd' 2 | # coding=utf-8 3 | 4 | """ 5 | Controller for the FFmpeg process 6 | """ 7 | 8 | import subprocess 9 | import json 10 | from urllib2 import Request, urlopen, URLError 11 | import logging 12 | import re 13 | import time 14 | import datetime 15 | from configobj import ConfigObj 16 | import os 17 | import argparse 18 | 19 | 20 | class LiveTranscoder: 21 | def __init__(self, log_filename): 22 | # initialize config 23 | # Initialize Blank Configs 24 | self.config = ConfigObj() 25 | 26 | # Initialize Logger 27 | self.log = logging.getLogger("LiveTranscoder") 28 | self.log.setLevel(logging.DEBUG) 29 | log_format = logging.Formatter("%(asctime)s %(name)s [%(levelname)s]: %(message)s") 30 | 31 | # when executing the collector separately, log directly on the output stream 32 | stream_handler = logging.StreamHandler() 33 | stream_handler.setFormatter(log_format) 34 | self.log.addHandler(stream_handler) 35 | 36 | logging.getLogger('LiveTranscoder').addHandler(stream_handler) 37 | 38 | try: 39 | file_handler = logging.FileHandler(log_filename) 40 | file_handler.setFormatter(log_format) 41 | self.log.addHandler(file_handler) 42 | self.log.info("Log Handler added for file [%s]", log_filename) 43 | except Exception, e: 44 | self.log.warn("Could not create logfile [%s]") 45 | self.log.exception(e) 46 | pass 47 | 48 | 49 | 50 | def _get_default_config(self, file_name='/etc/live-transcoder/default_config.json'): 51 | config_file = open(file_name) 52 | cfg = json.load(config_file) 53 | config_file.close() 54 | 55 | return cfg 56 | 57 | def _get_user_config(self, user_config_json): 58 | if not user_config_json: 59 | return None 60 | try: 61 | self.log.info("Loading user-data: %s", user_config_json) 62 | config = json.loads(user_config_json) 63 | self.log.info("User-Data: %s", json.dumps(config)) 64 | return config 65 | except Exception, e: 66 | self.log.exception(e) 67 | return None 68 | 69 | 70 | def _updateStreamMetadataInConfig(self, config): 71 | """ 72 | Reads config.source metadata (width, height, bitrate, HD) and adds it back into the config object 73 | """ 74 | self.log.info("Reading metadata for %s", config.get("source")) 75 | cfg = config 76 | 77 | proc = subprocess.Popen(["ffmpeg", "-i", config.get("source")], stdout=subprocess.PIPE, stderr=subprocess.PIPE) 78 | 79 | stdoutdata, stderrdata = proc.communicate() 80 | ffmpeg_response = stderrdata 81 | 82 | self.log.debug("FFMPEG result %s", ffmpeg_response) 83 | # extract the bitrate either from the data exposed by ffmpeg, either from the first track that hax "XYZ kb/s" 84 | regex = re.compile("bitrate:\s(\d+)\s|\s(\d+)\skb\/s", re.IGNORECASE | re.MULTILINE | re.DOTALL) 85 | bitrate = regex.search(ffmpeg_response) 86 | if bitrate is not None: 87 | bitrate = bitrate.group(1) 88 | cfg["bitrate"] = bitrate 89 | self.log.info("Source bitrate: %s", bitrate) 90 | 91 | regex = re.compile("\s(\d+)x(\d+)[\s,]", re.IGNORECASE | re.MULTILINE | re.DOTALL) 92 | size = regex.search(ffmpeg_response) 93 | width = 1 94 | height = 1 95 | ratio = 0 96 | isHD = False 97 | if size is not None: 98 | width = int(size.group(1)) 99 | height = int(size.group(2)) 100 | ratio = round(width / float(height), 2) 101 | isHD = (ratio >= 1.60) 102 | self.log.info("Source size: width=%d, height=%d, ratio=%.4f, HD=%s", width, height, ratio, isHD) 103 | 104 | cfg["width"] = width 105 | cfg["height"] = height 106 | cfg["HD"] = isHD 107 | 108 | return cfg 109 | 110 | 111 | def _getTranscodingCmd(self, config): 112 | bitrates = None 113 | if config["HD"] == True: 114 | bitrates = config["hd_streams"] 115 | else: 116 | bitrates = config["sd_streams"] 117 | 118 | cmd = 'ffmpeg -i %s ' % (config["source"]) 119 | sub_commands = [] 120 | for quality in bitrates: 121 | if int(quality["bitrate"]) <= int(config["bitrate"]): 122 | sub_cmd_template = """-f flv -c:a copy -c:v %s -s %dx%d -x264opts bitrate=%d -rtmp_playpath %s -rtmp_app %s %s """ 123 | sub_cmd_template_audio = """-f flv -c:a %s -b:a %dk -c:v libx264 -s %dx%d -x264opts bitrate=%d -rtmp_playpath %s -rtmp_app %s %s """ 124 | target_stream = config["target_stream"] 125 | target_stream = target_stream.replace("$width", str(quality["width"])) 126 | target_stream = target_stream.replace("$height", str(quality["height"])) 127 | target_stream = target_stream.replace("$bitrate", str(quality["bitrate"])) 128 | sub_cmd = '' 129 | if "audio_bitrate" in quality and "audio_codec" in quality: 130 | sub_cmd = sub_cmd_template_audio % ( 131 | quality["audio_codec"], quality["audio_bitrate"], quality["width"], quality["height"], quality["bitrate"], 132 | target_stream, 133 | config["target_app"], config["target_host"] ) 134 | else: 135 | sub_cmd = sub_cmd_template % ( 136 | quality["video_codec"],quality["width"], quality["height"], quality["bitrate"], target_stream, 137 | config["target_app"], config["target_host"] ) 138 | cmd = cmd + sub_cmd 139 | return cmd 140 | 141 | def _runTranscodingCommand(self, command_with_args): 142 | try: 143 | s = subprocess.Popen(command_with_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) 144 | while True: 145 | line = s.stdout.readline() 146 | if not line: 147 | break 148 | self.log.info(line) 149 | self.log.info("FFmpeg process stopped normally.") 150 | return 1 151 | except Exception, e: 152 | # Note that FFMpeg will always exit with error code for live transcoding 153 | self.log.info("FFmpeg process stopped. Reason:") 154 | self.log.exception(e) 155 | return -1 156 | 157 | def startLiveTranscoding(self, user_config_json): 158 | # Load default 159 | self.config.merge(self._get_default_config()) 160 | # Merge with user data 161 | user_config = self._get_user_config(user_config_json) 162 | if user_config is not None: 163 | self.config.merge(user_config) 164 | self.log.info("Running live-transcoder with configuration: %s", self.config) 165 | 166 | max_retries = int(self.config["max_retries"]) 167 | max_retries_delay = int(self.config["max_retries_delay_sec"]) 168 | cmd_exit_code = None 169 | for i in range(1, max_retries + 1): 170 | self.config = self._updateStreamMetadataInConfig(self.config) 171 | if "bitrate" in self.config and "width" in self.config and "height" in self.config: 172 | cmd = self._getTranscodingCmd(self.config) 173 | self.log.info("Executing FFmpeg command:\n%s\n", cmd) 174 | 175 | # start live transcoding 176 | cmd_args = cmd.split() 177 | self.log.info("Running command. (run=%d/%d)", i, max_retries) 178 | cmd_exit_code = self._runTranscodingCommand(cmd_args) 179 | self.log.info("Transcoding command stopped. (run=%d/%d). Code=%d", i, max_retries, (cmd_exit_code or -777)) 180 | time.sleep(max_retries_delay) 181 | 182 | self.log.info("Live-Transcoder has completed ! You can now shutdown the instance.") 183 | 184 | 185 | 186 | user_config_json = None 187 | log_file = "/var/log/streamkit/live_transcoder_%s.log" % \ 188 | (datetime.datetime.fromtimestamp(time.time()).strftime('%Y-%m-%d_%H_%M_%S')) 189 | if __name__ == '__main__': 190 | parser = argparse.ArgumentParser() 191 | parser.add_argument('-u', '--user-config-json', dest='user_config_json') 192 | parser.add_argument('-l', '--log-file', dest='log_file') 193 | args = parser.parse_args() 194 | user_config_json = args.user_config_json 195 | log_file = args.log_file or log_file 196 | 197 | transcoder = LiveTranscoder(log_file) 198 | transcoder.startLiveTranscoding(user_config_json) --------------------------------------------------------------------------------