videoconverter-worker 1.0.1__py3-none-any.whl → 1.0.3__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"
@@ -256,13 +285,15 @@ def split_video_to_chunks(
256
285
  })
257
286
  logger.info("切分完成: %s (%.1f - %.1f秒)", chunk_id, ch_start, ch_end)
258
287
 
288
+ _now = __import__("datetime").datetime.utcnow()
259
289
  metadata = {
260
290
  "videoId": video_id,
261
291
  "originalPath": video_path,
262
292
  "chunkSize": chunk_size_sec,
263
293
  "totalChunks": total_chunks,
264
294
  "chunks": chunks,
265
- "createdAt": __import__("datetime").datetime.utcnow().isoformat() + "Z",
295
+ "createdAt": _now.isoformat() + "Z",
296
+ "splitStartedAt": _now.isoformat() + "Z",
266
297
  }
267
298
  meta_path = chunk_dir / "metadata.json"
268
299
  with open(meta_path, "w", encoding="utf-8") as f:
@@ -272,20 +303,33 @@ def split_video_to_chunks(
272
303
 
273
304
 
274
305
  def merge_chunks(metadata: dict, start_time: float, end_time: float, output_path: str) -> bool:
275
- """合并已处理的 chunk(按 startTime 排序,concat + 可选 trim)。"""
306
+ """合并已处理的 chunk(按 startTime 排序,concat + 可选 trim)。processedPath 支持相对路径(相对 output_dir/video_id)或绝对路径。"""
276
307
  chunks = metadata.get("chunks") or []
277
308
  processed = [c for c in chunks if c.get("status") == "processed" and c.get("processedPath")]
278
- processed = [c for c in processed if Path(c["processedPath"]).exists()]
309
+ out_path = Path(output_path)
310
+ video_id = metadata.get("videoId") or ""
311
+ chunk_dir = out_path.parent / video_id if video_id else out_path.parent
312
+
313
+ def resolve_path(c: dict) -> Path:
314
+ raw = c["processedPath"]
315
+ p = Path(raw)
316
+ if p.is_absolute():
317
+ return p.resolve()
318
+ return (chunk_dir / raw).resolve()
319
+
320
+ processed = [c for c in processed if resolve_path(c).exists()]
279
321
  if not processed:
280
322
  raise ValueError("没有可用的已处理小块")
281
323
  processed.sort(key=lambda c: c["startTime"])
282
324
 
283
325
  with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
284
326
  for c in processed:
285
- f.write(f"file '{Path(c['processedPath']).resolve()}'\n")
327
+ p = resolve_path(c)
328
+ # FFmpeg concat 列表:路径中单引号须转义为 '\''
329
+ path_str = str(p).replace("'", "'\\''")
330
+ f.write(f"file '{path_str}'\n")
286
331
  list_path = f.name
287
332
  try:
288
- out_path = Path(output_path)
289
333
  tmp_concat = out_path.parent / f"chunk_merge_{os.getpid()}.mp4"
290
334
  tmp_trim = out_path.parent / f"chunk_trim_{os.getpid()}.mp4"
291
335
  try:
metadata.py CHANGED
@@ -54,13 +54,15 @@ def update_chunk_processed(metadata_path: str, chunk_id: str, processed_path: st
54
54
 
55
55
  def _do_update():
56
56
  data = load_metadata(metadata_path)
57
+ # 存相对路径(相对 metadata 所在目录),便于移动 output 目录后仍可合并
58
+ rel_path = processed_path_obj.name
57
59
  for chunk in data.get("chunks") or []:
58
60
  if chunk.get("chunkId") == chunk_id:
59
- chunk["processedPath"] = processed_path
61
+ chunk["processedPath"] = rel_path
60
62
  chunk["status"] = "processed"
61
63
  chunk["processedAt"] = __import__("datetime").datetime.utcnow().isoformat() + "Z"
62
64
  save_metadata(metadata_path, data)
63
- logger.info("已更新 metadata 中 chunk %s 为已处理: %s", chunk_id, processed_path)
65
+ logger.info("已更新 metadata 中 chunk %s 为已处理: %s", chunk_id, rel_path)
64
66
  return
65
67
  logger.warning("未在 metadata 中找到 chunk: %s", chunk_id)
66
68
 
@@ -71,5 +73,9 @@ def update_chunk_processed(metadata_path: str, chunk_id: str, processed_path: st
71
73
  _do_update()
72
74
  finally:
73
75
  fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
76
+ try:
77
+ lock_path.unlink(missing_ok=True)
78
+ except OSError:
79
+ pass
74
80
  else:
75
81
  _do_update()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: videoconverter-worker
3
- Version: 1.0.1
3
+ Version: 1.0.3
4
4
  Summary: VideoConverter Python Worker:从 queue 目录读取任务并执行切分/去字幕/合成
5
5
  License: MIT
6
6
  Keywords: videoconverter,ffmpeg,worker,video
@@ -0,0 +1,10 @@
1
+ ffmpeg_runner.py,sha256=2sYugXkKp-NKj3ahxbwbhAo4G49FDu-0oze-cdJvcY0,12218
2
+ metadata.py,sha256=32zQn7jJ6XTHhunwTn5Xv0qmpjHnap_pkPxY0XI8MBs,3116
3
+ schema.py,sha256=3ILdGl5qSQOSvKfiKWcnaxdyHwV4rDvGnRZIparVp3o,4361
4
+ task_queue.py,sha256=sYQelPRuTbP9g_sPs69xOR3n5SIxGBFxY4EI5ueQrQs,11822
5
+ worker.py,sha256=2t7mFIV2i_qJJxN55wNrU6ZmPbG4wT9YEjzECi04BR8,19664
6
+ videoconverter_worker-1.0.3.dist-info/METADATA,sha256=8qyb44UnKGgChp4v3F8SuHkL4EpK6IepBhdMLJ7UWTQ,5139
7
+ videoconverter_worker-1.0.3.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
8
+ videoconverter_worker-1.0.3.dist-info/entry_points.txt,sha256=qedJjjix02n9Hz8adBioDIpGHghm8S3fQZdFwM5LV1A,83
9
+ videoconverter_worker-1.0.3.dist-info/top_level.txt,sha256=iamWyiqUZ4X0_2UZx6GEk9gsPmiI9qhse_15HqtzUj8,48
10
+ videoconverter_worker-1.0.3.dist-info/RECORD,,
worker.py CHANGED
@@ -19,6 +19,7 @@ from ffmpeg_runner import (
19
19
  merge_chunks,
20
20
  get_duration,
21
21
  get_video_size,
22
+ check_ffmpeg_available,
22
23
  )
23
24
 
24
25
  logging.basicConfig(
@@ -145,6 +146,45 @@ def check_and_create_merge_task(store: QueueStore, video_id: str, output_dir: st
145
146
  logger.info("自动创建合成任务: videoId=%s, 已处理 %d/%d 块", video_id, len(processed), total)
146
147
 
147
148
 
149
+ def _format_duration(sec: float) -> str:
150
+ """不足1分钟用秒,超过60秒用分钟,超过60分钟用小时。"""
151
+ if sec < 60:
152
+ return f"{sec:.1f}秒" if sec != int(sec) else f"{int(sec)}秒"
153
+ if sec < 3600:
154
+ m = int(sec // 60)
155
+ s = int(round(sec % 60))
156
+ return f"{m}分{s}秒" if s else f"{m}分"
157
+ h = int(sec // 3600)
158
+ m = int((sec % 3600) // 60)
159
+ s = int(round(sec % 60))
160
+ if m and s:
161
+ return f"{h}小时{m}分{s}秒"
162
+ if m:
163
+ return f"{h}小时{m}分"
164
+ if s:
165
+ return f"{h}小时{s}秒"
166
+ return f"{h}小时"
167
+
168
+
169
+ def _log_split_to_merge_duration(data: dict, task_id: str, store: QueueStore) -> None:
170
+ """若 metadata 含 splitStartedAt,则计算并输出从切分到合成结束的总时长。"""
171
+ s = data.get("splitStartedAt") or ""
172
+ if not s:
173
+ return
174
+ try:
175
+ from datetime import datetime, timezone
176
+ ts = datetime.fromisoformat(s.replace("Z", "+00:00"))
177
+ if ts.tzinfo is None:
178
+ ts = ts.replace(tzinfo=timezone.utc)
179
+ duration_sec = (datetime.now(timezone.utc) - ts).total_seconds()
180
+ if duration_sec >= 0:
181
+ msg = f"从切分到合成结束总时长: {_format_duration(duration_sec)}"
182
+ logger.info("videoId=%s %s", data.get("videoId", ""), msg)
183
+ store.add_log(task_id, "INFO", msg)
184
+ except Exception:
185
+ pass
186
+
187
+
148
188
  def process_merge_task(store: QueueStore, task: dict) -> None:
149
189
  task_id = task["task_id"]
150
190
  video_id = task.get("video_id")
@@ -173,6 +213,8 @@ def process_merge_task(store: QueueStore, task: dict) -> None:
173
213
  merge_chunks(data, start_time, end_time, str(output_file))
174
214
  store.complete_task(task_id)
175
215
  store.add_log(task_id, "INFO", f"合成完成: {output_file.name}")
216
+ # 从切分到合成结束总时长(若 metadata 含 splitStartedAt)
217
+ _log_split_to_merge_duration(data, task_id, store)
176
218
  except Exception as e:
177
219
  store.fail_task(task_id, str(e))
178
220
  store.add_log(task_id, "WARN", str(e))
@@ -285,6 +327,7 @@ def run_simple_compose(
285
327
  }
286
328
 
287
329
  logger.info("简易模式: 切分 %s (%.0f - %.0f秒), 字幕高度(裁底)=%d", input_path.name, start_sec, end_sec, crop_bottom)
330
+ t0 = time.time()
288
331
  metadata, video_id = split_video_to_chunks(input_file, output_dir, 120.0, start_sec, end_sec)
289
332
  chunk_list = [c for c in (metadata.get("chunks") or []) if c.get("originalPath")]
290
333
  logger.info("切分完成: %d 块,开始去字幕", len(chunk_list))
@@ -319,7 +362,8 @@ def run_simple_compose(
319
362
  end_t = processed[-1]["endTime"]
320
363
  out_file = Path(output_dir) / f"{video_id}_merged.mp4"
321
364
  merge_chunks(data, start_t, end_t, str(out_file))
322
- logger.info("简易模式完成: %s", out_file)
365
+ elapsed = time.time() - t0
366
+ logger.info("简易模式完成: %s,从切分到合成结束总时长: %s", out_file, _format_duration(elapsed))
323
367
  return str(out_file)
324
368
 
325
369
 
@@ -417,6 +461,11 @@ def main() -> int:
417
461
  print(INPUT_SCHEMA)
418
462
  return 0
419
463
 
464
+ err = check_ffmpeg_available()
465
+ if err:
466
+ logger.error("%s", err)
467
+ return 1
468
+
420
469
  if args.simple:
421
470
  base = Path(args.dir).resolve()
422
471
  if not base.is_dir():
@@ -1,10 +0,0 @@
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,,