videoconverter-worker 1.0.1__tar.gz → 1.0.3__tar.gz
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.
- {videoconverter_worker-1.0.1 → videoconverter_worker-1.0.3}/PKG-INFO +1 -1
- {videoconverter_worker-1.0.1 → videoconverter_worker-1.0.3}/ffmpeg_runner.py +49 -5
- {videoconverter_worker-1.0.1 → videoconverter_worker-1.0.3}/metadata.py +8 -2
- {videoconverter_worker-1.0.1 → videoconverter_worker-1.0.3}/pyproject.toml +1 -1
- {videoconverter_worker-1.0.1 → videoconverter_worker-1.0.3}/videoconverter_worker.egg-info/PKG-INFO +1 -1
- {videoconverter_worker-1.0.1 → videoconverter_worker-1.0.3}/worker.py +50 -1
- {videoconverter_worker-1.0.1 → videoconverter_worker-1.0.3}/README.txt +0 -0
- {videoconverter_worker-1.0.1 → videoconverter_worker-1.0.3}/schema.py +0 -0
- {videoconverter_worker-1.0.1 → videoconverter_worker-1.0.3}/setup.cfg +0 -0
- {videoconverter_worker-1.0.1 → videoconverter_worker-1.0.3}/task_queue.py +0 -0
- {videoconverter_worker-1.0.1 → videoconverter_worker-1.0.3}/videoconverter_worker.egg-info/SOURCES.txt +0 -0
- {videoconverter_worker-1.0.1 → videoconverter_worker-1.0.3}/videoconverter_worker.egg-info/dependency_links.txt +0 -0
- {videoconverter_worker-1.0.1 → videoconverter_worker-1.0.3}/videoconverter_worker.egg-info/entry_points.txt +0 -0
- {videoconverter_worker-1.0.1 → videoconverter_worker-1.0.3}/videoconverter_worker.egg-info/top_level.txt +0 -0
|
@@ -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":
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
@@ -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"] =
|
|
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,
|
|
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()
|
|
@@ -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
|
-
|
|
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():
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|