初次提交

This commit is contained in:
Jerry Yan 2022-04-15 12:26:43 +08:00
commit 09b2956573
30 changed files with 952 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.idea
*.flv
*.xml
*.db
*.ini

30
app.py Normal file
View File

@ -0,0 +1,30 @@
from flask import Flask
from config import load_config, WEB_HOST, WEB_PORT
from controller.view.main_blueprint import blueprint as view_main_blueprint
from controller.api.config_blueprint import blueprint as api_config_blueprint
from controller.api.collector_blueprint import blueprint as api_collector_blueprint
from controller.api.bilirecorder_blueprint import blueprint as api_bilirecorder_blueprint
from controller.api.workflow_blueprint import blueprint as api_workflow_blueprint
from model import db
app = Flask(__name__)
app.config['JSON_AS_ASCII'] = False
# CORS(app, supports_credentials=True)
app.debug = True
load_config()
app.config['SQLALCHEMY_DATABASE_URI'] = "sqlite:///database.db?check_same_thread=False"
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = True
app.config['SQLALCHEMY_ECHO'] = True
db.init_app(app)
app.register_blueprint(view_main_blueprint)
app.register_blueprint(api_config_blueprint)
app.register_blueprint(api_collector_blueprint)
app.register_blueprint(api_bilirecorder_blueprint)
app.register_blueprint(api_workflow_blueprint)
with app.app_context():
# db.drop_all(app=app)
db.create_all(app=app)
if __name__ == '__main__':
app.run(WEB_HOST, WEB_PORT)

105
config.py Normal file
View File

@ -0,0 +1,105 @@
import configparser
import os.path
# [danmaku]
# exec
DANMAKU_FACTORY_EXEC = "DanmakuFactory"
# speed
DANMAKU_SPEED = 12
# font
DEFAULT_FONT_NAME = "Sarasa Term SC"
# resolution
VIDEO_RESOLUTION = "1280x720"
# [ffmpeg]
# exec
FFMPEG_EXEC = "ffmpeg"
# gpu
FFMPEG_USE_GPU = False
# bitrate
VIDEO_BITRATE = "2.5M"
# [video]
# title
VIDEO_TITLE = "【永恒de草薙直播录播】直播于 {}"
# [recorder]
# bili_dir
BILILIVE_RECORDER_DIRECTORY = "./"
# xigua_dir
XIGUALIVE_RECORDER_DIRECTORY = "./"
# [web]
# host
WEB_HOST = "0.0.0.0"
# port
WEB_PORT = 5000
def load_config():
if not os.path.exists("config.ini"):
write_config()
return False
config = configparser.ConfigParser()
if config.has_section("danmaku"):
section = config['danmaku']
global DANMAKU_FACTORY_EXEC, DANMAKU_SPEED, DEFAULT_FONT_NAME, VIDEO_RESOLUTION
DANMAKU_FACTORY_EXEC = section.get('exec', DANMAKU_FACTORY_EXEC)
DANMAKU_SPEED = section.getfloat('speed', DANMAKU_SPEED)
DEFAULT_FONT_NAME = section.get('font', DEFAULT_FONT_NAME)
VIDEO_RESOLUTION = section.get('resolution', VIDEO_RESOLUTION)
if config.has_section("video"):
section = config['video']
global VIDEO_TITLE
VIDEO_TITLE = section.get('title', VIDEO_TITLE)
if config.has_section("ffmpeg"):
section = config['ffmpeg']
global FFMPEG_EXEC, FFMPEG_USE_GPU, VIDEO_BITRATE
FFMPEG_EXEC = section.get('exec', FFMPEG_EXEC)
FFMPEG_USE_GPU = section.getboolean('gpu', FFMPEG_USE_GPU)
VIDEO_BITRATE = section.get('bitrate', VIDEO_BITRATE)
if config.has_section("recorder"):
global BILILIVE_RECORDER_DIRECTORY, XIGUALIVE_RECORDER_DIRECTORY
section = config['recorder']
BILILIVE_RECORDER_DIRECTORY = section.get('bili_dir', BILILIVE_RECORDER_DIRECTORY)
XIGUALIVE_RECORDER_DIRECTORY = section.get('xigua_dir', XIGUALIVE_RECORDER_DIRECTORY)
if config.has_section("web"):
global WEB_HOST, WEB_PORT
section = config['web']
WEB_HOST = section.get('host', WEB_HOST)
WEB_PORT = section.getint('port', WEB_PORT)
return True
def get_config():
config = {
'danmaku': {
'exec': DANMAKU_FACTORY_EXEC,
'speed': DANMAKU_SPEED,
'font': DEFAULT_FONT_NAME,
'resolution': VIDEO_RESOLUTION,
},
'video': {
'title': VIDEO_TITLE,
},
'ffmpeg': {
'exec': FFMPEG_EXEC,
'gpu': FFMPEG_USE_GPU,
'bitrate': VIDEO_BITRATE,
},
'recorder': {
'bili_dir': BILILIVE_RECORDER_DIRECTORY,
'xigua_dir': XIGUALIVE_RECORDER_DIRECTORY,
},
'web': {
'host': WEB_HOST,
'port': WEB_PORT,
}
}
return config
def write_config():
config = configparser.ConfigParser()
_config = get_config()
for _i in _config:
config[_i] = _config[_i]
with open("config.ini", "w", encoding="utf-8") as f:
config.write(f)
return True

