196 lines
5.5 KiB
Python
196 lines
5.5 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import subprocess
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from app.config import settings
|
|
from app.services.upload_video_asset_service import (
|
|
build_upload_url_from_path,
|
|
ensure_shot_segment_dir,
|
|
get_ffmpeg_bin,
|
|
)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
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:
|
|
return datetime.now().strftime("%Y/%m/%d")
|
|
|
|
|
|
def _safe_unlink(path: Path) -> None:
|
|
try:
|
|
if path.exists():
|
|
path.unlink()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def split_video_segment(
|
|
*,
|
|
source_path: str | Path,
|
|
segment_id: str,
|
|
start_second: float,
|
|
end_second: float,
|
|
date_dir: str | None = None,
|
|
attempt_key: str | None = None,
|
|
finalize: bool = False,
|
|
) -> ShotSplitResult:
|
|
"""执行 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}"
|
|
)
|
|
|
|
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"
|
|
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",
|
|
|
|
# 先 seek 到起始秒,再按 duration 切割,避免 -to 在不同 ffmpeg 参数位置下语义不一致。
|
|
"-ss",
|
|
f"{start:.3f}",
|
|
"-i",
|
|
str(source_path),
|
|
"-t",
|
|
f"{duration:.3f}",
|
|
|
|
# 只取主视频流,音频可选,避免 map 0 把字幕/数据流带进去导致 mp4 封装失败。
|
|
"-map",
|
|
"0:v:0",
|
|
"-map",
|
|
"0:a:0?",
|
|
|
|
# 当前是拆镜片段,重编码更稳,避免关键帧不准导致片段首尾异常。
|
|
"-c:v",
|
|
"libx264",
|
|
"-preset",
|
|
"veryfast",
|
|
"-crf",
|
|
"23",
|
|
"-pix_fmt",
|
|
"yuv420p",
|
|
"-c:a",
|
|
"aac",
|
|
"-b:a",
|
|
"128k",
|
|
"-movflags",
|
|
"+faststart",
|
|
|
|
# 即使临时文件扩展名未来被改坏,也强制指定 mp4 muxer。
|
|
"-f",
|
|
"mp4",
|
|
str(part_path),
|
|
]
|
|
|
|
try:
|
|
completed = subprocess.run(
|
|
cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
timeout=timeout,
|
|
check=False,
|
|
)
|
|
except subprocess.TimeoutExpired as exc:
|
|
_safe_unlink(part_path)
|
|
raise RuntimeError(
|
|
f"ffmpeg 拆镜超时:timeout={timeout}s, start={start:.3f}, end={end:.3f}"
|
|
) from exc
|
|
|
|
if completed.returncode != 0:
|
|
_safe_unlink(part_path)
|
|
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 拆镜失败:输出文件为空")
|
|
|
|
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=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,
|
|
segment_id: str,
|
|
start_second: float,
|
|
end_second: float,
|
|
date_dir: str | None = None,
|
|
attempt_key: str | None = None,
|
|
finalize: bool = False,
|
|
) -> ShotSplitResult:
|
|
return await asyncio.to_thread(
|
|
split_video_segment,
|
|
source_path=source_path,
|
|
segment_id=segment_id,
|
|
start_second=start_second,
|
|
end_second=end_second,
|
|
date_dir=date_dir,
|
|
attempt_key=attempt_key,
|
|
finalize=finalize,
|
|
)
|