初次提交
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