From 09b2956573f40ca5d81fe96c40496f5539b5ba67 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Fri, 15 Apr 2022 12:26:43 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E6=AC=A1=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 + app.py | 30 +++++ config.py | 105 ++++++++++++++++++ controller/api/bilirecorder_blueprint.py | 134 +++++++++++++++++++++++ controller/api/collector_blueprint.py | 20 ++++ controller/api/config_blueprint.py | 29 +++++ controller/api/workflow_blueprint.py | 32 ++++++ controller/view/main_blueprint.py | 8 ++ entity/DanmakuFile.py | 5 + entity/File.py | 25 +++++ entity/VideoClip.py | 18 +++ entity/VideoPart.py | 5 + entity/WorkflowItem.py | 118 ++++++++++++++++++++ exception/danmaku.py | 6 + model/DanmakuClip.py | 20 ++++ model/Posting.py | 25 +++++ model/VideoClip.py | 29 +++++ model/VideoPart.py | 29 +++++ model/Workflow.py | 46 ++++++++ model/__init__.py | 3 + templates/index.html | 131 ++++++++++++++++++++++ templates/layout/foot.html | 0 templates/layout/head.html | 4 + util/__init__.py | 0 util/file.py | 6 + util/system.py | 29 +++++ workflow/__init__.py | 0 workflow/danmaku.py | 61 +++++++++++ workflow/queues.py | 13 +++ workflow/video.py | 16 +++ 30 files changed, 952 insertions(+) create mode 100644 .gitignore create mode 100644 app.py create mode 100644 config.py create mode 100644 controller/api/bilirecorder_blueprint.py create mode 100644 controller/api/collector_blueprint.py create mode 100644 controller/api/config_blueprint.py create mode 100644 controller/api/workflow_blueprint.py create mode 100644 controller/view/main_blueprint.py create mode 100644 entity/DanmakuFile.py create mode 100644 entity/File.py create mode 100644 entity/VideoClip.py create mode 100644 entity/VideoPart.py create mode 100644 entity/WorkflowItem.py create mode 100644 exception/danmaku.py create mode 100644 model/DanmakuClip.py create mode 100644 model/Posting.py create mode 100644 model/VideoClip.py create mode 100644 model/VideoPart.py create mode 100644 model/Workflow.py create mode 100644 model/__init__.py create mode 100644 templates/index.html create mode 100644 templates/layout/foot.html create mode 100644 templates/layout/head.html create mode 100644 util/__init__.py create mode 100644 util/file.py create mode 100644 util/system.py create mode 100644 workflow/__init__.py create mode 100644 workflow/danmaku.py create mode 100644 workflow/queues.py create mode 100644 workflow/video.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43c39b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea +*.flv +*.xml +*.db +*.ini \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..01113ba --- /dev/null +++ b/app.py @@ -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) diff --git a/config.py b/config.py new file mode 100644 index 0000000..0f05859 --- /dev/null +++ b/config.py @@ -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 \ No newline at end of file diff --git a/controller/api/bilirecorder_blueprint.py b/controller/api/bilirecorder_blueprint.py new file mode 100644 index 0000000..f9d244f --- /dev/null +++ b/controller/api/bilirecorder_blueprint.py @@ -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()) diff --git a/controller/api/collector_blueprint.py b/controller/api/collector_blueprint.py new file mode 100644 index 0000000..5d2a161 --- /dev/null +++ b/controller/api/collector_blueprint.py @@ -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(), + } + }) diff --git a/controller/api/config_blueprint.py b/controller/api/config_blueprint.py new file mode 100644 index 0000000..5fa4303 --- /dev/null +++ b/controller/api/config_blueprint.py @@ -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() + }) diff --git a/controller/api/workflow_blueprint.py b/controller/api/workflow_blueprint.py new file mode 100644 index 0000000..63a2e55 --- /dev/null +++ b/controller/api/workflow_blueprint.py @@ -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("/") +def get_workflow_info(workflow_id): + workflow = Workflow.get(workflow_id) + return jsonify(workflow) + + +@blueprint.put("//done") +def done_editing(workflow_id): + workflow = Workflow.get(workflow_id) + return jsonify(workflow.to_dict()) + + +@blueprint.post("//queue") +def add_to_queue(workflow_id): + # JOB_QUEUE.put(workflow_item) + return jsonify({ + 'id': workflow_id, + }) diff --git a/controller/view/main_blueprint.py b/controller/view/main_blueprint.py new file mode 100644 index 0000000..f560948 --- /dev/null +++ b/controller/view/main_blueprint.py @@ -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") diff --git a/entity/DanmakuFile.py b/entity/DanmakuFile.py new file mode 100644 index 0000000..0b03b9e --- /dev/null +++ b/entity/DanmakuFile.py @@ -0,0 +1,5 @@ +from entity.File import File + + +class DanmakuFile(File): + ... \ No newline at end of file diff --git a/entity/File.py b/entity/File.py new file mode 100644 index 0000000..26aadf7 --- /dev/null +++ b/entity/File.py @@ -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) \ No newline at end of file diff --git a/entity/VideoClip.py b/entity/VideoClip.py new file mode 100644 index 0000000..92e4492 --- /dev/null +++ b/entity/VideoClip.py @@ -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: + ... \ No newline at end of file diff --git a/entity/VideoPart.py b/entity/VideoPart.py new file mode 100644 index 0000000..f8bce88 --- /dev/null +++ b/entity/VideoPart.py @@ -0,0 +1,5 @@ +from entity.File import File + + +class VideoPart(File): + ... \ No newline at end of file diff --git a/entity/WorkflowItem.py b/entity/WorkflowItem.py new file mode 100644 index 0000000..f7ed2c0 --- /dev/null +++ b/entity/WorkflowItem.py @@ -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 diff --git a/exception/danmaku.py b/exception/danmaku.py new file mode 100644 index 0000000..174beab --- /dev/null +++ b/exception/danmaku.py @@ -0,0 +1,6 @@ +class NoDanmakuException(Exception): + ... + + +class DanmakuFormatErrorException(Exception): + ... diff --git a/model/DanmakuClip.py b/model/DanmakuClip.py new file mode 100644 index 0000000..2168c7e --- /dev/null +++ b/model/DanmakuClip.py @@ -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, + } diff --git a/model/Posting.py b/model/Posting.py new file mode 100644 index 0000000..27974e5 --- /dev/null +++ b/model/Posting.py @@ -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()] + } diff --git a/model/VideoClip.py b/model/VideoClip.py new file mode 100644 index 0000000..2983391 --- /dev/null +++ b/model/VideoClip.py @@ -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, + } diff --git a/model/VideoPart.py b/model/VideoPart.py new file mode 100644 index 0000000..e6f9af1 --- /dev/null +++ b/model/VideoPart.py @@ -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, + } diff --git a/model/Workflow.py b/model/Workflow.py new file mode 100644 index 0000000..1d8510e --- /dev/null +++ b/model/Workflow.py @@ -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], + } diff --git a/model/__init__.py b/model/__init__.py new file mode 100644 index 0000000..f0b13d6 --- /dev/null +++ b/model/__init__.py @@ -0,0 +1,3 @@ +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..e958daa --- /dev/null +++ b/templates/index.html @@ -0,0 +1,131 @@ + + + + 首页 + {% include 'layout/head.html' %} + + + +{% raw %} +
+

