diff --git a/video-gen-api/alembic/versions/e7bf423b248c_ffmpeg_video_cover.py b/video-gen-api/alembic/versions/e7bf423b248c_ffmpeg_video_cover.py new file mode 100644 index 00000000..6485ccf4 --- /dev/null +++ b/video-gen-api/alembic/versions/e7bf423b248c_ffmpeg_video_cover.py @@ -0,0 +1,47 @@ +"""FFMPEG_VIDEO_COVER + +Revision ID: e7bf423b248c +Revises: 8a00badae463 +Create Date: 2026-06-02 11:38:49.326686 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'e7bf423b248c' +down_revision: Union[str, None] = '8a00badae463' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('chat_generation_tasks', sa.Column('video_cover_url', sa.String(length=512), nullable=True)) + op.alter_column('chat_generation_tasks', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.alter_column('chat_generation_tasks', 'updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=False, + existing_server_default=sa.text('now()')) + op.add_column('generation_records', sa.Column('video_cover_url', sa.String(length=512), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('generation_records', 'video_cover_url') + op.alter_column('chat_generation_tasks', 'updated_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('now()')) + op.alter_column('chat_generation_tasks', 'created_at', + existing_type=postgresql.TIMESTAMP(timezone=True), + nullable=True, + existing_server_default=sa.text('now()')) + op.drop_column('chat_generation_tasks', 'video_cover_url') + # ### end Alembic commands ### diff --git a/video-gen-api/app/services/video_cover_service.py b/video-gen-api/app/services/video_cover_service.py new file mode 100644 index 00000000..79951f5c --- /dev/null +++ b/video-gen-api/app/services/video_cover_service.py @@ -0,0 +1,238 @@ +import logging +import os +import shutil +import subprocess +from pathlib import Path + +from app.config import settings + +import asyncio + +logger = logging.getLogger("videogen") + + +class VideoCoverError(RuntimeError): + pass + + +def _clean_cover_format(value: str | None) -> str: + ext = (value or "jpg").strip().lower().lstrip(".") + return ext or "jpg" + + +def get_ffmpeg_bin() -> str: + """ + 获取 ffmpeg 可执行文件路径。 + + 优先级: + 1. 环境变量 FFMPEG_BIN + 2. settings.FFMPEG_BIN + 3. 系统 PATH 中的 ffmpeg / ffmpeg.exe + """ + env_bin = settings.FFMPEG_BIN + if env_bin: + ffmpeg_path = Path(env_bin) + if ffmpeg_path.exists(): + return str(ffmpeg_path) + + found = shutil.which("ffmpeg") + if found: + return found + + found_exe = shutil.which("ffmpeg.exe") + if found_exe: + return found_exe + + raise VideoCoverError("未找到 ffmpeg,请安装 ffmpeg 或配置环境变量 FFMPEG_BIN") + + +def generate_video_cover( + video_path: str, + output_path: str, + seek_time: str = "00:00:01", + width: int = 720, + timeout: int = 15, +) -> str: + """ + 从视频中截取封面图。 + + 说明: + - 默认截第 1 秒,避免首帧黑屏。 + - Windows / Linux 都可用。 + - 不使用 shell=True,避免路径空格、命令注入问题。 + - 使用 -nostdin + timeout,避免 ffmpeg 卡死 worker。 + - output_path 建议使用 .jpg 或 .webp。 + """ + video_file = Path(video_path) + output_file = Path(output_path) + + if not video_file.exists(): + raise VideoCoverError(f"视频文件不存在: {video_file}") + + output_file.parent.mkdir(parents=True, exist_ok=True) + + ffmpeg_bin = get_ffmpeg_bin() + scale_filter = f"scale={width}:-2" + + cmd = [ + ffmpeg_bin, + "-y", + "-nostdin", + "-hide_banner", + "-loglevel", + "error", + "-ss", + seek_time, + "-i", + str(video_file), + "-frames:v", + "1", + "-vf", + scale_filter, + "-q:v", + "3", + str(output_file), + ] + + try: + result = subprocess.run( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + ) + except subprocess.TimeoutExpired as exc: + raise VideoCoverError(f"ffmpeg 截图超时: {video_file}") from exc + except OSError as exc: + raise VideoCoverError(f"ffmpeg 执行失败: {exc}") from exc + + if result.returncode != 0: + err = (result.stderr or "").strip() + raise VideoCoverError(f"ffmpeg 截图失败: {err[-1000:]}") + + if not output_file.exists() or output_file.stat().st_size <= 0: + raise VideoCoverError(f"封面生成失败,输出文件为空: {output_file}") + + return str(output_file) + + +def build_video_cover_path_and_url(record_id: str, date_dir: str) -> tuple[str, str]: + """ + 根据记录ID和日期目录生成本地封面文件路径与对外URL。 + + 注意: + - 这里不做签名,接口返回时统一通过 build_resource_signed_url 处理。 + - URL 固定使用 /generate/covers,便于 OpenResty/Nginx 统一做资源验签。 + """ + ext = _clean_cover_format(settings.VIDEO_COVER_FORMAT) + cover_dir = os.path.join(settings.STORAGE_VIDEO_COVER_LOCAL_PATH, date_dir) + cover_path = os.path.join(cover_dir, f"{record_id}.{ext}") + cover_url = f"/generate/covers/{date_dir}/{record_id}.{ext}" + return cover_path, cover_url + + +def try_generate_video_cover( + *, + video_path: str, + output_path: str, + seek_time: str | None = None, + fallback_seek_time: str | None = None, + width: int | None = None, + timeout: int | None = None, + log_prefix: str = "视频封面生成", +) -> str | None: + """ + best-effort 封面生成。 + + 规则: + - 任何异常都捕获并记录日志。 + - 主 seek_time 失败后,使用 fallback_seek_time 再尝试一次。 + - 最终失败返回 None,不影响视频任务 completed。 + """ + first_seek = seek_time or settings.VIDEO_COVER_SEEK_TIME + second_seek = fallback_seek_time or settings.VIDEO_COVER_FALLBACK_SEEK_TIME + cover_width = width or settings.VIDEO_COVER_WIDTH + cover_timeout = timeout or settings.VIDEO_COVER_TIMEOUT_SECONDS + + try: + return generate_video_cover( + video_path=video_path, + output_path=output_path, + seek_time=first_seek, + width=cover_width, + timeout=cover_timeout, + ) + except Exception as first_exc: + if second_seek and second_seek != first_seek: + try: + return generate_video_cover( + video_path=video_path, + output_path=output_path, + seek_time=second_seek, + width=cover_width, + timeout=cover_timeout, + ) + except Exception as second_exc: + logger.warning( + "%s失败,已忽略,不影响视频生成成功。video_path=%s, output_path=%s, first_error=%s, fallback_error=%s", + log_prefix, + video_path, + output_path, + first_exc, + second_exc, + exc_info=True, + ) + return None + + logger.warning( + "%s失败,已忽略,不影响视频生成成功。video_path=%s, output_path=%s, error=%s", + log_prefix, + video_path, + output_path, + first_exc, + exc_info=True, + ) + return None + + +def create_video_cover_for_local_video( + *, + record_id: str, + video_path: str, + date_dir: str, + log_prefix: str = "视频封面生成", +) -> tuple[str | None, str | None]: + """ + 给本地视频生成封面。 + + 返回: + - cover_url: 成功时为 /generate/covers/...,失败为 None + - cover_path: 成功时为本地文件路径,失败为 None + """ + cover_path, cover_url = build_video_cover_path_and_url(record_id, date_dir) + generated_path = try_generate_video_cover( + video_path=video_path, + output_path=cover_path, + log_prefix=log_prefix, + ) + if not generated_path: + return None, None + return cover_url, generated_path + +async def async_create_video_cover_for_local_video( + *, + record_id: str, + video_path: str, + date_dir: str, + log_prefix: str = "视频封面生成", +) -> tuple[str | None, str | None]: + return await asyncio.to_thread( + create_video_cover_for_local_video, + record_id=record_id, + video_path=video_path, + date_dir=date_dir, + log_prefix=log_prefix, + )