videoconverter-worker 1.0.0__py3-none-any.whl → 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ffmpeg_runner.py +25 -0
- schema.py +101 -0
- task_queue.py +58 -3
- {videoconverter_worker-1.0.0.dist-info → videoconverter_worker-1.0.1.dist-info}/METADATA +30 -13
- videoconverter_worker-1.0.1.dist-info/RECORD +10 -0
- {videoconverter_worker-1.0.0.dist-info → videoconverter_worker-1.0.1.dist-info}/top_level.txt +1 -0
- worker.py +228 -5
- videoconverter_worker-1.0.0.dist-info/RECORD +0 -9
- {videoconverter_worker-1.0.0.dist-info → videoconverter_worker-1.0.1.dist-info}/WHEEL +0 -0
- {videoconverter_worker-1.0.0.dist-info → videoconverter_worker-1.0.1.dist-info}/entry_points.txt +0 -0
ffmpeg_runner.py
CHANGED
|
@@ -51,6 +51,31 @@ def get_duration(video_path: str) -> float:
|
|
|
51
51
|
return float(line)
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
def get_video_size(video_path: str) -> Tuple[int, int]:
|
|
55
|
+
"""返回 (width, height),用于去字幕滤镜。"""
|
|
56
|
+
cmd = [
|
|
57
|
+
_find_ffprobe(),
|
|
58
|
+
"-v", "error",
|
|
59
|
+
"-select_streams", "v:0",
|
|
60
|
+
"-show_entries", "stream=width,height",
|
|
61
|
+
"-of", "csv=p=0",
|
|
62
|
+
video_path,
|
|
63
|
+
]
|
|
64
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
65
|
+
if r.returncode != 0:
|
|
66
|
+
return 1920, 1080
|
|
67
|
+
line = (r.stdout or "").strip()
|
|
68
|
+
if not line:
|
|
69
|
+
return 1920, 1080
|
|
70
|
+
parts = line.split(",")
|
|
71
|
+
if len(parts) >= 2:
|
|
72
|
+
try:
|
|
73
|
+
return int(parts[0]), int(parts[1])
|
|
74
|
+
except ValueError:
|
|
75
|
+
pass
|
|
76
|
+
return 1920, 1080
|
|
77
|
+
|
|
78
|
+
|
|
54
79
|
def split_chunk(video_path: str, start_time: float, duration: float, output_path: str) -> None:
|
|
55
80
|
cmd = [
|
|
56
81
|
_find_ffmpeg(),
|
schema.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""任务队列输入格式说明,供对接方参考。可通过 videoconverter --show-schema 查看。"""
|
|
3
|
+
|
|
4
|
+
INPUT_SCHEMA = r"""# 任务队列输入格式(如何「喂数据」给 Worker)
|
|
5
|
+
|
|
6
|
+
## 目录结构
|
|
7
|
+
|
|
8
|
+
- 数据目录(默认 ~/.videoconverter/data,可用 --data-dir 指定)
|
|
9
|
+
- queue/ 必选,存放任务
|
|
10
|
+
- <task_id>.json 每个任务一个文件,文件名=task_id.json
|
|
11
|
+
- <task_id>.lock 运行时抢占锁,勿手动编辑
|
|
12
|
+
- queue_config.json 可选,队列暂停等配置
|
|
13
|
+
|
|
14
|
+
## 单任务文件 queue/<task_id>.json
|
|
15
|
+
|
|
16
|
+
根级字段:
|
|
17
|
+
task_id string 必填,与文件名一致(如 a1b2c3d4-e5f6-7890-abcd-ef1234567890.json)
|
|
18
|
+
task_type string 必填,取值: SPLIT | DESUBTITLE | MERGE | ONE_CLICK_COMPOSE
|
|
19
|
+
parent_task_id string 可选
|
|
20
|
+
video_id string 可选,同一视频/一键合成流程 ID(MERGE 必填)
|
|
21
|
+
input_file string 必填(MERGE 可为空串),输入视频或 chunk 的绝对路径
|
|
22
|
+
output_dir string 必填,输出目录(绝对路径)
|
|
23
|
+
config object 必填,见下
|
|
24
|
+
status string 必填,新建任务填 PENDING
|
|
25
|
+
progress number 默认 0
|
|
26
|
+
progress_text string 可选
|
|
27
|
+
created_time number 必填,毫秒时间戳
|
|
28
|
+
error_message string 可选
|
|
29
|
+
|
|
30
|
+
任务类型说明:
|
|
31
|
+
SPLIT 输入整段视频,按 config.startTime/endTime 切分为 chunk,写入 output_dir 并生成 metadata.json;Worker 会自动创建各 chunk 的 DESUBTITLE 任务。
|
|
32
|
+
DESUBTITLE 输入单个 chunk 文件,去字幕后写入 output_dir/<video_id>/xxx_desub.mp4,并更新 metadata;全部完成后 Worker 会自动创建 MERGE 任务。
|
|
33
|
+
MERGE 依赖 output_dir/<video_id>/metadata.json(由 SPLIT 生成、DESUBTITLE 更新),将已处理的 chunk 合成为最终视频。需填 video_id、output_dir,input_file 可空。
|
|
34
|
+
ONE_CLICK_COMPOSE 与 SPLIT 类似:输入整段视频 + startTime/endTime,切分后自动创建所有 DESUBTITLE 任务(不自动建 SPLIT 子任务名,仅建去字幕任务),后续 MERGE 由 Worker 在全部去字幕后自动创建。
|
|
35
|
+
|
|
36
|
+
路径: 建议使用本机绝对路径;若队列从别处拷贝而来,启动时用 --path-replace "原前缀=本机前缀" 做替换。
|
|
37
|
+
|
|
38
|
+
config 对象常用字段:
|
|
39
|
+
targetWidth/targetHeight 默认 1920/1080
|
|
40
|
+
cropTop/cropBottom/cropLeft/cropRight 裁剪(如去字幕 208)
|
|
41
|
+
keepAudio, audioBitrate, videoQuality
|
|
42
|
+
startTime, endTime 截取时间(秒)
|
|
43
|
+
inputPath, outputPath Worker 会按需填充
|
|
44
|
+
|
|
45
|
+
## queue_config.json(可选)
|
|
46
|
+
|
|
47
|
+
{ "queue_paused": "true" } 表示暂停,不取新任务;"false" 表示运行。
|
|
48
|
+
|
|
49
|
+
## 示例:新建一个去字幕任务
|
|
50
|
+
|
|
51
|
+
在数据目录下创建 queue/<uuid>.json,例如:
|
|
52
|
+
|
|
53
|
+
{
|
|
54
|
+
"task_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
55
|
+
"task_type": "DESUBTITLE",
|
|
56
|
+
"video_id": "my-video-id",
|
|
57
|
+
"input_file": "/path/to/chunk_001.mp4",
|
|
58
|
+
"output_dir": "/path/to/output",
|
|
59
|
+
"config": {
|
|
60
|
+
"targetWidth": 1920,
|
|
61
|
+
"targetHeight": 1080,
|
|
62
|
+
"cropBottom": 208,
|
|
63
|
+
"cropLeft": 293,
|
|
64
|
+
"cropRight": 293,
|
|
65
|
+
"keepAudio": true,
|
|
66
|
+
"videoQuality": 23
|
|
67
|
+
},
|
|
68
|
+
"status": "PENDING",
|
|
69
|
+
"progress": 0,
|
|
70
|
+
"created_time": 1738828800000
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
然后启动: videoconverter --data-dir /path/to/data
|
|
74
|
+
|
|
75
|
+
Worker 会按 created_time 顺序抢占 status=PENDING 的任务并执行。
|
|
76
|
+
|
|
77
|
+
## 示例:切分+去字幕流程(SPLIT 或 ONE_CLICK_COMPOSE)
|
|
78
|
+
|
|
79
|
+
新建一个 SPLIT 任务(或 ONE_CLICK_COMPOSE),Worker 会先切分再自动创建各 chunk 的去字幕任务,全部去字幕后自动创建 MERGE 并合成:
|
|
80
|
+
|
|
81
|
+
{
|
|
82
|
+
"task_id": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
|
|
83
|
+
"task_type": "SPLIT",
|
|
84
|
+
"input_file": "/path/to/full_video.mp4",
|
|
85
|
+
"output_dir": "/path/to/output",
|
|
86
|
+
"config": {
|
|
87
|
+
"startTime": 0,
|
|
88
|
+
"endTime": 3600,
|
|
89
|
+
"targetWidth": 1920,
|
|
90
|
+
"targetHeight": 1080,
|
|
91
|
+
"cropBottom": 208,
|
|
92
|
+
"cropLeft": 293,
|
|
93
|
+
"cropRight": 293
|
|
94
|
+
},
|
|
95
|
+
"status": "PENDING",
|
|
96
|
+
"progress": 0,
|
|
97
|
+
"created_time": 1738828800000
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
注意: MERGE 任务一般由 Worker 在「该 video_id 下所有 DESUBTITLE 完成」后自动创建,无需手写;若需手写 MERGE,则 video_id、output_dir 必填,且 output_dir/<video_id>/metadata.json 已存在且所有 chunk 已标记为已处理。
|
|
101
|
+
"""
|
task_queue.py
CHANGED
|
@@ -39,6 +39,7 @@ class QueueStore:
|
|
|
39
39
|
self.queue_dir = self.data_dir / QUEUE_DIR_NAME
|
|
40
40
|
self.config_file = self.data_dir / CONFIG_FILE_NAME
|
|
41
41
|
self.path_replace = path_replace # (old, new)
|
|
42
|
+
self._task_cache: Dict[Path, tuple] = {} # path -> (mtime, status, created_time),减少重复读盘
|
|
42
43
|
self._ensure_dirs()
|
|
43
44
|
|
|
44
45
|
def _ensure_dirs(self) -> None:
|
|
@@ -92,17 +93,57 @@ class QueueStore:
|
|
|
92
93
|
return []
|
|
93
94
|
return sorted(self.queue_dir.glob("*.json"), key=lambda p: p.name)
|
|
94
95
|
|
|
96
|
+
def recover_orphaned_processing(self) -> int:
|
|
97
|
+
"""断电/强制关机后:将无 .lock 的 PROCESSING 任务重置为 PENDING,便于重启后继续。返回恢复数量。"""
|
|
98
|
+
count = 0
|
|
99
|
+
for p in self._list_task_files():
|
|
100
|
+
task_id = p.stem
|
|
101
|
+
lock_path = self._lock_file(task_id)
|
|
102
|
+
if lock_path.exists():
|
|
103
|
+
continue
|
|
104
|
+
try:
|
|
105
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
106
|
+
data = json.load(f)
|
|
107
|
+
if data.get("status") != "PROCESSING":
|
|
108
|
+
continue
|
|
109
|
+
data["status"] = "PENDING"
|
|
110
|
+
data["progress"] = 0.0
|
|
111
|
+
data["progress_text"] = "等待重试(已恢复)"
|
|
112
|
+
if "start_time" in data:
|
|
113
|
+
del data["start_time"]
|
|
114
|
+
tmp = p.with_suffix(p.suffix + ".tmp")
|
|
115
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
116
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
117
|
+
tmp.replace(p)
|
|
118
|
+
self._task_cache.pop(p, None)
|
|
119
|
+
count += 1
|
|
120
|
+
logger.info("恢复孤儿任务为 PENDING: %s", task_id)
|
|
121
|
+
except Exception as e:
|
|
122
|
+
logger.warning("恢复任务失败 %s: %s", task_id, e)
|
|
123
|
+
return count
|
|
124
|
+
|
|
95
125
|
def acquire_pending_task(self) -> Optional[Dict[str, Any]]:
|
|
96
|
-
"""原子抢占一个 PENDING 任务,返回任务 dict 或 None。"""
|
|
126
|
+
"""原子抢占一个 PENDING 任务,返回任务 dict 或 None。用缓存避免每轮读取全部 JSON。"""
|
|
97
127
|
files = self._list_task_files()
|
|
98
128
|
pending_with_time = []
|
|
99
129
|
for p in files:
|
|
100
130
|
try:
|
|
131
|
+
mtime = p.stat().st_mtime
|
|
132
|
+
cached = self._task_cache.get(p)
|
|
133
|
+
if cached is not None and cached[0] == mtime:
|
|
134
|
+
_mtime, status, created_time = cached
|
|
135
|
+
if status != "PENDING":
|
|
136
|
+
continue
|
|
137
|
+
pending_with_time.append((created_time, p))
|
|
138
|
+
continue
|
|
101
139
|
with open(p, "r", encoding="utf-8") as f:
|
|
102
140
|
data = json.load(f)
|
|
103
|
-
|
|
141
|
+
status = data.get("status")
|
|
142
|
+
created_time = data.get("created_time", 0)
|
|
143
|
+
self._task_cache[p] = (mtime, status, created_time)
|
|
144
|
+
if status != "PENDING":
|
|
104
145
|
continue
|
|
105
|
-
pending_with_time.append((
|
|
146
|
+
pending_with_time.append((created_time, p))
|
|
106
147
|
except Exception as e:
|
|
107
148
|
logger.warning("读取任务文件失败 %s: %s", p.name, e)
|
|
108
149
|
pending_with_time.sort(key=lambda x: x[0])
|
|
@@ -133,6 +174,8 @@ class QueueStore:
|
|
|
133
174
|
raise
|
|
134
175
|
finally:
|
|
135
176
|
lock_path.unlink(missing_ok=True)
|
|
177
|
+
if len(self._task_cache) > 50000:
|
|
178
|
+
self._task_cache.clear()
|
|
136
179
|
return None
|
|
137
180
|
|
|
138
181
|
def _apply_paths_to_task(self, task: dict) -> None:
|
|
@@ -184,6 +227,18 @@ class QueueStore:
|
|
|
184
227
|
with open(log_path, "a", encoding="utf-8") as f:
|
|
185
228
|
f.write(line)
|
|
186
229
|
|
|
230
|
+
def has_merge_task_for_video_id(self, video_id: str) -> bool:
|
|
231
|
+
"""是否存在该 video_id 的 MERGE 任务,遇第一个即返回,避免全量 get_tasks_by_video_id。"""
|
|
232
|
+
for p in self._list_task_files():
|
|
233
|
+
try:
|
|
234
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
235
|
+
d = json.load(f)
|
|
236
|
+
if d.get("video_id") == video_id and d.get("task_type") == "MERGE":
|
|
237
|
+
return True
|
|
238
|
+
except Exception:
|
|
239
|
+
pass
|
|
240
|
+
return False
|
|
241
|
+
|
|
187
242
|
def get_tasks_by_video_id(self, video_id: str) -> List[Dict[str, Any]]:
|
|
188
243
|
out = []
|
|
189
244
|
for p in self._list_task_files():
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: videoconverter-worker
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.1
|
|
4
4
|
Summary: VideoConverter Python Worker:从 queue 目录读取任务并执行切分/去字幕/合成
|
|
5
5
|
License: MIT
|
|
6
6
|
Keywords: videoconverter,ffmpeg,worker,video
|
|
@@ -19,21 +19,32 @@ Python Worker 使用说明
|
|
|
19
19
|
|
|
20
20
|
0) 安装(任选其一)
|
|
21
21
|
- 从 PyPI 安装(推荐,Windows/Linux/macOS 通用):
|
|
22
|
-
pip install videoconverter
|
|
23
|
-
|
|
22
|
+
pip install videoconverter-worker
|
|
23
|
+
安装后运行: videoconverter 或 videoconverter-worker [--data-dir DIR] [--path-replace OLD=NEW]
|
|
24
24
|
- 或从本地源码安装: cd src/python && pip install .
|
|
25
25
|
|
|
26
|
-
1)
|
|
26
|
+
1) 简易模式(当前目录 + 开始/结束时间 + 字幕高度,便于测试)
|
|
27
|
+
不经过队列,直接在指定目录内对单个文件或批量视频做「切分→去字幕→合成」:
|
|
28
|
+
- 单文件:
|
|
29
|
+
videoconverter --simple --dir . --start 0 --end 120 --crop-bottom 208 --file 视频.mp4
|
|
30
|
+
- 批量(当前目录下所有 .mp4/.mkv/.mov 等):
|
|
31
|
+
videoconverter --simple --dir . --start 0 --end 60 --crop-bottom 208 --batch
|
|
32
|
+
- 参数:--dir 工作目录(默认当前目录),--start/--end 秒(0 表示到结尾),
|
|
33
|
+
--crop-bottom 字幕高度/裁底像素(默认 208),--crop-left/--crop-right 默认 293,
|
|
34
|
+
--output 输出目录(默认 <dir>/videoconverter_out)。
|
|
35
|
+
输出在 <dir>/videoconverter_out 或 --output 指定目录下,合成文件为 <video_id>_merged.mp4。
|
|
36
|
+
|
|
37
|
+
2) 作用(队列模式)
|
|
27
38
|
与 Java BackendWorker 行为一致:从 queue 目录读取任务(queue/<task_id>.json),
|
|
28
39
|
执行 SPLIT / DESUBTITLE / MERGE / ONE_CLICK_COMPOSE,更新状态与 metadata.json。
|
|
29
40
|
适合在服务器上运行,利用多核与高性能做切分、去字幕、合成。
|
|
30
41
|
|
|
31
|
-
|
|
42
|
+
3) 环境
|
|
32
43
|
- Python 3.8+
|
|
33
44
|
- 系统已安装 ffmpeg、ffprobe(或在 PATH 中)
|
|
34
45
|
- 可选:环境变量 FFMPEG_PATH、FFPROBE_PATH 指定路径
|
|
35
46
|
|
|
36
|
-
|
|
47
|
+
4) 本机与 Java 共用同一队列(同一台机器)
|
|
37
48
|
- 数据目录一致即可,例如: ~/.videoconverter/data
|
|
38
49
|
- 先由 Java 前端创建任务(写 queue/*.json),再在本机运行 Python worker 处理:
|
|
39
50
|
cd src/python
|
|
@@ -41,7 +52,7 @@ Python Worker 使用说明
|
|
|
41
52
|
- 或指定数据目录:
|
|
42
53
|
python worker.py --data-dir /path/to/.videoconverter/data
|
|
43
54
|
|
|
44
|
-
|
|
55
|
+
5) 把「前端处理过的文件夹」拷到服务器后运行
|
|
45
56
|
- 将本机用于队列的「数据目录」整个拷到服务器(例如 /server/data),
|
|
46
57
|
其中应包含 queue/ 目录及 queue/*.json 任务文件。
|
|
47
58
|
- 若任务 JSON 里的路径是本机绝对路径(如 /Users/me/videos/a.mp4),
|
|
@@ -54,22 +65,28 @@ Python Worker 使用说明
|
|
|
54
65
|
- 建议:在服务器上把视频放在固定目录(如 /server/videos),
|
|
55
66
|
拷过去的 data 里 queue 的 JSON 中路径统一用本机前缀,用 --path-replace 换成服务器前缀。
|
|
56
67
|
|
|
57
|
-
|
|
68
|
+
6) 多进程并发
|
|
58
69
|
当前 worker 为单进程单线程循环。要跑满多核,可在同一 data-dir 下启动多个进程:
|
|
59
70
|
- 任务抢占通过 queue/<task_id>.lock 原子创建,多进程不会抢到同一任务。
|
|
60
71
|
- 示例(4 个 worker 进程):
|
|
61
72
|
for i in 1 2 3 4; do python worker.py --data-dir /server/data & done
|
|
62
73
|
或使用 systemd/supervisor 起多个 worker 实例。
|
|
63
74
|
|
|
64
|
-
|
|
75
|
+
7) 暂停/继续
|
|
65
76
|
队列暂停由 queue_config.json 的 queue_paused 控制("true" 为暂停)。
|
|
66
77
|
Python worker 会定期读该配置,为 true 时不取新任务。可由 Java 前端或手动改该文件控制。
|
|
67
78
|
|
|
68
|
-
|
|
69
|
-
-
|
|
79
|
+
8) 对方如何提供数据(喂数据)
|
|
80
|
+
- 运行以下命令查看完整任务/队列格式说明:
|
|
81
|
+
videoconverter --show-schema
|
|
82
|
+
- 按说明在数据目录下创建 queue/<task_id>.json(status=PENDING),
|
|
83
|
+
Worker 会按 created_time 顺序抢占并执行。
|
|
84
|
+
|
|
85
|
+
9) 与 Java 的约定
|
|
86
|
+
- 任务与 config 格式详见 --show-schema 或项目根目录 queue_task_schema.txt。
|
|
70
87
|
- metadata.json 与 Java 生成的格式一致,合成(MERGE)以 metadata 为准。
|
|
71
88
|
|
|
72
|
-
|
|
89
|
+
10) 打包与部署
|
|
73
90
|
- 打 zip 包(拷贝到服务器解压即用):
|
|
74
91
|
cd src/python
|
|
75
92
|
./build_deploy.sh
|
|
@@ -78,4 +95,4 @@ Python Worker 使用说明
|
|
|
78
95
|
- 或安装为命令行(本机/服务器均可):
|
|
79
96
|
cd src/python
|
|
80
97
|
pip install .
|
|
81
|
-
然后可直接运行: videoconverter [--data-dir DIR] [--path-replace OLD=NEW]
|
|
98
|
+
然后可直接运行: videoconverter 或 videoconverter-worker [--data-dir DIR] [--path-replace OLD=NEW]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
ffmpeg_runner.py,sha256=d3nM7GekNj3iMcOUSIUosrGKFSt8D2e04zgzvy-8iAY,10545
|
|
2
|
+
metadata.py,sha256=PPigPLAZIZ_vI6zeQgY6EQ4uuZskJEXvE-md7nNI7G4,2881
|
|
3
|
+
schema.py,sha256=3ILdGl5qSQOSvKfiKWcnaxdyHwV4rDvGnRZIparVp3o,4361
|
|
4
|
+
task_queue.py,sha256=sYQelPRuTbP9g_sPs69xOR3n5SIxGBFxY4EI5ueQrQs,11822
|
|
5
|
+
worker.py,sha256=BmAr0uO_g7HadeFRJfxCz15GvdiVoyzDMBBckAmhNu0,17877
|
|
6
|
+
videoconverter_worker-1.0.1.dist-info/METADATA,sha256=BcBvOZyiD5T0-wEE7qHGn3ovugacp-TFE1mzQ8QrQzI,5139
|
|
7
|
+
videoconverter_worker-1.0.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
8
|
+
videoconverter_worker-1.0.1.dist-info/entry_points.txt,sha256=qedJjjix02n9Hz8adBioDIpGHghm8S3fQZdFwM5LV1A,83
|
|
9
|
+
videoconverter_worker-1.0.1.dist-info/top_level.txt,sha256=iamWyiqUZ4X0_2UZx6GEk9gsPmiI9qhse_15HqtzUj8,48
|
|
10
|
+
videoconverter_worker-1.0.1.dist-info/RECORD,,
|
worker.py
CHANGED
|
@@ -18,6 +18,7 @@ from ffmpeg_runner import (
|
|
|
18
18
|
run_desubtitle,
|
|
19
19
|
merge_chunks,
|
|
20
20
|
get_duration,
|
|
21
|
+
get_video_size,
|
|
21
22
|
)
|
|
22
23
|
|
|
23
24
|
logging.basicConfig(
|
|
@@ -102,6 +103,30 @@ def process_desubtitle_task(store: QueueStore, task: dict) -> None:
|
|
|
102
103
|
logger.warning("更新 metadata 或检查合成失败: videoId=%s, chunkId=%s, %s", video_id, chunk_id, e)
|
|
103
104
|
|
|
104
105
|
|
|
106
|
+
def _merge_creation_lock(store: QueueStore, video_id: str):
|
|
107
|
+
"""对同一 video_id 串行化「检查并创建 MERGE」,避免多进程同时建两个 MERGE。"""
|
|
108
|
+
from contextlib import contextmanager
|
|
109
|
+
safe_id = (video_id or "").replace("/", "_").replace("\\", "_")[:64]
|
|
110
|
+
lock_path = store.queue_dir / f".merge_{safe_id}.lock"
|
|
111
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
|
|
113
|
+
@contextmanager
|
|
114
|
+
def _held():
|
|
115
|
+
try:
|
|
116
|
+
import fcntl
|
|
117
|
+
f = open(lock_path, "w")
|
|
118
|
+
try:
|
|
119
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
|
120
|
+
yield
|
|
121
|
+
finally:
|
|
122
|
+
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
|
123
|
+
f.close()
|
|
124
|
+
except ImportError:
|
|
125
|
+
yield # Windows 无 fcntl,接受可能的重复 MERGE
|
|
126
|
+
|
|
127
|
+
return _held()
|
|
128
|
+
|
|
129
|
+
|
|
105
130
|
def check_and_create_merge_task(store: QueueStore, video_id: str, output_dir: str, config: dict) -> None:
|
|
106
131
|
metadata_path = Path(output_dir) / video_id / "metadata.json"
|
|
107
132
|
if not metadata_path.exists():
|
|
@@ -111,10 +136,11 @@ def check_and_create_merge_task(store: QueueStore, video_id: str, output_dir: st
|
|
|
111
136
|
total = len(chunks)
|
|
112
137
|
processed = get_processed_chunks(data)
|
|
113
138
|
pending = get_pending_chunks(data)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
139
|
+
if total == 0 or len(processed) != total or pending:
|
|
140
|
+
return
|
|
141
|
+
with _merge_creation_lock(store, video_id):
|
|
142
|
+
if store.has_merge_task_for_video_id(video_id):
|
|
143
|
+
return
|
|
118
144
|
store.create_merge_task(video_id, output_dir, config)
|
|
119
145
|
logger.info("自动创建合成任务: videoId=%s, 已处理 %d/%d 块", video_id, len(processed), total)
|
|
120
146
|
|
|
@@ -218,8 +244,96 @@ def work_loop(store: QueueStore) -> None:
|
|
|
218
244
|
time.sleep(5)
|
|
219
245
|
|
|
220
246
|
|
|
247
|
+
def run_simple_compose(
|
|
248
|
+
input_file: str,
|
|
249
|
+
output_dir: str,
|
|
250
|
+
start_sec: float,
|
|
251
|
+
end_sec: float,
|
|
252
|
+
crop_bottom: int = 208,
|
|
253
|
+
crop_left: int = 293,
|
|
254
|
+
crop_right: int = 293,
|
|
255
|
+
) -> str:
|
|
256
|
+
"""
|
|
257
|
+
简易模式:在当前目录/指定目录内,对单个视频做「切分→去字幕→合成」全流程,不经过队列。
|
|
258
|
+
返回最终合成文件路径;异常时抛出。
|
|
259
|
+
"""
|
|
260
|
+
input_path = Path(input_file)
|
|
261
|
+
if not input_path.exists():
|
|
262
|
+
raise FileNotFoundError(f"文件不存在: {input_file}")
|
|
263
|
+
if end_sec <= 0:
|
|
264
|
+
end_sec = get_duration(input_file)
|
|
265
|
+
start_sec = max(0.0, start_sec)
|
|
266
|
+
if start_sec >= end_sec:
|
|
267
|
+
raise ValueError("开始时间必须小于结束时间")
|
|
268
|
+
|
|
269
|
+
width, height = get_video_size(input_file)
|
|
270
|
+
config = {
|
|
271
|
+
"targetWidth": 1920,
|
|
272
|
+
"targetHeight": 1080,
|
|
273
|
+
"cropTop": 0,
|
|
274
|
+
"cropBottom": crop_bottom,
|
|
275
|
+
"cropLeft": crop_left,
|
|
276
|
+
"cropRight": crop_right,
|
|
277
|
+
"keepAudio": True,
|
|
278
|
+
"audioBitrate": 192,
|
|
279
|
+
"videoQuality": 23,
|
|
280
|
+
"originalWidth": width,
|
|
281
|
+
"originalHeight": height,
|
|
282
|
+
"startTime": start_sec,
|
|
283
|
+
"endTime": end_sec,
|
|
284
|
+
"useHardwareAccel": False,
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
logger.info("简易模式: 切分 %s (%.0f - %.0f秒), 字幕高度(裁底)=%d", input_path.name, start_sec, end_sec, crop_bottom)
|
|
288
|
+
metadata, video_id = split_video_to_chunks(input_file, output_dir, 120.0, start_sec, end_sec)
|
|
289
|
+
chunk_list = [c for c in (metadata.get("chunks") or []) if c.get("originalPath")]
|
|
290
|
+
logger.info("切分完成: %d 块,开始去字幕", len(chunk_list))
|
|
291
|
+
|
|
292
|
+
for ch in chunk_list:
|
|
293
|
+
rel = ch.get("originalPath", "")
|
|
294
|
+
if not rel:
|
|
295
|
+
continue
|
|
296
|
+
chunk_path = Path(output_dir) / rel
|
|
297
|
+
if not chunk_path.exists():
|
|
298
|
+
continue
|
|
299
|
+
chunk_id = ch.get("chunkId", "")
|
|
300
|
+
out_dir_v = Path(output_dir) / video_id
|
|
301
|
+
out_dir_v.mkdir(parents=True, exist_ok=True)
|
|
302
|
+
output_file = out_dir_v / (Path(chunk_path).stem + "_desub.mp4")
|
|
303
|
+
cfg = dict(config)
|
|
304
|
+
cfg["inputPath"] = str(chunk_path)
|
|
305
|
+
cfg["outputPath"] = str(output_file)
|
|
306
|
+
cfg["startTime"] = 0
|
|
307
|
+
cfg["endTime"] = 0
|
|
308
|
+
cfg["forceKeyframeAtStart"] = True
|
|
309
|
+
run_desubtitle(cfg, str(chunk_path), str(output_file))
|
|
310
|
+
meta_path = Path(output_dir) / video_id / "metadata.json"
|
|
311
|
+
if meta_path.exists():
|
|
312
|
+
update_chunk_processed(str(meta_path), chunk_id, str(output_file))
|
|
313
|
+
|
|
314
|
+
data = load_metadata(str(Path(output_dir) / video_id / "metadata.json"))
|
|
315
|
+
processed = get_processed_chunks(data)
|
|
316
|
+
if not processed:
|
|
317
|
+
raise RuntimeError("没有已处理的 chunk")
|
|
318
|
+
start_t = processed[0]["startTime"]
|
|
319
|
+
end_t = processed[-1]["endTime"]
|
|
320
|
+
out_file = Path(output_dir) / f"{video_id}_merged.mp4"
|
|
321
|
+
merge_chunks(data, start_t, end_t, str(out_file))
|
|
322
|
+
logger.info("简易模式完成: %s", out_file)
|
|
323
|
+
return str(out_file)
|
|
324
|
+
|
|
325
|
+
|
|
221
326
|
def main() -> int:
|
|
222
|
-
parser = argparse.ArgumentParser(
|
|
327
|
+
parser = argparse.ArgumentParser(
|
|
328
|
+
description="VideoConverter Python Worker:从队列执行任务,或简易模式单文件/批量处理。",
|
|
329
|
+
epilog="""
|
|
330
|
+
简易模式示例(不经过队列,指定开始/结束时间、字幕高度,便于测试):
|
|
331
|
+
单文件: videoconverter --simple --dir . --start 0 --end 120 --crop-bottom 208 --file 视频.mp4
|
|
332
|
+
批量: videoconverter --simple --dir . --start 0 --end 60 --crop-bottom 208 --batch
|
|
333
|
+
参数: --dir 工作目录 --start/--end 秒(0=到结尾) --crop-bottom 字幕高度 --output 输出目录
|
|
334
|
+
""",
|
|
335
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
336
|
+
)
|
|
223
337
|
parser.add_argument(
|
|
224
338
|
"--data-dir",
|
|
225
339
|
default=None,
|
|
@@ -236,8 +350,112 @@ def main() -> int:
|
|
|
236
350
|
default=1,
|
|
237
351
|
help="并发 worker 数(默认 1,多进程时由外部起多个 worker 进程)",
|
|
238
352
|
)
|
|
353
|
+
parser.add_argument(
|
|
354
|
+
"--show-schema",
|
|
355
|
+
action="store_true",
|
|
356
|
+
help="打印任务/队列输入格式说明(对接方如何喂数据)",
|
|
357
|
+
)
|
|
358
|
+
parser.add_argument(
|
|
359
|
+
"--simple",
|
|
360
|
+
action="store_true",
|
|
361
|
+
help="简易模式:指定目录内输入开始/结束时间、字幕高度,单文件或批量处理(便于测试)",
|
|
362
|
+
)
|
|
363
|
+
parser.add_argument(
|
|
364
|
+
"--dir",
|
|
365
|
+
default=".",
|
|
366
|
+
help="简易模式:工作目录,默认当前目录",
|
|
367
|
+
)
|
|
368
|
+
parser.add_argument(
|
|
369
|
+
"--start",
|
|
370
|
+
type=float,
|
|
371
|
+
default=0.0,
|
|
372
|
+
help="简易模式:开始时间(秒)",
|
|
373
|
+
)
|
|
374
|
+
parser.add_argument(
|
|
375
|
+
"--end",
|
|
376
|
+
type=float,
|
|
377
|
+
default=0,
|
|
378
|
+
help="简易模式:结束时间(秒),0 表示到视频结尾",
|
|
379
|
+
)
|
|
380
|
+
parser.add_argument(
|
|
381
|
+
"--crop-bottom",
|
|
382
|
+
type=int,
|
|
383
|
+
default=208,
|
|
384
|
+
help="简易模式:字幕高度(裁底像素),默认 208",
|
|
385
|
+
)
|
|
386
|
+
parser.add_argument(
|
|
387
|
+
"--crop-left",
|
|
388
|
+
type=int,
|
|
389
|
+
default=293,
|
|
390
|
+
help="简易模式:左侧裁剪像素,默认 293",
|
|
391
|
+
)
|
|
392
|
+
parser.add_argument(
|
|
393
|
+
"--crop-right",
|
|
394
|
+
type=int,
|
|
395
|
+
default=293,
|
|
396
|
+
help="简易模式:右侧裁剪像素,默认 293",
|
|
397
|
+
)
|
|
398
|
+
parser.add_argument(
|
|
399
|
+
"--file",
|
|
400
|
+
default=None,
|
|
401
|
+
help="简易模式:指定一个视频文件(否则需用 --batch)",
|
|
402
|
+
)
|
|
403
|
+
parser.add_argument(
|
|
404
|
+
"--batch",
|
|
405
|
+
action="store_true",
|
|
406
|
+
help="简易模式:批量处理当前目录下所有视频(.mp4/.mkv/.mov)",
|
|
407
|
+
)
|
|
408
|
+
parser.add_argument(
|
|
409
|
+
"--output",
|
|
410
|
+
default=None,
|
|
411
|
+
help="简易模式:输出目录,默认 <dir>/videoconverter_out",
|
|
412
|
+
)
|
|
239
413
|
args = parser.parse_args()
|
|
240
414
|
|
|
415
|
+
if args.show_schema:
|
|
416
|
+
from schema import INPUT_SCHEMA
|
|
417
|
+
print(INPUT_SCHEMA)
|
|
418
|
+
return 0
|
|
419
|
+
|
|
420
|
+
if args.simple:
|
|
421
|
+
base = Path(args.dir).resolve()
|
|
422
|
+
if not base.is_dir():
|
|
423
|
+
logger.error("目录不存在: %s", base)
|
|
424
|
+
return 1
|
|
425
|
+
out_dir = Path(args.output).resolve() if args.output else (base / "videoconverter_out")
|
|
426
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
427
|
+
if args.file:
|
|
428
|
+
p = Path(args.file)
|
|
429
|
+
files = [p.resolve() if p.is_absolute() else (base / p).resolve()]
|
|
430
|
+
if not files[0].exists():
|
|
431
|
+
logger.error("文件不存在: %s", files[0])
|
|
432
|
+
return 1
|
|
433
|
+
elif args.batch:
|
|
434
|
+
exts = {".mp4", ".mkv", ".mov", ".avi", ".flv"}
|
|
435
|
+
files = sorted([p for p in base.iterdir() if p.is_file() and p.suffix.lower() in exts])
|
|
436
|
+
if not files:
|
|
437
|
+
logger.error("目录下没有视频文件: %s", base)
|
|
438
|
+
return 1
|
|
439
|
+
else:
|
|
440
|
+
logger.error("简易模式请指定 --file <路径> 或 --batch")
|
|
441
|
+
return 1
|
|
442
|
+
failed = 0
|
|
443
|
+
for f in files:
|
|
444
|
+
try:
|
|
445
|
+
run_simple_compose(
|
|
446
|
+
str(f),
|
|
447
|
+
str(out_dir),
|
|
448
|
+
args.start,
|
|
449
|
+
args.end,
|
|
450
|
+
crop_bottom=args.crop_bottom,
|
|
451
|
+
crop_left=args.crop_left,
|
|
452
|
+
crop_right=args.crop_right,
|
|
453
|
+
)
|
|
454
|
+
except Exception as e:
|
|
455
|
+
logger.exception("处理失败 %s: %s", f.name, e)
|
|
456
|
+
failed += 1
|
|
457
|
+
return 1 if failed else 0
|
|
458
|
+
|
|
241
459
|
data_dir = args.data_dir or __import__("os").environ.get(
|
|
242
460
|
"VIDEOCONVERTER_DATA_DIR",
|
|
243
461
|
str(Path.home() / ".videoconverter" / "data"),
|
|
@@ -258,6 +476,11 @@ def main() -> int:
|
|
|
258
476
|
if path_replace:
|
|
259
477
|
logger.info("路径替换: %s -> %s", path_replace[0], path_replace[1])
|
|
260
478
|
|
|
479
|
+
# 断电/强制关机后:将无主 PROCESSING 任务重置为 PENDING,便于继续处理
|
|
480
|
+
n = store.recover_orphaned_processing()
|
|
481
|
+
if n > 0:
|
|
482
|
+
logger.info("已恢复 %d 个孤儿任务(PROCESSING -> PENDING)", n)
|
|
483
|
+
|
|
261
484
|
work_loop(store)
|
|
262
485
|
return 0
|
|
263
486
|
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
ffmpeg_runner.py,sha256=k7yhCVfAEfY4CcFxKhIdEtlQDdn435JR32Kk_j-krg4,9851
|
|
2
|
-
metadata.py,sha256=PPigPLAZIZ_vI6zeQgY6EQ4uuZskJEXvE-md7nNI7G4,2881
|
|
3
|
-
task_queue.py,sha256=zzcXhE40UFWF5CtJfh8dwv50dzER519hYfo9xPEu_iw,9224
|
|
4
|
-
worker.py,sha256=tuDSrqtoC_4BqlEkbDm_7MNC0qLWjaO5wpmikq-ejB0,9832
|
|
5
|
-
videoconverter_worker-1.0.0.dist-info/METADATA,sha256=yX61rjdqytOHP_Pm3GIvVcuGHI3lrN2ZnMh8PdvO2A4,3926
|
|
6
|
-
videoconverter_worker-1.0.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
7
|
-
videoconverter_worker-1.0.0.dist-info/entry_points.txt,sha256=qedJjjix02n9Hz8adBioDIpGHghm8S3fQZdFwM5LV1A,83
|
|
8
|
-
videoconverter_worker-1.0.0.dist-info/top_level.txt,sha256=Dp_FI_KBiJQvfLddduLaKINmGfq3ANDn6No5yUVlu84,41
|
|
9
|
-
videoconverter_worker-1.0.0.dist-info/RECORD,,
|
|
File without changes
|
{videoconverter_worker-1.0.0.dist-info → videoconverter_worker-1.0.1.dist-info}/entry_points.txt
RENAMED
|
File without changes
|