初次提交
This commit is contained in:
commit
09b2956573
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
.idea
|
||||
*.flv
|
||||
*.xml
|
||||
*.db
|
||||
*.ini
|
30
app.py
Normal file
30
app.py
Normal 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
105
config.py
Normal 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
|
134
controller/api/bilirecorder_blueprint.py
Normal file
134
controller/api/bilirecorder_blueprint.py
Normal 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())
|
20
controller/api/collector_blueprint.py
Normal file
20
controller/api/collector_blueprint.py
Normal 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(),
|
||||
}
|
||||
})
|
29
controller/api/config_blueprint.py
Normal file
29
controller/api/config_blueprint.py
Normal 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()
|
||||
})
|
32
controller/api/workflow_blueprint.py
Normal file
32
controller/api/workflow_blueprint.py
Normal 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,
|
||||
})
|
8
controller/view/main_blueprint.py
Normal file
8
controller/view/main_blueprint.py
Normal 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
5
entity/DanmakuFile.py
Normal file
@ -0,0 +1,5 @@
|
||||
from entity.File import File
|
||||
|
||||
|
||||
class DanmakuFile(File):
|
||||
...
|
25
entity/File.py
Normal file
25
entity/File.py
Normal 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
18
entity/VideoClip.py
Normal 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
5
entity/VideoPart.py
Normal file
@ -0,0 +1,5 @@
|
||||
from entity.File import File
|
||||
|
||||
|
||||
class VideoPart(File):
|
||||
...
|
118
entity/WorkflowItem.py
Normal file
118
entity/WorkflowItem.py
Normal 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
6
exception/danmaku.py
Normal file
@ -0,0 +1,6 @@
|
||||
class NoDanmakuException(Exception):
|
||||
...
|
||||
|
||||
|
||||
class DanmakuFormatErrorException(Exception):
|
||||
...
|
20
model/DanmakuClip.py
Normal file
20
model/DanmakuClip.py
Normal 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
25
model/Posting.py
Normal 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
29
model/VideoClip.py
Normal 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
29
model/VideoPart.py
Normal 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
46
model/Workflow.py
Normal 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
3
model/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
db = SQLAlchemy()
|
131
templates/index.html
Normal file
131
templates/index.html
Normal 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>
|
0
templates/layout/foot.html
Normal file
0
templates/layout/foot.html
Normal file
4
templates/layout/head.html
Normal file
4
templates/layout/head.html
Normal 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
0
util/__init__.py
Normal file
6
util/file.py
Normal file
6
util/file.py
Normal 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
29
util/system.py
Normal 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
0
workflow/__init__.py
Normal file
61
workflow/danmaku.py
Normal file
61
workflow/danmaku.py
Normal 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
13
workflow/queues.py
Normal 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
16
workflow/video.py
Normal 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
|
||||
|
Loading…
x
Reference in New Issue
Block a user