diff --git a/biz/ffmpeg.py b/biz/ffmpeg.py index 941b0c9..e3d61b9 100644 --- a/biz/ffmpeg.py +++ b/biz/ffmpeg.py @@ -21,6 +21,7 @@ def parse_ffmpeg_task(task_info, template_info): logger.warning("no video found for part: " + str(part)) continue sub_ffmpeg_task = FfmpegTask(source) + sub_ffmpeg_task.annexb = True sub_ffmpeg_task.frame_rate = template_info.get("frame_rate", 25) for lut in part.get('filters', []): sub_ffmpeg_task.add_lut(os.path.join(template_info.get("local_path"), lut)) @@ -75,9 +76,19 @@ def clear_task_tmp_file(ffmpeg_task): for task in ffmpeg_task.analyze_input_render_tasks(): clear_task_tmp_file(task) try: - os.remove(ffmpeg_task.get_output_file()) - logger.info("delete tmp file: " + ffmpeg_task.get_output_file()) + if "template" not in ffmpeg_task.get_output_file(): + os.remove(ffmpeg_task.get_output_file()) + logger.info("delete tmp file: " + ffmpeg_task.get_output_file()) + else: + logger.info("skip delete template file: " + ffmpeg_task.get_output_file()) except OSError: logger.warning("delete tmp file failed: " + ffmpeg_task.get_output_file()) return False - return True \ No newline at end of file + return True + + +def probe_video_info(ffmpeg_task): + # 获取视频长度宽度和时长 + return ffmpeg.probe_video_info(ffmpeg_task.get_output_file()) + + diff --git a/biz/task.py b/biz/task.py index c9e08e6..2fd879a 100644 --- a/biz/task.py +++ b/biz/task.py @@ -3,7 +3,7 @@ from util import api def start_task(task_info): - from biz.ffmpeg import parse_ffmpeg_task, start_ffmpeg_task, clear_task_tmp_file + from biz.ffmpeg import parse_ffmpeg_task, start_ffmpeg_task, clear_task_tmp_file, probe_video_info task_info = api.normalize_task(task_info) template_info = get_template_def(task_info.get("templateId")) api.report_task_start(task_info) @@ -14,5 +14,11 @@ def start_task(task_info): oss_result = api.upload_task_file(task_info, ffmpeg_task) if not oss_result: return api.report_task_failed(task_info) + # 获取视频长度宽度和时长 + width, height, duration = probe_video_info(ffmpeg_task) clear_task_tmp_file(ffmpeg_task) - api.report_task_success(task_info) \ No newline at end of file + api.report_task_success(task_info, videoInfo={ + "width": width, + "height": height, + "duration": duration + }) \ No newline at end of file diff --git a/entity/ffmpeg.py b/entity/ffmpeg.py index a728a2e..18f28d0 100644 --- a/entity/ffmpeg.py +++ b/entity/ffmpeg.py @@ -1,10 +1,14 @@ +import time import uuid class FfmpegTask(object): def __init__(self, input_file, task_type='copy', output_file=''): + self.annexb = False if type(input_file) is str: + if input_file.endswith(".ts"): + self.annexb = True self.input_file = [input_file] elif type(input_file) is list: self.input_file = input_file @@ -19,7 +23,6 @@ class FfmpegTask(object): self.luts = [] self.audios = [] self.overlays = [] - self.annexb = False def __repr__(self): _str = f'FfmpegTask(input_file={self.input_file}, task_type={self.task_type}' @@ -125,7 +128,10 @@ class FfmpegTask(object): if self.task_type == 'encode': input_args = [] filter_args = [] - output_args = ["-shortest", "-c:v h264_qsv"] + output_args = ["-shortest", "-c:v", "h264_qsv"] + if self.annexb: + output_args.append("-bsf:v") + output_args.append("h264_mp4toannexb") video_output_str = "[0:v]" audio_output_str = "[0:v]" video_input_count = 0 @@ -172,11 +178,43 @@ class FfmpegTask(object): output_args.append(audio_output_str) return args + input_args + ["-filter_complex", ";".join(filter_args)] + output_args + [self.get_output_file()] elif self.task_type == 'concat': + # 无法通过 annexb 合并的 input_args = [] - output_args = ["-shortest", "-c:v", "h264_qsv", "-r", "25"] + output_args = ["-shortest"] + if self.check_annexb() and len(self.audios) <= 1: + # output_args + if len(self.audios) > 0: + input_args.append("-an") + _tmp_file = "tmp_concat_"+str(time.time())+".txt" + with open(_tmp_file, "w", encoding="utf-8") as f: + for input_file in self.input_file: + if type(input_file) is str: + f.write("file '"+input_file+"'\n") + elif isinstance(input_file, FfmpegTask): + f.write("file '" + input_file.get_output_file() + "'\n") + input_args.append("-f") + input_args.append("concat") + input_args.append("-safe") + input_args.append("0") + input_args.append("-i") + input_args.append(_tmp_file) + output_args.append("-c:v") + output_args.append("copy") + if len(self.audios) > 0: + input_args.append("-i") + input_args.append(self.audios[0]) + output_args.append("-c:a") + output_args.append("copy") + output_args.append("-f") + output_args.append("mp4") + return args + input_args + output_args + [self.get_output_file()] + output_args.append("-c:v") + output_args.append("h264_qsv") + output_args.append("-r") + output_args.append("25") filter_args = [] video_output_str = "[0:v]" - audio_output_str = "[0:v]" + audio_output_str = "[0:a]" video_input_count = 0 audio_input_count = 0 for input_file in self.input_file: @@ -217,12 +255,31 @@ class FfmpegTask(object): if len(self.input_file) == 1: if type(self.input_file[0]) is str: if self.input_file[0] == self.get_output_file(): - return None + return [] return args + ["-i", self.input_file[0]] + ["-c", "copy", self.get_output_file()] def set_output_file(self, file=None): if file is None: if self.output_file == '': - self.output_file = "rand_" + str(uuid.uuid4()) + ".mp4" + if self.annexb: + self.output_file = "rand_" + str(uuid.uuid4()) + ".ts" + else: + self.output_file = "rand_" + str(uuid.uuid4()) + ".mp4" else: - self.output_file = file \ No newline at end of file + self.output_file = file + + def check_annexb(self): + for input_file in self.input_file: + if type(input_file) is str: + if self.task_type == 'encode': + return self.annexb + elif self.task_type == 'concat': + return False + elif self.task_type == 'copy': + return self.annexb + else: + return False + elif isinstance(input_file, FfmpegTask): + if not input_file.check_annexb(): + return False + return True \ No newline at end of file diff --git a/template/__init__.py b/template/__init__.py index ed1f572..c96f376 100644 --- a/template/__init__.py +++ b/template/__init__.py @@ -86,7 +86,10 @@ def download_template(template_id): if str(_template['source']).startswith("http"): _, _fn = os.path.split(_template['source']) oss.download_from_oss(_template['source'], os.path.join(template_info['local_path'], _fn)) - _template['source'] = _fn + if _fn.endswith(".mp4"): + from util.ffmpeg import to_annexb + _fn = to_annexb(os.path.join(template_info['local_path'], _fn)) + _template['source'] = os.path.relpath(_fn, template_info['local_path']) if 'overlays' in _template: for i in range(len(_template['overlays'])): overlay = _template['overlays'][i] diff --git a/util/api.py b/util/api.py index 1380c29..f308b0e 100644 --- a/util/api.py +++ b/util/api.py @@ -104,10 +104,11 @@ def get_template_info(template_id): return template -def report_task_success(task_info): +def report_task_success(task_info, **kwargs): try: response = session.post('{0}/{1}/success'.format(os.getenv('API_ENDPOINT'), task_info.get("id")), json={ 'accessKey': os.getenv('ACCESS_KEY'), + **kwargs }, timeout=10) response.raise_for_status() except requests.RequestException as e: diff --git a/util/ffmpeg.py b/util/ffmpeg.py index 28a4ac8..bd1e8d6 100644 --- a/util/ffmpeg.py +++ b/util/ffmpeg.py @@ -1,27 +1,48 @@ +import logging import os +import subprocess from datetime import datetime from typing import Optional, IO from entity.ffmpeg import FfmpegTask +logger = logging.getLogger(__name__) + +def to_annexb(file): + if not os.path.exists(file): + return file + logger.info("ToAnnexb: %s", file) + ffmpeg_process = subprocess.run(["ffmpeg.exe", "-y", "-hide_banner", "-i", file, "-c", "copy", "-bsf:v", "h264_mp4toannexb", + "-f", "mpegts", file+".ts"]) + logger.info("ToAnnexb: %s, returned: %s", file, ffmpeg_process.returncode) + if ffmpeg_process.returncode == 0: + os.remove(file) + return file+".ts" + else: + return file def start_render(ffmpeg_task: FfmpegTask): - print(ffmpeg_task) - print(ffmpeg_task.get_ffmpeg_args()) + logger.info(ffmpeg_task) + logger.info(ffmpeg_task.get_ffmpeg_args()) if not ffmpeg_task.need_run(): ffmpeg_task.set_output_file(ffmpeg_task.input_file[0]) return True - code = os.system("ffmpeg.exe "+" ".join(ffmpeg_task.get_ffmpeg_args())) + ffmpeg_args = ffmpeg_task.get_ffmpeg_args() + if len(ffmpeg_args) == 0: + ffmpeg_task.set_output_file(ffmpeg_task.input_file[0]) + return True + ffmpeg_process = subprocess.run(["ffmpeg.exe", "-progress", "-", "-loglevel", "error", *ffmpeg_args], **subprocess_args(True)) + logger.info("FINISH TASK, OUTPUT IS %s", handle_ffmpeg_output(ffmpeg_process.stdout)) + code = ffmpeg_process.returncode return code == 0 -def handle_ffmpeg_output(stdout: Optional[IO[bytes]]) -> str: +def handle_ffmpeg_output(stdout: Optional[bytes]) -> str: out_time = "0:0:0.0" if stdout is None: print("[!]STDOUT is null") return out_time speed = "0" - while True: - line = stdout.readline() + for line in stdout.split(b"\n"): if line == b"": break if line.strip() == b"progress=end": @@ -32,8 +53,75 @@ def handle_ffmpeg_output(stdout: Optional[IO[bytes]]) -> str: if line.startswith(b"speed="): speed = line.replace(b"speed=", b"").decode().strip() print("[ ]Speed:", out_time, "@", speed) - return out_time + return out_time+"@"+speed + def duration_str_to_float(duration_str: str) -> float: _duration = datetime.strptime(duration_str, "%H:%M:%S.%f") - datetime(1900, 1, 1) return _duration.total_seconds() + + +def probe_video_info(video_file): + # 获取宽度和高度 + result = subprocess.run( + ["ffprobe.exe", '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height:format=duration', '-of', + 'csv=s=x:p=0', video_file], + stderr=subprocess.STDOUT, + **subprocess_args(True) + ) + all_result = result.stdout.decode('utf-8').strip() + wh, duration = all_result.split('\n') + width, height = wh.strip().split('x') + + return int(width), int(height), float(duration) + + +# Create a set of arguments which make a ``subprocess.Popen`` (and +# variants) call work with or without Pyinstaller, ``--noconsole`` or +# not, on Windows and Linux. Typical use:: +# +# subprocess.call(['program_to_run', 'arg_1'], **subprocess_args()) +# +# When calling ``check_output``:: +# +# subprocess.check_output(['program_to_run', 'arg_1'], +# **subprocess_args(False)) +def subprocess_args(include_stdout=True): + # The following is true only on Windows. + if hasattr(subprocess, 'STARTUPINFO'): + # On Windows, subprocess calls will pop up a command window by default + # when run from Pyinstaller with the ``--noconsole`` option. Avoid this + # distraction. + si = subprocess.STARTUPINFO() + si.dwFlags |= subprocess.STARTF_USESHOWWINDOW + # Windows doesn't search the path by default. Pass it an environment so + # it will. + env = os.environ + else: + si = None + env = None + + # ``subprocess.check_output`` doesn't allow specifying ``stdout``:: + # + # Traceback (most recent call last): + # File "test_subprocess.py", line 58, in + # **subprocess_args(stdout=None)) + # File "C:\Python27\lib\subprocess.py", line 567, in check_output + # raise ValueError('stdout argument not allowed, it will be overridden.') + # ValueError: stdout argument not allowed, it will be overridden. + # + # So, add it only if it's needed. + if include_stdout: + ret = {'stdout': subprocess.PIPE} + else: + ret = {} + + # On Windows, running this from the binary produced by Pyinstaller + # with the ``--noconsole`` option requires redirecting everything + # (stdin, stdout, stderr) to avoid an OSError exception + # "[Error 6] the handle is invalid." + ret.update({'stdin': subprocess.PIPE, + 'startupinfo': si, + 'env': env}) + return ret + diff --git a/util/oss.py b/util/oss.py index c003c8e..140dba1 100644 --- a/util/oss.py +++ b/util/oss.py @@ -1,7 +1,10 @@ +import logging import os import requests +logger = logging.getLogger(__name__) + def upload_to_oss(url, file_path): """ @@ -25,6 +28,7 @@ def download_from_oss(url, file_path): :param Union[LiteralString, str, bytes] file_path: 文件路径 :return bool: 是否成功 """ + logging.info("download_from_oss: %s", url) file_dir, file_name = os.path.split(file_path) if file_dir: if not os.path.exists(file_dir):