View File

@ -0,0 +1,134 @@
import os.path
from datetime import datetime, timedelta
from glob import glob
from flask import Blueprint, jsonify, request, current_app
from typing import Optional
from config import BILILIVE_RECORDER_DIRECTORY, VIDEO_TITLE
from model import db
from model.DanmakuClip import DanmakuClip
from model.VideoClip import VideoClip
from model.Workflow import Workflow
blueprint = Blueprint("api_bilirecorder", __name__, url_prefix="/api/bilirecorder")
bili_record_workflow_item: Optional[Workflow] = None
def clear_item():
global bili_record_workflow_item
bili_record_workflow_item = None
def commit_item():
global bili_record_workflow_item
if bili_record_workflow_item is None:
return
bili_record_workflow_item.calculate_start_time()
db.session.commit()
def safe_create_item():
global bili_record_workflow_item
if bili_record_workflow_item is None:
bili_record_workflow_item = Workflow.query.filter(
Workflow.update_time > (datetime.now() - timedelta(hours=8)),
Workflow.automatic == 1
).first()
if bili_record_workflow_item is None:
bili_record_workflow_item = Workflow()
else:
if bili_record_workflow_item is not None and bili_record_workflow_item.id is not None:
bili_record_workflow_item.editing = False
commit_item()
bili_record_workflow_item = Workflow()
if bili_record_workflow_item is None:
bili_record_workflow_item = Workflow()
bili_record_workflow_item.name = VIDEO_TITLE.format(datetime.now().strftime("%Y%m%d"))
bili_record_workflow_item.automatic = True
bili_record_workflow_item.editing = True
db.session.commit()
return bili_record_workflow_item
def safe_get_item() -> Workflow:
global bili_record_workflow_item
if bili_record_workflow_item is None:
return safe_create_item()
return bili_record_workflow_item
def collect_danmaku_files(workflow: Optional[Workflow]):
if workflow is None:
return
clip: VideoClip
for clip in workflow.video_clips:
full_path = clip.full_path
pre_file_name = os.path.splitext(full_path)[0]
# 理论上也只有一个结果
for danmaku_file in glob("{}*xml".format(pre_file_name)):
if os.path.exists(danmaku_file):
relpath = os.path.relpath(danmaku_file, BILILIVE_RECORDER_DIRECTORY)
# 确认是否已经添加
already_add = False
for danmaku_clip in workflow.danmaku_clips:
if danmaku_clip.file == relpath and danmaku_clip.base_path == BILILIVE_RECORDER_DIRECTORY:
already_add = True
break
if not already_add:
danmaku = DanmakuClip()
danmaku.file = relpath
danmaku.base_path = BILILIVE_RECORDER_DIRECTORY
danmaku.offset = 0
danmaku.workflow = workflow
db.session.add(danmaku)
workflow.danmaku_clips.append(danmaku)
commit_item()
@blueprint.post("/")
def bilirecorder_event():
payload = request.json
current_app.logger.debug(payload)
if 'EventType' not in payload:
response = jsonify({
'error': "异常",
'payload': payload,
})
response.status_code = 403
return response
if payload['EventType'] == "SessionStarted":
# 录制开始
safe_create_item()
elif payload['EventType'] == "SessionEnded":
# 录制结束
item = safe_get_item()
item.editing = False
commit_item()
clear_item()
return jsonify(item.to_dict())
elif payload['EventType'] == "FileClosed":
# 文件关闭
item = safe_get_item()
event_data = payload.get("EventData", {})
video_file = event_data.get("RelativePath", None)
# 判断是否重复
already_add = False
for clip in item.video_clips:
if video_file == clip.file and clip.base_path == BILILIVE_RECORDER_DIRECTORY:
already_add = True
break
if not already_add:
video_clip = VideoClip()
video_clip.file = video_file
video_clip.base_path = BILILIVE_RECORDER_DIRECTORY
video_clip.duration = event_data.get("Duration", 0)
item.video_clips.append(video_clip)
commit_item()
collect_danmaku_files(item)
return jsonify(item.to_dict())
commit_item()
item = safe_get_item()
return jsonify(item.to_dict())

