videoconverter-worker 1.0.0__py3-none-any.whl → 1.0.2__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 CHANGED
@@ -21,6 +21,35 @@ def _find_ffprobe() -> str:
21
21
  return os.environ.get("FFPROBE_PATH", "ffprobe")
22
22
 
23
23
 
24
+ def check_ffmpeg_available() -> Optional[str]:
25
+ """
26
+ 检测 ffmpeg / ffprobe 是否可用。可用则返回 None,不可用则返回错误说明(含安装提示)。
27
+ """
28
+ missing = []
29
+ for name, getter in [("ffmpeg", _find_ffmpeg), ("ffprobe", _find_ffprobe)]:
30
+ path = getter()
31
+ try:
32
+ r = subprocess.run(
33
+ [path, "-version"],
34
+ capture_output=True,
35
+ text=True,
36
+ timeout=5,
37
+ )
38
+ if r.returncode != 0:
39
+ missing.append(name)
40
+ except (FileNotFoundError, OSError):
41
+ missing.append(name)
42
+ if not missing:
43
+ return None
44
+ return (
45
+ "未检测到 {},请先安装 FFmpeg(本程序不会自动安装系统依赖)。\n"
46
+ " macOS: brew install ffmpeg\n"
47
+ " Ubuntu: sudo apt install ffmpeg\n"
48
+ " Windows: 从 https://ffmpeg.org/download.html 下载或将 ffmpeg/ffprobe 加入 PATH\n"
49
+ " 或设置环境变量: FFMPEG_PATH=... FFPROBE_PATH=..."
50
+ ).format(" / ".join(missing))
51
+
52
+
24
53
  def _format_time(seconds: float) -> str:
25
54
  if seconds < 0:
26
55
  return "0"
@@ -51,6 +80,31 @@ def get_duration(video_path: str) -> float:
51
80
  return float(line)
52
81
 
53
82
 
83
+ def get_video_size(video_path: str) -> Tuple[int, int]:
84
+ """返回 (width, height),用于去字幕滤镜。"""
85
+ cmd = [
86
+ _find_ffprobe(),
87
+ "-v", "error",
88
+ "-select_streams", "v:0",
89
+ "-show_entries", "stream=width,height",
90
+ "-of", "csv=p=0",
91
+ video_path,
92
+ ]
93
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
94
+ if r.returncode != 0:
95
+ return 1920, 1080
96
+ line = (r.stdout or "").strip()
97
+ if not line:
98
+ return 1920, 1080
99
+ parts = line.split(",")
100
+ if len(parts) >= 2:
101
+ try:
102
+ return int(parts[0]), int(parts[1])
103
+ except ValueError:
104
+ pass
105
+ return 1920, 1080
106
+
107
+
54
108
  def split_chunk(video_path: str, start_time: float, duration: float, output_path: str) -> None:
55
109
  cmd = [
56
110
  _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
- if data.get("status") != "PENDING":
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((data.get("created_time", 0), p))
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.0
3
+ Version: 1.0.2
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
- 安装后直接运行: videoconverter [--data-dir DIR] [--path-replace OLD=NEW]
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
- 2) 环境
42
+ 3) 环境
32
43
  - Python 3.8+
33
44
  - 系统已安装 ffmpeg、ffprobe(或在 PATH 中)
34
45
  - 可选:环境变量 FFMPEG_PATH、FFPROBE_PATH 指定路径
35
46
 
36
- 3) 本机与 Java 共用同一队列(同一台机器)
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
- 4) 把「前端处理过的文件夹」拷到服务器后运行
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
- 5) 多进程并发
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
- 6) 暂停/继续
75
+ 7) 暂停/继续
65
76
  队列暂停由 queue_config.json 的 queue_paused 控制("true" 为暂停)。
66
77
  Python worker 会定期读该配置,为 true 时不取新任务。可由 Java 前端或手动改该文件控制。
67
78
 
