ffmpeg video cover
This commit is contained in:
@@ -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 ###
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user