View File

@ -0,0 +1,20 @@
import platform
from flask import Blueprint, jsonify
from util.system import check_exec
from config import DANMAKU_FACTORY_EXEC, FFMPEG_EXEC
blueprint = Blueprint("api_collector", __name__, url_prefix="/api/collector")
@blueprint.get("/")
def collect_basic_status():
return jsonify({
'exec': {
'ffmpeg': check_exec(FFMPEG_EXEC),
'danmaku': check_exec(DANMAKU_FACTORY_EXEC),
},
'system': {
'os': platform.system(),
}
})

View File

@ -0,0 +1,29 @@
from flask import Blueprint, jsonify, request
from config import get_config, write_config, load_config
blueprint = Blueprint("api_config", __name__, url_prefix="/api/config")
@blueprint.get("/")
def get_global_config():
return jsonify(get_config())
@blueprint.put("/")
def modify_global_config():
return jsonify(request.json)
@blueprint.post("/write")
def write_global_config():
return jsonify({
"result": write_config()
})
@blueprint.post("/load")
def load_global_config():
return jsonify({
"result": load_config()
})

View File

@ -0,0 +1,32 @@
from flask import Blueprint, jsonify
from model.Workflow import Workflow
from model import db
blueprint = Blueprint("api_workflow", __name__, url_prefix="/api/workflow")
@blueprint.get("/")
def get_workflow_list():
workflows = Workflow.query.all()
return jsonify([d.to_dict() for d in workflows])
@blueprint.get("/<int:workflow_id>")
def get_workflow_info(workflow_id):
workflow = Workflow.get(workflow_id)
return jsonify(workflow)
@blueprint.put("/<int:workflow_id>/done")
def done_editing(workflow_id):
workflow = Workflow.get(workflow_id)
return jsonify(workflow.to_dict())
@blueprint.post("/<int:workflow_id>/queue")
def add_to_queue(workflow_id):
# JOB_QUEUE.put(workflow_item)
return jsonify({
'id': workflow_id,
})

View File

@ -0,0 +1,8 @@
from flask import Blueprint, render_template
blueprint = Blueprint("view_main", __name__, url_prefix="/")
@blueprint.route("/")
def main_page():
return render_template("index.html")

5
entity/DanmakuFile.py Normal file
View File

@ -0,0 +1,5 @@
from entity.File import File
class DanmakuFile(File):
...

25
entity/File.py Normal file
View File

@ -0,0 +1,25 @@
import os
from os import PathLike
from typing import Union, Optional
class File(object):
base_path: Optional[Union[os.PathLike[str], str]]
file: Union[PathLike[str], str]
def __init__(self, file: Union[os.PathLike[str], str], base_path: Optional[Union[os.PathLike[str], str]] = None):
self.file = file
self.base_path = base_path
@property
def full_path(self) -> str:
if self.base_path is None or len(self.base_path.strip()) == 0:
return self.file
else:
return os.path.join(self.base_path, self.file)
def abs_path(self) -> str:
return os.path.abspath(self.full_path)
def exist(self) -> bool:
return os.path.exists(self.full_path)

18
entity/VideoClip.py Normal file
View File

