Files
video-gen/video-gen-api/app/services/shot_video_split_service.py
T
2026-06-11 17:54:40 +08:00

173 lines
4.6 KiB
Python

from __future__ import annotations
import asyncio
import os
import subprocess
from dataclasses import dataclass
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
def _date_dir_from_segment_id(segment_id: str) -> str:
# 由调用方更适合按 created_at 传入;这里兜底按当前日期。
from datetime import datetime
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,
) -> ShotSplitResult:
"""使用 ffmpeg 拆出单个视频片段,输出到 storage/uploads/shot_segments。"""
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"
# 注意:
# 不能用 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"
_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 拆镜失败:输出文件为空")
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,
)
async def split_video_segment_async(
*,
source_path: str | Path,
segment_id: str,
start_second: float,
end_second: float,
date_dir: str | None = None,
) -> ShotSplitResult:
"""异步拆镜入口。
ffmpeg 本身是同步阻塞命令,不能直接在 Celery 进程内唯一 event loop 中执行。
这里通过 asyncio.to_thread 跑同步拆镜函数,避免阻塞 asyncpg / Redis / HTTP 等异步任务。
"""
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,
)