celery 容灾升级
This commit is contained in:
@@ -4,6 +4,7 @@ import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
@@ -19,12 +20,10 @@ class ShotSplitResult:
|
||||
url: str
|
||||
path: str
|
||||
file_size_bytes: int
|
||||
temporary_path: str | None = None
|
||||
|
||||
|
||||
def _date_dir_from_segment_id(segment_id: str) -> str:
|
||||
# 由调用方更适合按 created_at 传入;这里兜底按当前日期。
|
||||
from datetime import datetime
|
||||
|
||||
return datetime.now().strftime("%Y/%m/%d")
|
||||
|
||||
|
||||
@@ -43,17 +42,21 @@ def split_video_segment(
|
||||
start_second: float,
|
||||
end_second: float,
|
||||
date_dir: str | None = None,
|
||||
attempt_key: str | None = None,
|
||||
finalize: bool = False,
|
||||
) -> ShotSplitResult:
|
||||
"""使用 ffmpeg 拆出单个视频片段,输出到 storage/uploads/shot_segments。"""
|
||||
source_path = Path(source_path)
|
||||
"""执行 ffmpeg 切片。
|
||||
|
||||
默认只产出 attempt 专属临时文件;调用方完成 Redis/DB fencing 后再原子移动到正式路径,
|
||||
避免旧 Worker 在恢复任务接管后覆盖新结果。
|
||||
"""
|
||||
source_path = Path(source_path)
|
||||
if not source_path.exists():
|
||||
raise RuntimeError(f"ffmpeg 拆镜失败:源视频不存在 {source_path}")
|
||||
|
||||
start = max(float(start_second), 0.0)
|
||||
end = max(float(end_second), 0.0)
|
||||
duration = end - start
|
||||
|
||||
if duration <= 0:
|
||||
raise RuntimeError(
|
||||
f"ffmpeg 拆镜失败:非法时间范围 start_second={start_second}, end_second={end_second}"
|
||||
@@ -62,17 +65,11 @@ def split_video_segment(
|
||||
date_dir = date_dir or _date_dir_from_segment_id(segment_id)
|
||||
output_dir = ensure_shot_segment_dir(date_dir)
|
||||
output_path = output_dir / f"{segment_id}.mp4"
|
||||
|
||||
# 注意:
|
||||
# 不能用 xxx.mp4.part,因为 ffmpeg 会按最后一个扩展名 .part 判断输出格式,导致:
|
||||
# Unable to choose an output format
|
||||
# 这里改为 xxx.part.mp4,让 ffmpeg 能识别 mp4 容器。
|
||||
part_path = output_dir / f"{segment_id}.part.mp4"
|
||||
|
||||
suffix = str(attempt_key or "default").replace("/", "_").replace(":", "_")[:80]
|
||||
part_path = output_dir / f"{segment_id}.{suffix}.part.mp4"
|
||||
_safe_unlink(part_path)
|
||||
|
||||
timeout = int(getattr(settings, "SHOT_FFMPEG_TIMEOUT_SECONDS", 120) or 120)
|
||||
|
||||
cmd = [
|
||||
get_ffmpeg_bin(),
|
||||
"-y",
|
||||
@@ -100,19 +97,16 @@ def split_video_segment(
|
||||
"23",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
|
||||
# 即使临时文件扩展名未来被改坏,也强制指定 mp4 muxer。
|
||||
"-f",
|
||||
"mp4",
|
||||
|
||||
str(part_path),
|
||||
]
|
||||
|
||||
@@ -136,20 +130,49 @@ def split_video_segment(
|
||||
raise RuntimeError(
|
||||
f"ffmpeg 拆镜失败: {completed.stderr.strip() or completed.stdout.strip()}"
|
||||
)
|
||||
|
||||
if not part_path.exists() or part_path.stat().st_size <= 0:
|
||||
_safe_unlink(part_path)
|
||||
raise RuntimeError("ffmpeg 拆镜失败:输出文件为空")
|
||||
|
||||
os.replace(part_path, output_path)
|
||||
if finalize:
|
||||
os.replace(part_path, output_path)
|
||||
return ShotSplitResult(
|
||||
url=build_upload_url_from_path(output_path),
|
||||
path=str(output_path),
|
||||
file_size_bytes=output_path.stat().st_size,
|
||||
temporary_path=None,
|
||||
)
|
||||
|
||||
return ShotSplitResult(
|
||||
url=build_upload_url_from_path(output_path),
|
||||
path=str(output_path),
|
||||
file_size_bytes=output_path.stat().st_size,
|
||||
file_size_bytes=part_path.stat().st_size,
|
||||
temporary_path=str(part_path),
|
||||
)
|
||||
|
||||
|
||||
def finalize_split_result(result: ShotSplitResult) -> ShotSplitResult:
|
||||
if not result.temporary_path:
|
||||
return result
|
||||
temp_path = Path(result.temporary_path)
|
||||
final_path = Path(result.path)
|
||||
if not temp_path.exists() or temp_path.stat().st_size <= 0:
|
||||
raise RuntimeError("ffmpeg 拆镜临时结果不存在或为空")
|
||||
final_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.replace(temp_path, final_path)
|
||||
return ShotSplitResult(
|
||||
url=result.url,
|
||||
path=str(final_path),
|
||||
file_size_bytes=final_path.stat().st_size,
|
||||
temporary_path=None,
|
||||
)
|
||||
|
||||
|
||||
def cleanup_split_result(result: ShotSplitResult | None) -> None:
|
||||
if result and result.temporary_path:
|
||||
_safe_unlink(Path(result.temporary_path))
|
||||
|
||||
|
||||
async def split_video_segment_async(
|
||||
*,
|
||||
source_path: str | Path,
|
||||
@@ -157,12 +180,9 @@ async def split_video_segment_async(
|
||||
start_second: float,
|
||||
end_second: float,
|
||||
date_dir: str | None = None,
|
||||
attempt_key: str | None = None,
|
||||
finalize: bool = False,
|
||||
) -> ShotSplitResult:
|
||||
"""异步拆镜入口。
|
||||
|
||||
ffmpeg 本身是同步阻塞命令,不能直接在 Celery 进程内唯一 event loop 中执行。
|
||||
这里通过 asyncio.to_thread 跑同步拆镜函数,避免阻塞 asyncpg / Redis / HTTP 等异步任务。
|
||||
"""
|
||||
return await asyncio.to_thread(
|
||||
split_video_segment,
|
||||
source_path=source_path,
|
||||
@@ -170,4 +190,6 @@ async def split_video_segment_async(
|
||||
start_second=start_second,
|
||||
end_second=end_second,
|
||||
date_dir=date_dir,
|
||||
)
|
||||
attempt_key=attempt_key,
|
||||
finalize=finalize,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user