@ -0,0 +1,18 @@
from typing import Optional
from entity.File import File
class VideoClip(File):
duration: Optional[float]
def __init__(self, file, base_path):
super(VideoClip, self).__init__(file, base_path)
self.duration = None
def set_duration(self, duration: Optional[float]):
self.duration = duration
def evaluate_duration(self):
if self.duration is None or self.duration < 0:
...

5
entity/VideoPart.py Normal file
View File

@ -0,0 +1,5 @@
from entity.File import File
class VideoPart(File):
...

118
entity/WorkflowItem.py Normal file
View File

@ -0,0 +1,118 @@
import os
from datetime import datetime
from hashlib import md5
from typing import Union, Optional
from config import VIDEO_TITLE
from entity.DanmakuFile import DanmakuFile
from entity.VideoClip import VideoClip
from entity.VideoPart import VideoPart
class WorkflowItem(object):
automatic: bool
finished: bool
conflict: bool
editing: bool
error: bool
delay_time: int
mode: int
videoParts: list[VideoPart]
videos: list[VideoClip]
def __init__(self):
self.automatic = False
self.finished = False
self.conflict = False
self.editing = False
self.error = False
self.title = VIDEO_TITLE
self.create_time = datetime.now()
self.delay_time = 0
self.mode = WorkflowModeEnum.MANUAL
self.danmakus = []
self.subtitleFiles = []
self.videos = []
self.videoParts = []
@property
def id(self):
return md5(self.name.encode("utf-8")).hexdigest()
@property
def name(self):
return self.title.format(self.create_time.strftime("%Y%m%d"))
def mode_set(self, mode: int):
self.mode = mode
def mode_add(self, mode: int):
self.mode = self.mode | mode
def mode_del(self, mode: int):
self.mode = self.mode ^ mode
def mode_has(self, mode: int) -> bool:
return self.mode & mode == mode
def mode_add_merge(self):
return self.mode_add(WorkflowModeEnum.MERGE)
def mode_del_merge(self):
return self.mode_del(WorkflowModeEnum.MERGE)
def mode_has_merge(self) -> bool:
return self.mode_has(WorkflowModeEnum.MERGE)
def mode_add_danmaku(self):
return self.mode_add(WorkflowModeEnum.DANMAKU)
def mode_del_danmaku(self):
return self.mode_del(WorkflowModeEnum.DANMAKU)
def mode_has_danmaku(self) -> bool:
return self.mode_has(WorkflowModeEnum.DANMAKU)
def mode_add_encode(self):
return self.mode_add(WorkflowModeEnum.ENCODE)
def mode_del_encode(self):
return self.mode_del(WorkflowModeEnum.ENCODE)
def mode_has_encode(self) -> bool:
return self.mode_has(WorkflowModeEnum.ENCODE)
def mode_add_split(self):
return self.mode_add(WorkflowModeEnum.SPLIT)
def mode_del_split(self):
return self.mode_del(WorkflowModeEnum.SPLIT)
def mode_has_split(self) -> bool:
return self.mode_has(WorkflowModeEnum.SPLIT)
def add_video(self, video_file: Union[VideoClip, os.PathLike[str], str], base_path: Optional[Union[os.PathLike[str], str]] = None) -> VideoClip:
if isinstance(video_file, VideoClip):
self.videos.append(video_file)
else:
video_file = VideoClip(video_file, base_path)
self.videos.append(video_file)
return video_file
def add_danmaku(self, danmaku_file: Union[DanmakuFile, os.PathLike[str], str], base_path: Optional[Union[os.PathLike[str], str]] = None) -> DanmakuFile:
if isinstance(danmaku_file, DanmakuFile):
self.danmakus.append(danmaku_file)
else:
danmaku_file = DanmakuFile(danmaku_file, base_path)
self.danmakus.append(danmaku_file)
return danmaku_file
class WorkflowModeEnum:
MERGE = 1 << 4
DANMAKU = 1 << 3
ENCODE = 1 << 2
SPLIT = 1 << 1
DANMAKU_DUAL = DANMAKU | ENCODE | SPLIT
DANMAKU_SINGLE = DANMAKU | ENCODE | SPLIT
MANUAL = 0

6
exception/danmaku.py Normal file
View File

@ -0,0 +1,6 @@
class NoDanmakuException(Exception):
...
class DanmakuFormatErrorException(Exception):
...