TEST

+
+

当前状态

+ + + + + + + + + + + + + + + +
系统{{ collector.basic.system.os }}
FFMPEG状态
弹幕工具状态
+

配置状态

+ + + + + + + + + + + + + + + + + + + + +
FFMPEG
命令{{ config.ffmpeg.exec }}
GPU使用
视频比特率{{ config.ffmpeg.bitrate }}
+
+{% endraw %} + + + \ No newline at end of file diff --git a/templates/layout/foot.html b/templates/layout/foot.html new file mode 100644 index 0000000..e69de29 diff --git a/templates/layout/head.html b/templates/layout/head.html new file mode 100644 index 0000000..8a92889 --- /dev/null +++ b/templates/layout/head.html @@ -0,0 +1,4 @@ + + + + diff --git a/util/__init__.py b/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/util/file.py b/util/file.py new file mode 100644 index 0000000..095a7c8 --- /dev/null +++ b/util/file.py @@ -0,0 +1,6 @@ +import os + + +def check_file_exist(file): + if not os.path.isfile(file): + raise FileNotFoundError("文件不存在:%s" % file) diff --git a/util/system.py b/util/system.py new file mode 100644 index 0000000..5c9be07 --- /dev/null +++ b/util/system.py @@ -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" diff --git a/workflow/__init__.py b/workflow/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/workflow/danmaku.py b/workflow/danmaku.py new file mode 100644 index 0000000..0e10bb7 --- /dev/null +++ b/workflow/danmaku.py @@ -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)) diff --git a/workflow/queues.py b/workflow/queues.py new file mode 100644 index 0000000..2ad2159 --- /dev/null +++ b/workflow/queues.py @@ -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() diff --git a/workflow/video.py b/workflow/video.py new file mode 100644 index 0000000..388ead3 --- /dev/null +++ b/workflow/video.py @@ -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 +