68
- 7) 与 Java 的约定
69
- - 任务与 config 格式见项目根目录 queue_task_schema.txt。
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
- 8) 打包与部署
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=swnOI6cLw9Pe_Vn1XhOnjVzOI7GpUEkqNUgwcpPtlxY,11633
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=NvR_dQQWaHelf7rnX-vGSMUocLkpYr5k37FWyCHhGyA,18002
6
+ videoconverter_worker-1.0.2.dist-info/METADATA,sha256=Dm9e4sYkLd3JDk8J5m1y-kSkx0KKXqe6ZVwsvAg2zwI,5139
7
+ videoconverter_worker-1.0.2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
8
+ videoconverter_worker-1.0.2.dist-info/entry_points.txt,sha256=qedJjjix02n9Hz8adBioDIpGHghm8S3fQZdFwM5LV1A,83
9
+ videoconverter_worker-1.0.2.dist-info/top_level.txt,sha256=iamWyiqUZ4X0_2UZx6GEk9gsPmiI9qhse_15HqtzUj8,48
10
+ videoconverter_worker-1.0.2.dist-info/RECORD,,
@@ -1,4 +1,5 @@
1
1
  ffmpeg_runner
2
2
  metadata
3
+ schema
3
4
  task_queue
4
5
  worker
worker.py CHANGED
@@ -18,6 +18,8 @@ from ffmpeg_runner import (
18
18
  run_desubtitle,
19
19
  merge_chunks,
20
20
  get_duration,
21
+ get_video_size,
22
+ check_ffmpeg_available,
21
23
  )
22
24
 
23
25
  logging.basicConfig(
@@ -102,6 +104,30 @@ def process_desubtitle_task(store: QueueStore, task: dict) -> None:
102
104
  logger.warning("更新 metadata 或检查合成失败: videoId=%s, chunkId=%s, %s", video_id, chunk_id, e)
103
105
 
104
106
 
107
+ def _merge_creation_lock(store: QueueStore, video_id: str):
108
+ """对同一 video_id 串行化「检查并创建 MERGE」,避免多进程同时建两个 MERGE。"""
109
+ from contextlib import contextmanager
110
+ safe_id = (video_id or "").replace("/", "_").replace("\\", "_")[:64]
111
+ lock_path = store.queue_dir / f".merge_{safe_id}.lock"
112
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
113
+
114
+ @contextmanager
115
+ def _held():
116
+ try:
117
+ import fcntl
118
+ f = open(lock_path, "w")
119
+ try:
120
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX)
121
+ yield
122
+ finally:
123
+ fcntl.flock(f.fileno(), fcntl.LOCK_UN)
124
+ f.close()
125
+ except ImportError:
126
+ yield # Windows 无 fcntl,接受可能的重复 MERGE
127
+
128
+ return _held()
129
+
130
+
105
131
  def check_and_create_merge_task(store: QueueStore, video_id: str, output_dir: str, config: dict) -> None:
106
132
  metadata_path = Path(output_dir) / video_id / "metadata.json"
107
133
  if not metadata_path.exists():