20
model/DanmakuClip.py Normal file
View File

@ -0,0 +1,20 @@
from . import db
class DanmakuClip(db.Model):
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
base_path = db.Column(db.String(255))
file = db.Column(db.String(255))
subtitle_file = db.Column(db.String(255))
offset = db.Column(db.Float, nullable=False, default=0)
workflow_id = db.Column(db.Integer, db.ForeignKey('workflow.id'))
workflow = db.relationship("Workflow", backref=db.backref("danmaku_clips", lazy="dynamic"))
def to_json(self):
return {
"id": self.id,
"base_path": self.base_path,
"file": self.file,
"subtitle_file": self.subtitle_file,
"offset": self.offset,
}

25
model/Posting.py Normal file
View File

@ -0,0 +1,25 @@
from datetime import datetime
from model import db
class Posting(db.Model):
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
bvid = db.Column(db.String(255))
name = db.Column(db.String(255))
description = db.Column(db.Text)
state = db.Column(db.SmallInteger, nullable=False, default=0)
create_time = db.Column(db.DateTime, nullable=False, default=datetime.now)
update_time = db.Column(db.DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
def to_json(self):
return {
"id": self.id,
"bvid": self.bvid,
"name": self.name,
"description": self.description,
"state": self.state,
"create_time": self.create_time.strftime("%Y/%m/%d %H:%M:%S") if self.create_time else None,
"update_time": self.update_time.strftime("%Y/%m/%d %H:%M:%S") if self.update_time else None,
"video_parts": [i.to_json() for i in self.video_parts.all()]
}

29
model/VideoClip.py Normal file
View File

@ -0,0 +1,29 @@
import os.path
from typing import TYPE_CHECKING
from . import db
if TYPE_CHECKING:
from .Workflow import Workflow
class VideoClip(db.Model):
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
base_path = db.Column(db.String(255))
file = db.Column(db.String(255))
duration = db.Column(db.Float, nullable=False, default=0)
offset = db.Column(db.Float, nullable=False, default=0)
workflow_id = db.Column(db.Integer, db.ForeignKey('workflow.id'))
workflow: "Workflow" = db.relationship("Workflow", uselist=False, backref=db.backref("video_clips"))
@property
def full_path(self):
return os.path.abspath(os.path.join(self.base_path, self.file))
def to_json(self):
return {
"id": self.id,
"base_path": self.base_path,
"file": self.file,
"duration": self.duration,
"offset": self.offset,
}

29
model/VideoPart.py Normal file
View File

@ -0,0 +1,29 @@
from datetime import datetime
from . import db
class VideoPart(db.Model):
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
base_path = db.Column(db.String(255), nullable=False)
file = db.Column(db.String(255), nullable=False)
remote_id = db.Column(db.String(255))
name = db.Column(db.String(255))
state = db.Column(db.SmallInteger, nullable=False, default=0)
workflow_id = db.Column(db.Integer, db.ForeignKey('workflow.id'))
workflow = db.relationship("Workflow", uselist=False, backref=db.backref("video_parts", lazy="dynamic"))
posting_id = db.Column(db.Integer, db.ForeignKey('posting.id'))
posting = db.relationship("Posting", backref=db.backref("video_parts", lazy="dynamic"), lazy=False)
def to_json(self):
return {
"id": self.id,
"base_path": self.base_path,
"file": self.file,
"duration": self.duration,
"remote_id": self.remote_id,
"name": self.name,
"state": self.state,
}

46
model/Workflow.py Normal file
View File

@ -0,0 +1,46 @@
from datetime import datetime, timedelta
from typing import TYPE_CHECKING
from . import db
if TYPE_CHECKING:
from .VideoClip import VideoClip
from .VideoPart import VideoPart
from .DanmakuClip import DanmakuClip
class Workflow(db.Model):
video_clips: list["VideoClip"]
danmaku_clips: list["DanmakuClip"]
video_parts: list["VideoPart"]
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
name = db.Column(db.String(255), nullable=False)
state = db.Column(db.SmallInteger, nullable=False, default=0)
"""0未开始1弹幕处理2完成3弹幕压制4完成5切割视频6完成9全部完成"""
create_time = db.Column(db.DateTime, nullable=False, default=datetime.now)
update_time = db.Column(db.DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
automatic = db.Column(db.Boolean, default=False, nullable=False)
editing = db.Column(db.Boolean, default=True, nullable=False)
start_after_time = db.Column(db.DateTime, default=None)
def calculate_start_time(self):
if self.editing:
self.start_after_time = None
if not self.automatic:
self.start_after_time = self.start_after_time
else:
self.start_after_time = datetime.now() + timedelta(minutes=30)
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"state": self.state,
"create_time": self.create_time.strftime("%Y/%m/%d %H:%M:%S") if self.create_time else None,
"update_time": self.update_time.strftime("%Y/%m/%d %H:%M:%S") if self.update_time else None,
"automatic": self.automatic,
"editing": self.editing,
"start_after_time": self.start_after_time.strftime("%Y/%m/%d %H:%M:%S") if self.start_after_time else None,
"video_clips": [i.to_json() for i in self.video_clips],
"danmaku_clips": [i.to_json() for i in self.danmaku_clips],
}

3
model/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()

131
templates/index.html Normal file
View File

@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<title>首页</title>
{% include 'layout/head.html' %}
<style>
table {
border: 1px solid black;
}
table tr > td {
padding: 0;
margin: 0;
border: 1px solid black;
}
td.success {
background: green;
color: white;
}
td.warning {
background: orangered;
color: white;
}
#app[v-cloak] {
display: none;
}
</style>
</head>
<body>
{% raw %}
<div id="app" v-cloak>
<h1>TEST</h1>
<hr>
<h2>当前状态</h2>
<table class="current-status-table">
<tbody>
<tr>
<td>系统</td>
<td>{{ collector.basic.system.os }}</td>
</tr>
<tr>
<td>FFMPEG状态</td>
<td :class="collector.basic.exec.ffmpeg ? 'success' : 'warning'"></td>
</tr>
<tr>
<td>弹幕工具状态</td>
<td :class="collector.basic.exec.danmaku ? 'success' : 'warning'"></td>
</tr>
</tbody>
</table>
<h2>配置状态</h2>
<table class="current-config">
<thead>
<tr>
<td colspan="2">FFMPEG</td>
</tr>
</thead>
<tbody>
<tr>
<td>命令</td>
<td>{{ config.ffmpeg.exec }}</td>
</tr>
<tr>
<td>GPU使用</td>
<td :class="{warning: !config.ffmpeg.gpu, success: config.ffmpeg.gpu}"></td>
</tr>
<tr>
<td>视频比特率</td>
<td>{{ config.ffmpeg.bitrate }}</td>
</tr>
</tbody>
</table>
</div>
{% endraw %}
</body>
<script>
Vue.devtools = true
Vue.createApp({
data() {
return {
collector: {
basic: {
exec: {
ffmpeg: false,
danmaku: false,
},
system: {
os: "",
}
}
},
config: {
danmaku: {
exec: "",
speed: 0,
font: "",
resolution: "",
},
video: {
title: "",
},
ffmpeg: {
exec: "",
gpu: false,
bitrate: "",
},
web: {
host: "",
port: 0,
}
}
}
},
created() {
axios({
url: "/api/collector/"
}).then((response) => {
this.collector.basic = response.data;
})
axios({
url: "/api/config/"
}).then((response) => {
this.config = response.data;
})
},
}).mount("#app")
</script>
</html>

