Compare commits

..

16 Commits

Author SHA1 Message Date
c630e123f7 弹幕支持透明度 2024-04-17 08:23:54 +08:00
51d4892b71 删除无用hwupload_cuda 2024-03-21 08:02:25 +08:00
1d28060e59 add ffmpeg 2024-03-19 21:04:41 +08:00
a0b37e015d 不检查视频总长度。因为没有意义了 2024-03-19 13:58:23 +08:00
1887bac9e6 not assure pip version 2024-03-19 13:54:44 +08:00
7d6ecb595a 结束逻辑 2023-01-31 09:30:26 +08:00
f648a84bbd handbrake直接切开合并 2023-01-29 22:03:59 +08:00
657c6bcc11 align-av 2023-01-29 16:00:18 +08:00
0b5eb6b1b8 直接使用HandBrake,不折腾ffmpeg了 2023-01-28 21:55:23 +08:00
87daef2955 避免CRF影响Bitrate设置 2022-12-08 12:22:51 +08:00
cf7b56d2e3 支持更高gop,重新编码音频 2022-12-05 14:36:19 +08:00
9395fec802 弹幕字体设置 2022-12-05 14:36:00 +08:00
29034f42f3 长度不足时,直接跳过切割 2022-09-14 10:57:36 +08:00
2afd877937 支持hevc配置切换,支持gop设置 2022-09-06 07:30:28 +08:00
4c4e3fa4e6 统一qsv加速,删除无用不统一的rc参数 2022-06-02 14:40:53 +08:00
bdc82f986c VIDEO_CRF 2022-06-01 09:15:15 +08:00
7 changed files with 744 additions and 117 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
/build/
/dist/
/venv/
__pycache__

View File