@@ -111,10 +137,11 @@ def check_and_create_merge_task(store: QueueStore, video_id: str, output_dir: st
111
137
  total = len(chunks)
112
138
  processed = get_processed_chunks(data)
113
139
  pending = get_pending_chunks(data)
114
- existing = store.get_tasks_by_video_id(video_id)
115
- has_merge = any(t.get("task_type") == "MERGE" for t in existing)
116
-
117
- if total > 0 and len(processed) == total and not pending and not has_merge:
140
+ if total == 0 or len(processed) != total or pending:
141
+ return
142
+ with _merge_creation_lock(store, video_id):
143
+ if store.has_merge_task_for_video_id(video_id):
144
+ return
118
145
  store.create_merge_task(video_id, output_dir, config)
119
146
  logger.info("自动创建合成任务: videoId=%s, 已处理 %d/%d 块", video_id, len(processed), total)
120
147
 
@@ -218,8 +245,96 @@ def work_loop(store: QueueStore) -> None:
218
245
  time.sleep(5)
219
246
 
220
247
 
248
+ def run_simple_compose(
249
+ input_file: str,
250
+ output_dir: str,
251
+ start_sec: float,
252
+ end_sec: float,
253
+ crop_bottom: int = 208,
254
+ crop_left: int = 293,
255
+ crop_right: int = 293,
256
+ ) -> str:
257
+ """
258
+ 简易模式:在当前目录/指定目录内,对单个视频做「切分→去字幕→合成」全流程,不经过队列。
259
+ 返回最终合成文件路径;异常时抛出。
260
+ """
261
+ input_path = Path(input_file)
262
+ if not input_path.exists():
263
+ raise FileNotFoundError(f"文件不存在: {input_file}")
264
+ if end_sec <= 0:
265
+ end_sec = get_duration(input_file)
266
+ start_sec = max(0.0, start_sec)
267
+ if start_sec >= end_sec:
268
+ raise ValueError("开始时间必须小于结束时间")
269
+
270
+ width, height = get_video_size(input_file)
271
+ config = {
272
+ "targetWidth": 1920,
273
+ "targetHeight": 1080,
274
+ "cropTop": 0,
275
+ "cropBottom": crop_bottom,
276
+ "cropLeft": crop_left,
277
+ "cropRight": crop_right,
278
+ "keepAudio": True,
279
+ "audioBitrate": 192,
280
+ "videoQuality": 23,
281
+ "originalWidth": width,
282
+ "originalHeight": height,
283
+ "startTime": start_sec,
284
+ "endTime": end_sec,
285
+ "useHardwareAccel": False,
286
+ }
287
+
288
+ logger.info("简易模式: 切分 %s (%.0f - %.0f秒), 字幕高度(裁底)=%d", input_path.name, start_sec, end_sec, crop_bottom)
289
+ metadata, video_id = split_video_to_chunks(input_file, output_dir, 120.0, start_sec, end_sec)
290
+ chunk_list = [c for c in (metadata.get("chunks") or []) if c.get("originalPath")]
291
+ logger.info("切分完成: %d 块,开始去字幕", len(chunk_list))
292
+
293
+ for ch in chunk_list:
294
+ rel = ch.get("originalPath", "")
295
+ if not rel:
296
+ continue
297
+ chunk_path = Path(output_dir) / rel
298
+ if not chunk_path.exists():
299
+ continue
300
+ chunk_id = ch.get("chunkId", "")
301
+ out_dir_v = Path(output_dir) / video_id
302
+ out_dir_v.mkdir(parents=True, exist_ok=True)
303
+ output_file = out_dir_v / (Path(chunk_path).stem + "_desub.mp4")
304
+ cfg = dict(config)
305
+ cfg["inputPath"] = str(chunk_path)
306
+ cfg["outputPath"] = str(output_file)
307
+ cfg["startTime"] = 0
308
+ cfg["endTime"] = 0
309
+ cfg["forceKeyframeAtStart"] = True
310
+ run_desubtitle(cfg, str(chunk_path), str(output_file))
311
+ meta_path = Path(output_dir) / video_id / "metadata.json"
312
+ if meta_path.exists():
313
+ update_chunk_processed(str(meta_path), chunk_id, str(output_file))
314
+
315
+ data = load_metadata(str(Path(output_dir) / video_id / "metadata.json"))
316
+ processed = get_processed_chunks(data)
317
+ if not processed:
318
+ raise RuntimeError("没有已处理的 chunk")
319
+ start_t = processed[0]["startTime"]
320
+ end_t = processed[-1]["endTime"]
321
+ out_file = Path(output_dir) / f"{video_id}_merged.mp4"
322
+ merge_chunks(data, start_t, end_t, str(out_file))
323
+ logger.info("简易模式完成: %s", out_file)
324
+ return str(out_file)
325
+
326
+
221
327
  def main() -> int:
222
- parser = argparse.ArgumentParser(description="VideoConverter Python Worker")
328
+ parser = argparse.ArgumentParser(
329
+ description="VideoConverter Python Worker:从队列执行任务,或简易模式单文件/批量处理。",
330
+ epilog="""
331
+ 简易模式示例(不经过队列,指定开始/结束时间、字幕高度,便于测试):
332
+ 单文件: videoconverter --simple --dir . --start 0 --end 120 --crop-bottom 208 --file 视频.mp4
333
+ 批量: videoconverter --simple --dir . --start 0 --end 60 --crop-bottom 208 --batch
334
+ 参数: --dir 工作目录 --start/--end 秒(0=到结尾) --crop-bottom 字幕高度 --output 输出目录
335
+ """,
336
+ formatter_class=argparse.RawDescriptionHelpFormatter,
337
+ )
223
338
  parser.add_argument(
224
339
  "--data-dir",
225
340
  default=None,
@@ -236,8 +351,117 @@ def main() -> int:
236
351
  default=1,
237
352
  help="并发 worker 数(默认 1,多进程时由外部起多个 worker 进程)",
238
353
  )
354
+ parser.add_argument(
355
+ "--show-schema",
356
+ action="store_true",
357
+ help="打印任务/队列输入格式说明(对接方如何喂数据)",
358
+ )
359
+ parser.add_argument(
360
+ "--simple",
361
+ action="store_true",
362
+ help="简易模式:指定目录内输入开始/结束时间、字幕高度,单文件或批量处理(便于测试)",
363
+ )
364
+ parser.add_argument(
365
+ "--dir",
366
+ default=".",
367
+ help="简易模式:工作目录,默认当前目录",
368
+ )
369
+ parser.add_argument(
370
+ "--start",
371
+ type=float,
372
+ default=0.0,
373
+ help="简易模式:开始时间(秒)",
374
+ )
375
+ parser.add_argument(
376
+ "--end",
377
+ type=float,
378
+ default=0,
379
+ help="简易模式:结束时间(秒),0 表示到视频结尾",
380
+ )
381
+ parser.add_argument(
382
+ "--crop-bottom",
383
+ type=int,
384
+ default=208,
385
+ help="简易模式:字幕高度(裁底像素),默认 208",
386
+ )
387
+ parser.add_argument(
388
+ "--crop-left",
389
+ type=int,
390
+ default=293,
391
+ help="简易模式:左侧裁剪像素,默认 293",
392
+ )
393
+ parser.add_argument(
394
+ "--crop-right",
395
+ type=int,
396
+ default=293,
397
+ help="简易模式:右侧裁剪像素,默认 293",
398
+ )
399
+ parser.add_argument(
400
+ "--file",
401
+ default=None,
402
+ help="简易模式:指定一个视频文件(否则需用 --batch)",
403
+ )
404
+ parser.add_argument(
405
+ "--batch",
406
+ action="store_true",
407
+ help="简易模式:批量处理当前目录下所有视频(.mp4/.mkv/.mov)",
408
+ )
409
+ parser.add_argument(
410
+ "--output",
411
+ default=None,
412
+ help="简易模式:输出目录,默认 <dir>/videoconverter_out",
413
+ )
239
414
  args = parser.parse_args()
240
415
 
416
+ if args.show_schema:
417
+ from schema import INPUT_SCHEMA
418
+ print(INPUT_SCHEMA)
419
+ return 0
420
+
421
+ err = check_ffmpeg_available()
422
+ if err:
423
+ logger.error("%s", err)
424
+ return 1
425
+
426
+ if args.simple:
427
+ base = Path(args.dir).resolve()
428
+ if not base.is_dir():
429
+ logger.error("目录不存在: %s", base)
430
+ return 1
431
+ out_dir = Path(args.output).resolve() if args.output else (base / "videoconverter_out")
432
+ out_dir.mkdir(parents=True, exist_ok=True)
433
+ if args.file:
434
+ p = Path(args.file)
435
+ files = [p.resolve() if p.is_absolute() else (base / p).resolve()]
436
+ if not files[0].exists():
437
+ logger.error("文件不存在: %s", files[0])
438
+ return 1
439
+ elif args.batch:
440
+ exts = {".mp4", ".mkv", ".mov", ".avi", ".flv"}
441
+ files = sorted([p for p in base.iterdir() if p.is_file() and p.suffix.lower() in exts])
442
+ if not files:
443
+ logger.error("目录下没有视频文件: %s", base)
444
+ return 1
445
+ else:
446
+ logger.error("简易模式请指定 --file <路径> 或 --batch")
447
+ return 1
448
+ failed = 0
449
+ for f in files:
450
+ try:
451
+ run_simple_compose(
452
+ str(f),
453
+ str(out_dir),
454
+ args.start,
455
+ args.end,
456
+ crop_bottom=args.crop_bottom,
457
+ crop_left=args.crop_left,
458
+ crop_right=args.crop_right,
459
+ )
460
+ except Exception as e:
461
+ logger.exception("处理失败 %s: %s", f.name, e)
462
+ failed += 1
463
+ return 1 if failed else 0
464
+
241
465
  data_dir = args.data_dir or __import__("os").environ.get(
242
466
  "VIDEOCONVERTER_DATA_DIR",
243
467
  str(Path.home() / ".videoconverter" / "data"),
@@ -258,6 +482,11 @@ def main() -> int:
258
482
  if path_replace:
259
483
  logger.info("路径替换: %s -> %s", path_replace[0], path_replace[1])
260
484
 
485
+ # 断电/强制关机后:将无主 PROCESSING 任务重置为 PENDING,便于继续处理
486
+ n = store.recover_orphaned_processing()
487
+ if n > 0:
488
+ logger.info("已恢复 %d 个孤儿任务(PROCESSING -> PENDING)", n)
489
+
261
490
  work_loop(store)
262
491
  return 0
263
492
 
@@ -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,,