View File

View File

@ -0,0 +1,4 @@
<meta charset="UTF-8">
<link href="https://lf6-cdn-tos.bytecdntp.com/cdn/expire-1-y/modern-normalize/1.1.0/modern-normalize.css" type="text/css" rel="stylesheet" />
<script src="https://lf6-cdn-tos.bytecdntp.com/cdn/expire-1-y/vue/3.2.31/vue.global.js" type="application/javascript"></script>
<script src="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-y/axios/0.26.0/axios.js" type="application/javascript"></script>

0
util/__init__.py Normal file
View File

6
util/file.py Normal file
View File

@ -0,0 +1,6 @@
import os
def check_file_exist(file):
if not os.path.isfile(file):
raise FileNotFoundError("文件不存在:%s" % file)

29
util/system.py Normal file
View File

@ -0,0 +1,29 @@
import os
import platform
import subprocess
from typing import Union
def check_exec(name: Union[os.PathLike[str], str]) -> bool:
if is_windows():
check_process = subprocess.Popen([
"where.exe", name
], stdout=subprocess.PIPE)
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"

0
workflow/__init__.py Normal file
View File

61
workflow/danmaku.py Normal file
View File

@ -0,0 +1,61 @@
import datetime
import os
import argparse
import subprocess
from hashlib import md5
from typing import Union
from bs4 import BeautifulSoup
from config import DANMAKU_FACTORY_EXEC, VIDEO_RESOLUTION, DANMAKU_SPEED, DEFAULT_FONT_NAME
from exception.danmaku import NoDanmakuException, DanmakuFormatErrorException
from util.file import check_file_exist
def get_file_start(file: Union[os.PathLike[str], str]) -> float:
with open(file, "r", encoding="utf-8") as f:
soup = BeautifulSoup("".join(f.readlines()), "lxml")
danmaku_item = soup.find("d")
if danmaku_item is None:
# 没有弹幕?
raise NoDanmakuException()
danmaku_info = danmaku_item["p"]
split_info = danmaku_info.split(",")
if len(split_info) < 5:
raise DanmakuFormatErrorException()
bias_sec = float(split_info[0])
bias_ts_ms = int(split_info[4])
return bias_ts_ms / 1000 - bias_sec
def diff_danmaku_files(base_file: Union[os.PathLike[str], str], file: Union[os.PathLike[str], str]) -> float:
return get_file_start(file) - get_file_start(base_file)
def danmaku_to_subtitle(file: Union[os.PathLike[str], str], time_shift: float):
new_subtitle_name = md5(file).digest().hex() + ".ass"
process = subprocess.Popen((
DANMAKU_FACTORY_EXEC,
"-r", str(VIDEO_RESOLUTION), "-s", str(DANMAKU_SPEED), "-f", "5",
"-S", "40", "-N", str(DEFAULT_FONT_NAME), "--showmsgbox", "FALSE",
"-O", "255", "-L", "1", "-D", "0",
"-o", "ass", new_subtitle_name, "-i", file, "-t", str(time_shift)
))
process.wait()
return new_subtitle_name
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument("base", help="以此为标准")
parser.add_argument("file", nargs="+", help="需要对齐的文件")
args = parser.parse_args()
check_file_exist(args.base)
base_start_ts = get_file_start(args.base)
base_start = datetime.datetime.fromtimestamp(base_start_ts)
print("[+] 基准文件[{:s}],开始时间:{:.3f},时间:{}".format(args.base, base_start_ts, base_start))
for _file in args.file:
check_file_exist(_file)
file_start_ts = get_file_start(_file)
diff_sec = file_start_ts - base_start_ts
print("[+] 待调整文件[{:s}],开始时间:{:.3f},偏差:{:.3f}".format(_file, file_start_ts, diff_sec))