@ -3,26 +3,56 @@ import os.path
# [danmaku] # [danmaku]
# use_danmu2ass
DANMAKU_USE_DANMU2ASS = False
# use_danmakufactory
DANMAKU_USE_DANMAKUFACTORY = True
# exec # exec
DANMAKU_FACTORY_EXEC = "DanmakuFactory" DANMAKU_EXEC = "DanmakuFactory"
# opacity
DANMAKU_OPACITY = 40
# speed # speed
DANMAKU_SPEED = 12 DANMAKU_SPEED = 12
# font # font
DEFAULT_FONT_NAME = "Sarasa Term SC" DANMAKU_FONT_NAME = "Sarasa Term SC"
# font_size
DANMAKU_FONT_SIZE = 40
# resolution # resolution
VIDEO_RESOLUTION = "1280x720" VIDEO_RESOLUTION = "1280x720"
# [ffmpeg] # [ffmpeg]
# exec # exec
FFMPEG_EXEC = "ffmpeg" FFMPEG_EXEC = "ffmpeg"
# nvidia_gpu # nvidia_gpu
FFMPEG_USE_NVIDIA_GPU = True FFMPEG_USE_NVIDIA_GPU = False
# intel_gpu # intel_gpu
FFMPEG_USE_INTEL_GPU = True FFMPEG_USE_INTEL_GPU = False
# bitrate # vaapi
VIDEO_BITRATE = "2.5M" FFMPEG_USE_VAAPI = False
# [handbrake]
# exec
HANDBRAKE_EXEC = "HandBrakeCli"
# preset_file
HANDBRAKE_PRESET_FILE = "handbrake.json"
# preset
HANDBRAKE_PRESET = "NvEnc"
# encopt
HANDBRAKE_ENCOPT = ""
# [video] # [video]
# enabled
VIDEO_ENABLED = False
# title # title
VIDEO_TITLE = "【永恒de草薙直播录播】直播于 {}" VIDEO_TITLE = "【永恒de草薙直播录播】直播于 {}"
# desc
VIDEO_DESC = "弹幕来源:B站直播\r\n\r\n" + \
"原主播:永恒de草薙\r\n往期节目单查询:https://comment.sc.jerryyan.top\r\n\r\n" + \
"好多人这时候就开始问了,在哪直播呀,哎。对吧…咱来啦啊。在哔哩哔哩啊," \
"无论你是用网页百度搜索哔哩哔哩官网或者是用手机的APP下载一个哔哩哔哩," \
"啊,都能找到原主播。大概每天晚七点半左右吧…一般都是往左。然后的话呢搜索永恒de草薙就能找到他。" \
"那么今天的话呢今天的录播也就发完了…他是本期的主播永恒。咱明天同一时间不见不散…拜拜!!"
# tid
VIDEO_TID = 17
# tags
VIDEO_TAGS = "永恒de草薙,三国,三国战记,直播录像,录播,怀旧,街机"
# [clip] # [clip]
# each_sec # each_sec
VIDEO_CLIP_EACH_SEC = 6000 VIDEO_CLIP_EACH_SEC = 6000
@ -45,15 +75,24 @@ def load_config():
config.read("config.ini", encoding="utf-8") config.read("config.ini", encoding="utf-8")
if config.has_section("danmaku"): if config.has_section("danmaku"):
section = config['danmaku'] section = config['danmaku']
global DANMAKU_FACTORY_EXEC, DANMAKU_SPEED, DEFAULT_FONT_NAME, VIDEO_RESOLUTION global DANMAKU_EXEC, DANMAKU_SPEED, DANMAKU_FONT_NAME, VIDEO_RESOLUTION, DANMAKU_FONT_SIZE, \
DANMAKU_FACTORY_EXEC = section.get('exec', DANMAKU_FACTORY_EXEC) DANMAKU_USE_DANMU2ASS, DANMAKU_USE_DANMAKUFACTORY, DANMAKU_OPACITY
DANMAKU_USE_DANMU2ASS = section.getboolean('use_danmu2ass', DANMAKU_USE_DANMU2ASS)
DANMAKU_USE_DANMAKUFACTORY = section.getboolean('use_danmakufactory', DANMAKU_USE_DANMAKUFACTORY)
DANMAKU_EXEC = section.get('exec', DANMAKU_EXEC)
DANMAKU_SPEED = section.getfloat('speed', DANMAKU_SPEED) DANMAKU_SPEED = section.getfloat('speed', DANMAKU_SPEED)
DEFAULT_FONT_NAME = section.get('font', DEFAULT_FONT_NAME) DANMAKU_FONT_NAME = section.get('font', DANMAKU_FONT_NAME)
DANMAKU_FONT_SIZE = section.getint('font_size', DANMAKU_FONT_SIZE)
DANMAKU_OPACITY = section.getint('opacity', DANMAKU_OPACITY)
VIDEO_RESOLUTION = section.get('resolution', VIDEO_RESOLUTION) VIDEO_RESOLUTION = section.get('resolution', VIDEO_RESOLUTION)
if config.has_section("video"): if config.has_section("video"):
section = config['video'] section = config['video']
global VIDEO_TITLE global VIDEO_ENABLED, VIDEO_TITLE, VIDEO_DESC, VIDEO_TID, VIDEO_TAGS
VIDEO_ENABLED = section.getboolean('enabled', VIDEO_ENABLED)
VIDEO_TITLE = section.get('title', VIDEO_TITLE) VIDEO_TITLE = section.get('title', VIDEO_TITLE)
VIDEO_DESC = section.get('desc', VIDEO_DESC)
VIDEO_TID = section.getint('tid', VIDEO_TID)
VIDEO_TAGS = section.get('tags', VIDEO_TAGS)
if config.has_section("clip"): if config.has_section("clip"):
section = config['clip'] section = config['clip']
global VIDEO_CLIP_EACH_SEC, VIDEO_CLIP_OVERFLOW_SEC global VIDEO_CLIP_EACH_SEC, VIDEO_CLIP_OVERFLOW_SEC
@ -61,11 +100,18 @@ def load_config():
VIDEO_CLIP_OVERFLOW_SEC = section.getfloat('overflow_sec', VIDEO_CLIP_OVERFLOW_SEC) VIDEO_CLIP_OVERFLOW_SEC = section.getfloat('overflow_sec', VIDEO_CLIP_OVERFLOW_SEC)
if config.has_section("ffmpeg"): if config.has_section("ffmpeg"):
section = config['ffmpeg'] section = config['ffmpeg']
global FFMPEG_EXEC, FFMPEG_USE_NVIDIA_GPU, FFMPEG_USE_INTEL_GPU, VIDEO_BITRATE global FFMPEG_EXEC, FFMPEG_USE_NVIDIA_GPU, FFMPEG_USE_INTEL_GPU, FFMPEG_USE_VAAPI
FFMPEG_EXEC = section.get('exec', FFMPEG_EXEC) FFMPEG_EXEC = section.get('exec', FFMPEG_EXEC)
FFMPEG_USE_NVIDIA_GPU = section.getboolean('nvidia_gpu', FFMPEG_USE_NVIDIA_GPU) FFMPEG_USE_NVIDIA_GPU = section.getboolean('nvidia_gpu', FFMPEG_USE_NVIDIA_GPU)
FFMPEG_USE_INTEL_GPU = section.getboolean('intel_gpu', FFMPEG_USE_INTEL_GPU) FFMPEG_USE_INTEL_GPU = section.getboolean('intel_gpu', FFMPEG_USE_INTEL_GPU)
VIDEO_BITRATE = section.get('bitrate', VIDEO_BITRATE) FFMPEG_USE_VAAPI = section.getboolean('vaapi', FFMPEG_USE_VAAPI)
if config.has_section("handbrake"):
section = config['handbrake']
global HANDBRAKE_EXEC, HANDBRAKE_PRESET_FILE, HANDBRAKE_PRESET, HANDBRAKE_ENCOPT
HANDBRAKE_EXEC = section.get('exec', HANDBRAKE_EXEC)
HANDBRAKE_PRESET_FILE = section.get('preset_file', HANDBRAKE_PRESET_FILE)
HANDBRAKE_PRESET = section.get('preset', HANDBRAKE_PRESET)
HANDBRAKE_ENCOPT = section.get('encopt', HANDBRAKE_ENCOPT)
if config.has_section("recorder"): if config.has_section("recorder"):
global BILILIVE_RECORDER_DIRECTORY, XIGUALIVE_RECORDER_DIRECTORY, VIDEO_OUTPUT_DIR global BILILIVE_RECORDER_DIRECTORY, XIGUALIVE_RECORDER_DIRECTORY, VIDEO_OUTPUT_DIR
section = config['recorder'] section = config['recorder']
@ -78,20 +124,33 @@ def load_config():
def get_config(): def get_config():
config = { config = {
'danmaku': { 'danmaku': {
'exec': DANMAKU_FACTORY_EXEC, 'exec': DANMAKU_EXEC,
'use_danmu2ass': DANMAKU_USE_DANMU2ASS,
'use_danmakufactory': DANMAKU_USE_DANMAKUFACTORY,
'speed': DANMAKU_SPEED, 'speed': DANMAKU_SPEED,
'font': DEFAULT_FONT_NAME, 'font': DANMAKU_FONT_NAME,
'font_size': DANMAKU_FONT_SIZE,
'resolution': VIDEO_RESOLUTION, 'resolution': VIDEO_RESOLUTION,
}, },
'video': {
'enabled': VIDEO_ENABLED,
'title': VIDEO_TITLE,
'desc': VIDEO_DESC,
'tid': VIDEO_TID,
'tags': VIDEO_TAGS,
},
'clip': { 'clip': {
'each_sec': VIDEO_CLIP_EACH_SEC, 'each_sec': VIDEO_CLIP_EACH_SEC,
'overflow_sec': VIDEO_CLIP_OVERFLOW_SEC, 'overflow_sec': VIDEO_CLIP_OVERFLOW_SEC,
}, },
'ffmpeg': { 'ffmpeg': {
'exec': FFMPEG_EXEC, 'exec': FFMPEG_EXEC,
'nvidia_gpu': FFMPEG_USE_NVIDIA_GPU, },
'intel_gpu': FFMPEG_USE_INTEL_GPU, 'handbrake': {
'bitrate': VIDEO_BITRATE, 'exec': HANDBRAKE_EXEC,
'preset_file': HANDBRAKE_PRESET_FILE,
'preset': HANDBRAKE_PRESET,
'encopt': HANDBRAKE_ENCOPT,
}, },
'recorder': { 'recorder': {
'bili_dir': BILILIVE_RECORDER_DIRECTORY, 'bili_dir': BILILIVE_RECORDER_DIRECTORY,

View File

@ -1,9 +1,9 @@
# 工作流 # 工作流
import json
import os.path import os.path
import platform import platform
import subprocess import subprocess
import sys import sys
import threading
import traceback import traceback
from hashlib import md5 from hashlib import md5
from typing import Optional, IO, Union from typing import Optional, IO, Union
@ -14,9 +14,10 @@ from PyQt5.QtCore import Qt, QThread, pyqtSignal
from PyQt5.QtWidgets import QWidget, QLabel, QApplication, QFrame, QVBoxLayout, QPushButton, \ from PyQt5.QtWidgets import QWidget, QLabel, QApplication, QFrame, QVBoxLayout, QPushButton, \
QSizePolicy, QMessageBox QSizePolicy, QMessageBox
from danmaku_xml_helper import get_file_start, diff_danmaku_files, NoDanmakuException from danmaku_xml_helper import get_file_start, diff_danmaku_files, NoDanmakuException
from config import load_config, FFMPEG_EXEC, DANMAKU_FACTORY_EXEC, FFMPEG_USE_INTEL_GPU, FFMPEG_USE_NVIDIA_GPU, \ from config import load_config, \
VIDEO_BITRATE, VIDEO_CLIP_EACH_SEC, VIDEO_CLIP_OVERFLOW_SEC, VIDEO_RESOLUTION, DANMAKU_SPEED, DEFAULT_FONT_NAME, \ DANMAKU_EXEC, DANMAKU_SPEED, DANMAKU_FONT_NAME, DANMAKU_FONT_SIZE, DANMAKU_OPACITY, \
VIDEO_OUTPUT_DIR VIDEO_CLIP_EACH_SEC, VIDEO_CLIP_OVERFLOW_SEC, VIDEO_RESOLUTION, VIDEO_OUTPUT_DIR, \
FFMPEG_EXEC, HANDBRAKE_EXEC, HANDBRAKE_PRESET_FILE, HANDBRAKE_PRESET, HANDBRAKE_ENCOPT
class Job: class Job:
@ -302,28 +303,18 @@ class WorkerThread(QThread):
new_subtitle_name = danmaku_to_subtitle(danmaku, time_shift) new_subtitle_name = danmaku_to_subtitle(danmaku, time_shift)
job.subtitles.append(new_subtitle_name) job.subtitles.append(new_subtitle_name)
# 压制 # 压制
files_need_split = self.encode_video_with_subtitles(job.video, job.subtitles, base_start_ts) self.encode_video_with_subtitles(job.video, job.subtitles, base_start_ts)
for _f in files_need_split:
self.quick_split_video(_f)
for _f in job.subtitles: for _f in job.subtitles:
if os.path.isfile(_f): if os.path.isfile(_f):
os.remove(_f) os.remove(_f)
for _f in files_need_split:
if os.path.isfile(_f):
os.remove(_f)
def encode_video_with_subtitles(self, orig_filename: str, subtitles: list[str], base_ts: float): def encode_video_with_subtitles(self, orig_filename: str, subtitles: list[str], base_ts: float):
new_filename = base_ts_to_filename(base_ts, False) current_dt = create_dt = datetime.fromtimestamp(base_ts)
new_fullpath = os.path.join(VIDEO_OUTPUT_DIR, new_filename) process = get_encode_process_use_handbrake(orig_filename,
if FFMPEG_USE_NVIDIA_GPU: subtitles,
process = get_encode_process_use_nvenc(orig_filename, subtitles, new_fullpath) os.path.join(VIDEO_OUTPUT_DIR, "{}.mp4".format(current_dt)))
elif FFMPEG_USE_INTEL_GPU: self.handle_handbrake_output(process.stdout)
process = get_encode_process_use_intel(orig_filename, subtitles, new_fullpath)
else:
process = get_encode_process_use_cpu(orig_filename, subtitles, new_fullpath)
self.handle_ffmpeg_output(process.stdout)
process.wait() process.wait()
return [new_fullpath]
def quick_split_video(self, file): def quick_split_video(self, file):
if not os.path.isfile(file): if not os.path.isfile(file):
@ -333,6 +324,9 @@ class WorkerThread(QThread):
create_dt = datetime.strptime(_create_dt, "%Y%m%d_%H%M") create_dt = datetime.strptime(_create_dt, "%Y%m%d_%H%M")
duration = self.get_video_real_duration(file) duration = self.get_video_real_duration(file)
current_sec = 0 current_sec = 0
if duration < VIDEO_CLIP_EACH_SEC:
print("[-]Less than each sec, skip")
return False
while current_sec < duration: while current_sec < duration:
if (current_sec + VIDEO_CLIP_OVERFLOW_SEC * 2) > duration: if (current_sec + VIDEO_CLIP_OVERFLOW_SEC * 2) > duration:
print("[-]Less than 2 overflow sec, skip") print("[-]Less than 2 overflow sec, skip")
@ -343,7 +337,7 @@ class WorkerThread(QThread):
split_process = subprocess.Popen([ split_process = subprocess.Popen([
FFMPEG_EXEC, "-y", "-hide_banner", "-progress", "-", "-loglevel", "error", FFMPEG_EXEC, "-y", "-hide_banner", "-progress", "-", "-loglevel", "error",
"-ss", str(current_sec), "-ss", str(current_sec),
"-i", file, "-c", "copy", "-f", "mp4", "-i", file, "-c:v", "copy", "-f", "mp4", "-c:a", "aac",
"-t", str(VIDEO_CLIP_EACH_SEC + VIDEO_CLIP_OVERFLOW_SEC), "-t", str(VIDEO_CLIP_EACH_SEC + VIDEO_CLIP_OVERFLOW_SEC),
"-fflags", "+genpts", "-shortest", "-movflags", "faststart", "-fflags", "+genpts", "-shortest", "-movflags", "faststart",
os.path.join(VIDEO_OUTPUT_DIR, "{}.mp4".format(current_dt)) os.path.join(VIDEO_OUTPUT_DIR, "{}.mp4".format(current_dt))
@ -351,6 +345,34 @@ class WorkerThread(QThread):
self.handle_ffmpeg_output(split_process.stdout) self.handle_ffmpeg_output(split_process.stdout)
split_process.wait() split_process.wait()
current_sec += VIDEO_CLIP_EACH_SEC current_sec += VIDEO_CLIP_EACH_SEC
return True
def handle_handbrake_output(self, stdout: Optional[IO[bytes]]):
if stdout is None:
print("[!]STDOUT is null")
return
json_body = ""
json_start = False
while True:
line = stdout.readline()
if line == b"":
break
if json_start:
json_body += line.strip().decode("UTF-8")
if line.startswith(b"}"):
json_start = False
status_payload = json.loads(json_body)
if status_payload["State"] == "WORKING":
self.app.processCurTime.emit("ETA: {Hours:02d}:{Minutes:02d}:{Seconds:02d}".format_map(status_payload["Working"]))
self.app.processSpeed.emit("{Rate:.2f}FPS".format_map(status_payload["Working"]))
elif status_payload["State"] == "WORKDONE":
break
continue
if line.startswith(b"Progress:"):
json_start = True
json_body = "{"
self.app.processSpeed.emit("")
self.app.processCurTime.emit("")
def handle_ffmpeg_output(self, stdout: Optional[IO[bytes]]) -> str: def handle_ffmpeg_output(self, stdout: Optional[IO[bytes]]) -> str:
out_time = "0:0:0.0" out_time = "0:0:0.0"
@ -377,18 +399,6 @@ class WorkerThread(QThread):
return out_time return out_time
class NvencWorkerThread(QThread):
...
class IntelWorkerThread(QThread):
...
class SplitWorkerThread(QThread):
...
def duration_str_to_float(duration_str) -> float: def duration_str_to_float(duration_str) -> float:
_duration = datetime.strptime(duration_str, "%H:%M:%S.%f") - datetime(1900, 1, 1) _duration = datetime.strptime(duration_str, "%H:%M:%S.%f") - datetime(1900, 1, 1)
return _duration.total_seconds() return _duration.total_seconds()
@ -405,65 +415,25 @@ def base_ts_to_filename(start_ts: float, is_mp4=False) -> str:
def danmaku_to_subtitle(file: Union[os.PathLike[str], str], time_shift: float): def danmaku_to_subtitle(file: Union[os.PathLike[str], str], time_shift: float):
new_subtitle_name = md5(file.encode("utf-8")).hexdigest() + ".ass" new_subtitle_name = md5(file.encode("utf-8")).hexdigest() + ".ass"
process = subprocess.Popen(( process = subprocess.Popen((
DANMAKU_FACTORY_EXEC, "--ignore-warnings", DANMAKU_EXEC, "--ignore-warnings",
"-r", str(VIDEO_RESOLUTION), "-s", str(DANMAKU_SPEED), "-f", "5", "-r", str(VIDEO_RESOLUTION), "-s", str(DANMAKU_SPEED), "-f", "5",
"-S", "40", "-N", str(DEFAULT_FONT_NAME), "--showmsgbox", "FALSE", "-S", str(DANMAKU_FONT_SIZE), "-N", str(DANMAKU_FONT_NAME), "--showmsgbox", "FALSE",
"-O", "255", "-L", "1", "-D", "0", "-O", "{:.0f}".format(DANMAKU_OPACITY*255/100), "-L", "1", "-D", "0",
"-o", "ass", new_subtitle_name, "-i", file, "-t", str(time_shift) "-o", "ass", new_subtitle_name, "-i", file, "-t", str(time_shift)
), **subprocess_args(True)) ), **subprocess_args(True))
process.wait() process.wait()
return new_subtitle_name return new_subtitle_name
def get_encode_process_use_nvenc(orig_filename: str, subtitles: list[str], new_filename: str): def get_encode_process_use_handbrake(orig_filename: str, subtitles: list[str], new_filename: str):
print("[+]Use Nvidia NvEnc Acceleration") print("[+]Use HandBrakeCli")
encode_process = subprocess.Popen([ encode_process = subprocess.Popen([
FFMPEG_EXEC, *_common_ffmpeg_setting(), HANDBRAKE_EXEC, *_common_handbrake_setting(),
"-i", orig_filename, "-vf", "--preset-import-file", HANDBRAKE_PRESET_FILE, "--preset", HANDBRAKE_PRESET,
",".join("subtitles=%s" % i for i in subtitles) + ",hwupload_cuda", "-i", orig_filename, "-x", HANDBRAKE_ENCOPT,
"-c:v", "h264_nvenc", "-rc:v", "vbr", "--ssa-file", ",".join(i for i in subtitles),
*_common_ffmpeg_params(), "--ssa-burn", ",".join("%d" % (i+1) for i in range(len(subtitles))),
# "-t", "10", "-o",
new_filename
], **subprocess_args(True))
return encode_process
def get_encode_process_use_intel(orig_filename: str, subtitles: list[str], new_filename: str):
if platform.system().lower() == "windows":
print("[+]Use Intel QSV Acceleration")
encode_process = subprocess.Popen([
FFMPEG_EXEC, *_common_ffmpeg_setting(),
"-hwaccel", "qsv", "-i", orig_filename, "-vf",
",".join("subtitles=%s" % i for i in subtitles),
"-c:v", "h264_qsv", "-rc:v", "vbr",
*_common_ffmpeg_params(),
# "-t", "10",
new_filename
], **subprocess_args(True))
else:
print("[+]Use Intel VAAPI Acceleration")
encode_process = subprocess.Popen([
FFMPEG_EXEC, *_common_ffmpeg_setting(),
"-hwaccel", "vaapi", "-i", orig_filename, "-vf",
",".join("subtitles=%s" % i for i in subtitles) + ",hwupload",
"-c:v", "h264_vaapi", "-rc:v", "vbr",
*_common_ffmpeg_params(),
# "-t", "10",
new_filename
], **subprocess_args(True))
return encode_process
def get_encode_process_use_cpu(orig_filename: str, subtitles: list[str], new_filename: str):
print("[+]Use CPU Encode")
encode_process = subprocess.Popen([
FFMPEG_EXEC, *_common_ffmpeg_setting(),
"-i", orig_filename, "-vf",
",".join("subtitles=%s" % i for i in subtitles),
"-c:v", "h264",
*_common_ffmpeg_params(),
# "-t", "10",
new_filename new_filename
], **subprocess_args(True)) ], **subprocess_args(True))
return encode_process return encode_process
@ -545,12 +515,23 @@ def is_linux() -> bool:
def check_all_prerequisite(): def check_all_prerequisite():
if not check_exec(DANMAKU_FACTORY_EXEC): if not check_exec(DANMAKU_EXEC):
input("弹幕处理工具不存在") input("弹幕处理工具不存在")
exit(1) exit(1)
if not check_exec(FFMPEG_EXEC): if not check_exec(FFMPEG_EXEC):
input("FFMPEG工具不存在") input("FFMPEG工具不存在")
exit(1) exit(1)
if not check_exec(HANDBRAKE_EXEC):
input("HANDBRAKE工具不存在")
exit(1)
def _common_handbrake_setting():
return (
"--json",
"--crop-mode", "none", "--no-comb-detect", "--no-bwdif", "--no-decomb", "--no-detelecine", "--no-hqdn3d",
"--no-nlmeans", "--no-chroma-smooth", "--no-unsharp", "--no-lapsharp", "--no-deblock", "--align-av"
)
def _common_ffmpeg_setting(): def _common_ffmpeg_setting():
@ -559,15 +540,6 @@ def _common_ffmpeg_setting():
) )
def _common_ffmpeg_params():
return (
"-f", "mp4", "-b:v", VIDEO_BITRATE, "-c:a", "copy",
"-preset:v", "fast", "-profile:v", "main", "-avoid_negative_ts", "1",
"-qmin", "12", "-qmax", "34", "-crf", "26", "-g:v", "60",
"-fflags", "+genpts", "-shortest", "-movflags", "faststart"
)
def main(): def main():
check_all_prerequisite() check_all_prerequisite()
app = QApplication(sys.argv) app = QApplication(sys.argv)

557
danmaku_workflow_ffmpeg.py Normal file
View File

@ -0,0 +1,557 @@
# 工作流
import os.path
import platform
import subprocess
import sys
import traceback
from hashlib import md5
from typing import Optional, IO, Union
from datetime import datetime, timedelta
from PyQt5 import QtGui
from PyQt5.QtCore import Qt, QThread, pyqtSignal
from PyQt5.QtWidgets import QWidget, QLabel, QApplication, QFrame, QVBoxLayout, QPushButton, \
QSizePolicy, QMessageBox
from danmaku_xml_helper import get_file_start, diff_danmaku_files, NoDanmakuException
from config import load_config, \
DANMAKU_EXEC, DANMAKU_SPEED, DANMAKU_FONT_NAME, DANMAKU_FONT_SIZE, DANMAKU_OPACITY, \
VIDEO_CLIP_EACH_SEC, VIDEO_CLIP_OVERFLOW_SEC, VIDEO_RESOLUTION, VIDEO_OUTPUT_DIR, \
FFMPEG_EXEC, FFMPEG_USE_INTEL_GPU, FFMPEG_USE_NVIDIA_GPU
class Job:
DANMAKU_ENCODE = 0
PURE_SPLIT = 1
def __init__(self):
super(Job, self).__init__()
self.video = None
self.type = self.PURE_SPLIT
self.danmaku: list[str] = []
self.subtitles: list[str] = []
def __repr__(self):
return "Job对象:Video[{}];Type[{}]".format(self.video, self.type)
class WorkLabel(QLabel):
def __init__(self, *args, **kwargs):
super(WorkLabel, self).__init__(*args, **kwargs)
self.workVideo = None
self.workDanmaku: list[str] = []
self.running: bool = False
self.finished: bool = False
self.init_ui()
def _update_text(self):
if self.workVideo is None:
text = "请拖入视频"
else:
text = "工作视频:{}".format(self.workVideo)
if len(self.workDanmaku) == 0:
self.setText("{}\n分割视频模式".format(text))
else:
self.setText("{}\n压制弹幕模式\n基准弹幕:{}".format(text, "\n附加弹幕:".join(self.workDanmaku)))
self.adjustSize()
def mouseDoubleClickEvent(self, a0: QtGui.QMouseEvent) -> None:
if self.finished:
self.finished = False
elif self.workVideo is None:
self.parent().labelDestroy.emit(self)
return self.deleteLater()
else:
self.workVideo = None
self.workDanmaku = []
self.init_ui()
def init_ui(self):
font = QtGui.QFont()
font.setPixelSize(12)
self.setFont(font)
self.setFrameShape(QFrame.Box)
self.setStyleSheet("""QLabel {
background-color: white;
}""")
self.setAlignment(Qt.AlignTop)
self.setWordWrap(True)
self.setMinimumHeight(64)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self._update_text()
self.setAcceptDrops(True)
def dragEnterEvent(self, a0: QtGui.QDragEnterEvent) -> None:
if a0.mimeData().hasUrls():
a0.accept()
else:
a0.ignore()
print("Enter Label", a0.mimeData().urls())
def dropEvent(self, a0: QtGui.QDropEvent) -> None:
urls = a0.mimeData().urls()
for url in urls:
# 判断是否为视频
path = url.path().strip("/")
if path.endswith(".mp4") or path.endswith(".flv"):
self.set_video(path)
elif path.endswith(".xml"):
self.add_danmaku(path)
else:
print("Unknown File", path)
self._update_text()
def set_video(self, file):
if not os.path.isfile(file):
raise FileNotFoundError(file)
if file == self.workVideo:
return print("Same Video", file)
self.workVideo = file
self._update_text()
def add_danmaku(self, file: str) -> None:
if not os.path.isfile(file):
raise FileNotFoundError(file)
if file in self.workDanmaku:
return print("Already Added File", file)
self.workDanmaku.append(file)
self._update_text()
def have_job(self) -> bool:
return self.workVideo is not None and not self.running and not self.finished
def get_job(self) -> Job:
job = Job()
job.video = self.workVideo
if len(self.workDanmaku) > 0:
job.type = Job.DANMAKU_ENCODE
job.danmaku = self.workDanmaku
else:
job.type = Job.PURE_SPLIT
job.danmaku = []
return job
def start_running(self) -> None:
self.running = True
self.finished = False
self.setStyleSheet("""QLabel {
color: white;
background-color: red;
}""")
def stop_running(self) -> None:
self.running = False
self.finished = True
self.setStyleSheet("""QLabel {
color: white;
background-color: green;
}""")
class HomePage(QWidget):
showMessageBox = pyqtSignal(str, str)
labelDestroy = pyqtSignal(WorkLabel)
processCurTime = pyqtSignal(str)
processSpeed = pyqtSignal(str)
processCurTime2 = pyqtSignal(str)
processSpeed2 = pyqtSignal(str)
def __init__(self):
super(HomePage, self).__init__()
self.layout = None
self.labels: list[WorkLabel] = []
self.worker: WorkerThread
self.btn_start: QPushButton
self.showMessageBox.connect(self.on_show_message_box_info)
self.labelDestroy.connect(self.on_label_destroy)
self.processCurTime.connect(self.on_process_cur_time_change)
self.processSpeed.connect(self.on_process_speed_change)
self.process_cur_time = "-"
self.process_speed = "-"
self.cur_clip_duration = 0
self.init_ui()
def init_ui(self):
layout = QVBoxLayout()
self.layout = QVBoxLayout()
layout.addLayout(self.layout)
btn_start = QPushButton(self)
btn_start.setText("开始")
btn_start.clicked.connect(self.handle_do)
btn_start.setGeometry(0, 560, 400, 40)
layout.addWidget(btn_start, 1, Qt.AlignBottom)
self.setLayout(layout)
self.btn_start = btn_start
self.resize(400, 600)
self.setWindowTitle("录播工作流")
self.setAcceptDrops(True)
self.show()
def add_label(self):
label1 = WorkLabel(self)
self.layout.addWidget(label1)
self.labels.append(label1)
return label1
def handle_do(self):
if len(self.labels) == 0:
return self.on_show_message_box_warn("提示", "请添加任务")
for label in self.labels:
if not label.have_job():
continue
_thread = WorkerThread(self, label)
_thread.start()
self.worker = _thread
_thread.started.connect(self.on_worker_start)
_thread.finished.connect(self.on_worker_stop)
break
def dragEnterEvent(self, a0: QtGui.QDragEnterEvent) -> None:
if a0.mimeData().hasUrls():
a0.accept()
else:
a0.ignore()
def dropEvent(self, a0: QtGui.QDropEvent) -> None:
label1 = self.add_label()
label1.dropEvent(a0)
def on_show_message_box_info(self, title: str, content: str):
QMessageBox.information(self, title, content, QMessageBox.Yes)
def on_show_message_box_warn(self, title: str, content: str):
QMessageBox.warning(self, title, content, QMessageBox.Yes)
def on_label_destroy(self, label: WorkLabel):
if label in self.labels:
self.labels.remove(label)
def on_worker_start(self):
self.btn_start.setDisabled(True)
self.btn_start.setText("正在处理")
def on_process_cur_time_change(self, s: str) -> None:
if self.process_cur_time == s:
return
self.process_cur_time = s
self.update_btn_process_text()
def on_process_speed_change(self, s: str) -> None:
if self.process_speed == s:
return
self.process_speed = s
self.update_btn_process_text()
def update_btn_process_text(self):
if self.process_cur_time != "" and self.process_speed != "":
self.btn_start.setText("{}@{}".format(self.process_cur_time, self.process_speed))
else:
self.btn_start.setText("Working")
def on_worker_stop(self):
self.btn_start.setDisabled(False)
self.btn_start.setText("开始")
self.worker = None
self.handle_do()
class WorkerThread(QThread):
def __init__(self, app: HomePage, label: WorkLabel):
super(WorkerThread, self).__init__()
self.app = app
self.label = label
def get_video_real_duration(self, filename) -> float:
ffmpeg_process = subprocess.Popen([
FFMPEG_EXEC, "-hide_banner", "-progress", "-", "-loglevel", "error", "-i", filename, "-c", "copy", "-f",
"null", "-"
], **subprocess_args(True))
_duration_str = self.handle_ffmpeg_output(ffmpeg_process.stdout)
ffmpeg_process.wait()
return duration_str_to_float(_duration_str)
def run(self) -> None:
try:
self.label.start_running()
job = self.label.get_job()
if job.type == Job.DANMAKU_ENCODE:
self.run_danmaku_encode(job)
elif job.type == Job.PURE_SPLIT:
self.quick_split_video(job.video)
except Exception as e:
print(e)
print(traceback.format_exc())
finally:
self.label.stop_running()
def run_danmaku_encode(self, job: Job):
base_danmaku = job.danmaku.pop(0)
time_shift = 0
try:
base_start_ts = get_file_start(base_danmaku)
except NoDanmakuException:
return
new_subtitle_name = danmaku_to_subtitle(base_danmaku, time_shift)
job.subtitles.append(new_subtitle_name)
for danmaku in job.danmaku:
try:
time_shift = diff_danmaku_files(base_danmaku, danmaku)
except NoDanmakuException:
continue
new_subtitle_name = danmaku_to_subtitle(danmaku, time_shift)
job.subtitles.append(new_subtitle_name)
# 压制
self.encode_video_with_subtitles(job.video, job.subtitles, base_start_ts)
for _f in job.subtitles:
if os.path.isfile(_f):
os.remove(_f)
def encode_video_with_subtitles(self, orig_filename: str, subtitles: list[str], base_ts: float):
new_filename = base_ts_to_filename(base_ts, False)
new_fullpath = os.path.join(VIDEO_OUTPUT_DIR, new_filename)
if FFMPEG_USE_NVIDIA_GPU:
process = get_encode_process_use_nvenc(orig_filename, subtitles, new_fullpath)
elif FFMPEG_USE_INTEL_GPU:
process = get_encode_process_use_intel(orig_filename, subtitles, new_fullpath)
else:
process = get_encode_process_use_cpu(orig_filename, subtitles, new_fullpath)
self.handle_ffmpeg_output(process.stdout)
process.wait()
return [new_fullpath]
def quick_split_video(self, file):
if not os.path.isfile(file):
raise FileNotFoundError(file)
file_name = os.path.split(file)[-1]
_create_dt = os.path.splitext(file_name)[0]
create_dt = datetime.strptime(_create_dt, "%Y%m%d_%H%M")
duration = self.get_video_real_duration(file)
current_sec = 0
if duration < VIDEO_CLIP_EACH_SEC:
print("[-]Less than each sec, skip")
return False
while current_sec < duration:
if (current_sec + VIDEO_CLIP_OVERFLOW_SEC * 2) > duration:
print("[-]Less than 2 overflow sec, skip")
break
current_dt = (create_dt + timedelta(seconds=current_sec)).strftime("%Y%m%d_%H%M_")
print("CUR_DT", current_dt)
print("BIAS_T", current_sec)
split_process = subprocess.Popen([
FFMPEG_EXEC, "-y", "-hide_banner", "-progress", "-", "-loglevel", "error",
"-ss", str(current_sec),
"-i", file, "-c:v", "copy", "-f", "mp4", "-c:a", "aac",
"-t", str(VIDEO_CLIP_EACH_SEC + VIDEO_CLIP_OVERFLOW_SEC),
"-fflags", "+genpts", "-shortest", "-movflags", "faststart",
os.path.join(VIDEO_OUTPUT_DIR, "{}.mp4".format(current_dt))
], **subprocess_args(True))
self.handle_ffmpeg_output(split_process.stdout)
split_process.wait()
current_sec += VIDEO_CLIP_EACH_SEC
return True
def handle_ffmpeg_output(self, stdout: Optional[IO[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()
if line == b"":
break
if line.strip() == b"progress=end":
# 处理完毕
break
if line.startswith(b"out_time="):
out_time = line.replace(b"out_time=", b"").decode().strip()
self.app.processCurTime.emit(out_time)
if line.startswith(b"speed="):
speed = line.replace(b"speed=", b"").decode().strip()
self.app.processSpeed.emit(speed)
self.app.processSpeed.emit("")
self.app.processCurTime.emit("")
print("[ ]Speed:", out_time, "@", speed)
return out_time
def duration_str_to_float(duration_str) -> float:
_duration = datetime.strptime(duration_str, "%H:%M:%S.%f") - datetime(1900, 1, 1)
return _duration.total_seconds()
def base_ts_to_filename(start_ts: float, is_mp4=False) -> str:
base_start = datetime.fromtimestamp(start_ts)
if is_mp4:
return base_start.strftime("%Y%m%d_%H%M.mp4")
else:
return base_start.strftime("%Y%m%d_%H%M.flv")
def danmaku_to_subtitle(file: Union[os.PathLike[str], str], time_shift: float):
new_subtitle_name = md5(file.encode("utf-8")).hexdigest() + ".ass"
process = subprocess.Popen((
DANMAKU_EXEC, "--ignore-warnings",
"-r", str(VIDEO_RESOLUTION), "-s", str(DANMAKU_SPEED), "-f", "5",
"-S", str(DANMAKU_FONT_SIZE), "-N", str(DANMAKU_FONT_NAME), "--showmsgbox", "FALSE",
"-O", "255", "-L", "1", "-D", "0",
"-o", "ass", new_subtitle_name, "-i", file, "-t", str(time_shift)
), **subprocess_args(True))
process.wait()
return new_subtitle_name
def get_encode_process_use_nvenc(orig_filename: str, subtitles: list[str], new_filename: str):
print("[+]Use Nvidia NvEnc Acceleration")
encode_process = subprocess.Popen([
FFMPEG_EXEC, *_common_ffmpeg_setting(),
"-hwaccel", "cuda", "-i", orig_filename, "-vf",
",".join("subtitles=%s" % i for i in subtitles),
"-c:v", "h264_nvenc", "-preset:v", "p7", "-profile:v", "main",
"-tune", "hq", "-multipass", "2", "-cq", "30", "-rc-lookahead", "5",
"-g", "600", "-keyint_min", "1", "-qdiff", "30", "-qcomp", "0.9",
*_common_ffmpeg_params(),
# "-t", "10",
new_filename
], **subprocess_args(True))
return encode_process
def get_encode_process_use_intel(orig_filename: str, subtitles: list[str], new_filename: str):
print("[+]Use Intel QSV Acceleration")
encode_process = subprocess.Popen([
FFMPEG_EXEC, *_common_ffmpeg_setting(),
"-hwaccel", "qsv", "-i", orig_filename, "-vf",
",".join("subtitles=%s" % i for i in subtitles),
"-c:v", "h264_qsv", "-preset:v", "slow", "-profile:v", "main",
*_common_ffmpeg_params(),
# "-t", "10",
new_filename
], **subprocess_args(True))
return encode_process
def get_encode_process_use_cpu(orig_filename: str, subtitles: list[str], new_filename: str):
print("[+]Use CPU Encode")
encode_process = subprocess.Popen([
FFMPEG_EXEC, *_common_ffmpeg_setting(),
"-i", orig_filename, "-vf",
",".join("subtitles=%s" % i for i in subtitles),
"-c:v", "h264", "-preset:v", "slow", "-profile:v", "main",
*_common_ffmpeg_params(),
# "-t", "10",
new_filename
], **subprocess_args(True))
return encode_process
# 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 <module>
# **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
def check_exec(name: Union[os.PathLike[str], str]) -> bool:
if is_windows():
check_process = subprocess.Popen([
"where.exe", name
], **subprocess_args(True))
check_process.wait()
return len(check_process.stdout.readlines()) > 0
elif is_linux():
check_process = subprocess.Popen([
"which", name
])
check_process.wait()
return check_process.returncode == 0
else:
return False
def is_windows() -> bool:
return platform.system().lower() == "windows"
def is_linux() -> bool:
return platform.system().lower() == "linux"
def check_all_prerequisite():
if not check_exec(DANMAKU_EXEC):
input("弹幕处理工具不存在")
exit(1)
if not check_exec(FFMPEG_EXEC):
input("FFMPEG工具不存在")
exit(1)
def _common_ffmpeg_setting():
return (
"-y", "-hide_banner", "-progress", "-", "-loglevel", "error",
)
def _common_ffmpeg_params():
return (
"-f", "mp4", "-c:a", "aac",
"-avoid_negative_ts", "1",
"-fflags", "+genpts", "-shortest"
)
def main():
check_all_prerequisite()
app = QApplication(sys.argv)
page = HomePage()
sys.exit(app.exec_())
if __name__ == '__main__':
load_config()
main()

View File

@ -0,0 +1,40 @@
# -*- mode: python ; coding: utf-8 -*-
block_cipher = None
a = Analysis(['danmaku_workflow_ffmpeg.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False)
pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher)
exe = EXE(pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='录播工作流_ffmpeg',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None )

View File

@ -5,8 +5,6 @@ from typing import Union
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from config import DANMAKU_FACTORY_EXEC, VIDEO_RESOLUTION, DANMAKU_SPEED, DEFAULT_FONT_NAME
class NoDanmakuException(Exception): class NoDanmakuException(Exception):
... ...

View File

@ -1,9 +1,6 @@
beautifulsoup4==4.10.0 beautifulsoup4==4.10.0
bs4==0.0.1 bs4==0.0.1
lxml==4.8.0 lxml==4.8.0
pip==21.3.1
setuptools==60.2.0
soupsieve==2.3.1 soupsieve==2.3.1
wheel==0.37.1 wheel==0.37.1
PyQt5~=5.15.6 PyQt5~=5.15.6