13
workflow/queues.py Normal file
View File

@ -0,0 +1,13 @@
from multiprocessing import Queue
from dto.DanmakuJobItem import DanmakuJobItem
from dto.EncodeJobItem import EncodeJobItem
from dto.SplitJobItem import SplitJobItem
from dto.TypeJobResult import TypeJobResult
from entity.WorkflowItem import WorkflowItem
JOB_EVENT_QUEUE: "Queue[TypeJobResult]" = Queue()
JOB_QUEUE: "Queue[WorkflowItem]" = Queue()
DANMAKU_PROCESSING_QUEUE: "Queue[DanmakuJobItem]" = Queue()
VIDEO_ENCODING_QUEUE: "Queue[EncodeJobItem]" = Queue()
VIDEO_SPLITING_QUEUE: "Queue[SplitJobItem]" = Queue()

16
workflow/video.py Normal file
View File

@ -0,0 +1,16 @@
import re
import subprocess
def get_video_real_duration(filename):
ffmpeg_process = subprocess.Popen([
"ffmpeg", "-hide_banner", "-i", filename, "-c", "copy", "-f", "null", "-"
], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
result = "0:0:0.0"
for line in ffmpeg_process.stderr.readlines():
match_result = re.findall("(?<= time=).+?(?= )", line.decode())
if len(match_result) == 0:
continue
result = match_result.pop()
return result