608e200c0c
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m54s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1m35s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m39s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 4m14s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m32s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m50s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m41s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m8s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m25s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 6m41s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m43s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m22s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m11s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m34s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m15s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m8s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m16s
CI/CD Pipeline / Integration Tests (push) Successful in 4m59s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 13m50s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 4m12s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
393 lines
13 KiB
Python
Executable File
393 lines
13 KiB
Python
Executable File
"""视频封面抽帧工具 — 从视频中抽取帧作为封面,支持标题文字叠加。
|
||
|
||
统一封面管道:
|
||
- 从已渲染视频抽帧:标题已通过 ASS 字幕烧进视频,帧天然带标题,无需再叠加。
|
||
- 从源素材抽帧(API E2 兜底):源素材无标题,通过 Pillow 在帧上绘制标题文字。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ── 标题叠加(Pillow)──────────────────────────────────────────────────────
|
||
# 实现统一放在 packages/shared/title_overlay.py,API 和 Worker 共用。
|
||
|
||
|
||
def apply_title_overlay(
|
||
image_path: str,
|
||
title_text: str,
|
||
*,
|
||
color: str = "#ffffff",
|
||
position: str = "bottom",
|
||
font_size: int | None = None,
|
||
margin_ratio: float = 0.06,
|
||
stroke_width_ratio: float = 0.04,
|
||
) -> str:
|
||
"""在图片上绘制标题文字(指定颜色 + 黑色描边/阴影)。
|
||
|
||
委托给 packages.shared.title_overlay.apply_title_to_image,
|
||
保持 Worker 内调用方式不变。title_text 为空时直接返回原路径。
|
||
"""
|
||
from packages.shared.title_overlay import apply_title_to_image
|
||
|
||
if not title_text or not title_text.strip():
|
||
return image_path
|
||
result = apply_title_to_image(
|
||
image_path,
|
||
title_text,
|
||
color=color,
|
||
position=position,
|
||
font_size=font_size,
|
||
margin_ratio=margin_ratio,
|
||
stroke_width_ratio=stroke_width_ratio,
|
||
)
|
||
return result or image_path
|
||
|
||
|
||
def extract_first_frame(
|
||
video_path: str,
|
||
output_path: str | None = None,
|
||
*,
|
||
width: int = -1,
|
||
height: int = -1,
|
||
timeout: int = 30,
|
||
seek_ratio: float = 0.15,
|
||
min_seek_seconds: float = 1.0,
|
||
) -> str:
|
||
"""抽取视频封面帧(默认取视频时长 15% 处的帧,避开片头纯色画面)。
|
||
|
||
因为视频渲染时标题已通过 ASS 字幕烧录,抽取的帧天然带标题。
|
||
|
||
Args:
|
||
video_path: 视频文件路径
|
||
output_path: 输出图片路径,不传则用临时文件
|
||
width: 输出宽度(默认 -1,保持原始分辨率)
|
||
height: 输出高度(默认 -1,保持原始分辨率)
|
||
timeout: 超时时间(秒)
|
||
seek_ratio: 抽帧位置占视频时长的比例(默认 0.15,即 15% 处)
|
||
min_seek_seconds: 最小抽帧时间(秒),避免极短视频 seek 到 0
|
||
|
||
Returns:
|
||
生成的封面帧文件路径
|
||
|
||
Raises:
|
||
RuntimeError: ffmpeg 执行失败或输出文件为空
|
||
"""
|
||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
|
||
|
||
_is_temp_output = False
|
||
if output_path is None:
|
||
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
||
tmp.close()
|
||
output_path = tmp.name
|
||
_is_temp_output = True
|
||
|
||
try:
|
||
# 计算抽帧时间点:取视频时长 * seek_ratio,最少 min_seek_seconds 秒
|
||
try:
|
||
duration = probe_duration(video_path)
|
||
seek_time = max(min_seek_seconds, duration * seek_ratio)
|
||
except Exception:
|
||
# probe 失败时 fallback 到第1秒
|
||
seek_time = min_seek_seconds
|
||
|
||
# 格式化为 HH:MM:SS.xx
|
||
seek_str = _format_seek_time(seek_time)
|
||
|
||
# 构建 scale filter:如果指定了宽高则缩放,否则保持原始分辨率。
|
||
# NOTE: scale_filter 在此处通过 if/else 分支赋值,之后不再被覆盖,
|
||
# 后续 cmd / cmd2 均复用同一变量,逻辑无变化。
|
||
if width > 0 or height > 0:
|
||
w_str = str(width) if width > 0 else "-1"
|
||
h_str = str(height) if height > 0 else "-1"
|
||
scale_filter = f"scale={w_str}:{h_str}:force_original_aspect_ratio=decrease,format=yuvj420p"
|
||
else:
|
||
# 保持原始分辨率,只确保格式兼容
|
||
scale_filter = "format=yuvj420p"
|
||
|
||
# -ss 放在 -i 前面(input seeking,更快)
|
||
# -vframes 1 只取一帧
|
||
# -q:v 2 jpeg 高质量
|
||
cmd = [
|
||
FFMPEG_BIN,
|
||
"-y",
|
||
"-ss",
|
||
seek_str,
|
||
"-i",
|
||
video_path,
|
||
"-vframes",
|
||
"1",
|
||
"-vf",
|
||
scale_filter,
|
||
"-q:v",
|
||
"2",
|
||
output_path,
|
||
]
|
||
|
||
try:
|
||
run_ffmpeg(cmd, capture_output=True, timeout=timeout)
|
||
except Exception:
|
||
# 失败时退回到第0帧兜底
|
||
cmd2 = [
|
||
FFMPEG_BIN,
|
||
"-y",
|
||
"-i",
|
||
video_path,
|
||
"-ss",
|
||
"00:00:00",
|
||
"-vframes",
|
||
"1",
|
||
"-vf",
|
||
scale_filter,
|
||
"-q:v",
|
||
"2",
|
||
output_path,
|
||
]
|
||
run_ffmpeg(cmd2, capture_output=True, timeout=timeout)
|
||
|
||
if not Path(output_path).exists() or Path(output_path).stat().st_size == 0:
|
||
raise RuntimeError(f"Cover frame extraction failed: {output_path}")
|
||
|
||
return output_path
|
||
except Exception:
|
||
# 失败时清理自己创建的临时文件
|
||
if _is_temp_output and output_path:
|
||
try:
|
||
Path(output_path).unlink(missing_ok=True)
|
||
except Exception:
|
||
pass
|
||
raise
|
||
|
||
|
||
def _format_seek_time(seconds: float) -> str:
|
||
"""将秒数格式化为 HH:MM:SS.xx 格式。"""
|
||
h = int(seconds // 3600)
|
||
m = int((seconds % 3600) // 60)
|
||
s = seconds % 60
|
||
return f"{h:02d}:{m:02d}:{s:05.2f}"
|
||
|
||
|
||
def generate_and_upload_thumbnail(
|
||
video_path: str,
|
||
storage_key: str,
|
||
*,
|
||
seek_ratio: float = 0.15,
|
||
) -> str:
|
||
"""从视频中提取一帧缩略图并上传到 OSS。
|
||
|
||
Args:
|
||
video_path: 视频文件路径
|
||
storage_key: OSS 存储 key
|
||
seek_ratio: 抽帧位置比例(默认 0.15)
|
||
|
||
Returns:
|
||
上传后的 URL 字符串
|
||
|
||
Raises:
|
||
RuntimeError: 抽帧或上传失败
|
||
"""
|
||
from video_processing.oss_helpers import upload_to_oss
|
||
|
||
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
||
tmp.close()
|
||
try:
|
||
frame_path = extract_first_frame(video_path, output_path=tmp.name, seek_ratio=seek_ratio)
|
||
url = upload_to_oss(frame_path, storage_key)
|
||
if not url:
|
||
raise RuntimeError(f"上传缩略图到 OSS 失败: {storage_key}")
|
||
return url
|
||
finally:
|
||
Path(tmp.name).unlink(missing_ok=True)
|
||
|
||
|
||
def _extract_frames_via_mediakit(
|
||
video_path: str,
|
||
plan_id: str,
|
||
num_frames: int,
|
||
) -> list[dict] | None:
|
||
"""使用 MediaKit 智能抽帧 API 提取封面帧。
|
||
|
||
Args:
|
||
video_path: 本地视频文件路径
|
||
plan_id: 编辑计划 ID
|
||
num_frames: 需要的帧数
|
||
|
||
Returns:
|
||
帧列表 [{"image_url": str, "timestamp": float}, ...],失败返回 None
|
||
"""
|
||
import uuid
|
||
|
||
from video_processing.oss_helpers import upload_to_oss
|
||
|
||
from packages.shared.mediakit_client import get_mediakit_client
|
||
|
||
client = get_mediakit_client()
|
||
if not client.is_available:
|
||
logger.info("[thumbnail] MediaKit 未配置,跳过智能抽帧")
|
||
return None
|
||
|
||
# 1. 上传视频到 OSS 获取 URL
|
||
try:
|
||
video_storage_key = f"temp/{plan_id}/{uuid.uuid4().hex[:8]}_{Path(video_path).name}"
|
||
video_url = upload_to_oss(video_path, video_storage_key)
|
||
if not video_url:
|
||
logger.warning("[thumbnail] 视频上传 OSS 失败,无法使用 MediaKit")
|
||
return None
|
||
logger.info("[thumbnail] 视频已上传 OSS: %s", video_url[:80])
|
||
except Exception as e:
|
||
logger.warning("[thumbnail] 视频上传 OSS 异常: %s,降级到 ffmpeg", e)
|
||
return None
|
||
|
||
# 2. 调用 MediaKit 智能抽帧
|
||
try:
|
||
frames = client.extract_frames(
|
||
video_url=video_url,
|
||
strategy="SceneChange",
|
||
max_frames=num_frames * 2, # 多取一些帧供选择
|
||
)
|
||
if not frames:
|
||
logger.warning("[thumbnail] MediaKit 抽帧返回空,降级到 ffmpeg")
|
||
return None
|
||
|
||
# 选取最均匀的 num_frames 个帧
|
||
if len(frames) > num_frames:
|
||
step = len(frames) // num_frames
|
||
frames = [frames[i * step] for i in range(num_frames)]
|
||
|
||
logger.info("[thumbnail] MediaKit 抽帧成功: %d 帧", len(frames))
|
||
return frames
|
||
|
||
except Exception as e:
|
||
logger.warning("[thumbnail] MediaKit 抽帧异常: %s,降级到 ffmpeg", e)
|
||
return None
|
||
finally:
|
||
# 清理临时视频文件
|
||
try:
|
||
from video_processing.oss_helpers import delete_from_oss
|
||
|
||
delete_from_oss(video_storage_key)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def extract_and_upload_cover_frames(
|
||
video_path: str,
|
||
plan_id: str,
|
||
*,
|
||
task_id: str = "",
|
||
num_frames: int = 3,
|
||
title_text: str = "",
|
||
title_color: str = "#ffffff",
|
||
title_position: str = "bottom",
|
||
title_font_size: int | None = None,
|
||
) -> list[dict]:
|
||
"""从视频中抽取多帧作为封面候选,上传到 OSS。
|
||
|
||
优先使用 MediaKit 智能抽帧,失败时降级到 ffmpeg 直接抽帧。
|
||
|
||
Args:
|
||
video_path: 视频文件路径
|
||
plan_id: 编辑计划 ID(用于生成 storage key)
|
||
task_id: 任务 ID(用于生成独立的 storage key,避免标题变更时封面冲突)
|
||
num_frames: 抽取帧数(默认 3)
|
||
title_text: 标题文字;非空时用 Pillow 叠加到每帧。
|
||
从已渲染视频抽帧时通常传空(标题已烧录);从源素材抽帧时传标题。
|
||
title_color: 标题字体颜色(#RRGGBB)
|
||
title_position: 标题位置 top/center/bottom
|
||
title_font_size: 标题字号,None 时自动计算
|
||
|
||
Returns:
|
||
封面候选列表,每项包含 {"url": str, "position": float}
|
||
"""
|
||
import httpx
|
||
from video_processing.ffmpeg_utils import probe_duration
|
||
from video_processing.oss_helpers import upload_to_oss
|
||
|
||
try:
|
||
duration = probe_duration(video_path)
|
||
except Exception:
|
||
duration = 0.0
|
||
|
||
candidates: list[dict] = []
|
||
|
||
# 优先尝试 MediaKit 智能抽帧
|
||
mediakit_frames = _extract_frames_via_mediakit(video_path, plan_id, num_frames)
|
||
if mediakit_frames:
|
||
for i, frame in enumerate(mediakit_frames):
|
||
frame_url = frame.get("image_url")
|
||
if not frame_url:
|
||
continue
|
||
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
||
tmp.close()
|
||
try:
|
||
# 下载 MediaKit 返回的帧图
|
||
resp = httpx.get(frame_url, timeout=30, follow_redirects=True)
|
||
resp.raise_for_status()
|
||
with open(tmp.name, "wb") as f:
|
||
f.write(resp.content)
|
||
|
||
# 叠加标题文字(如需要)
|
||
if title_text and title_text.strip():
|
||
apply_title_overlay(
|
||
tmp.name,
|
||
title_text,
|
||
color=title_color,
|
||
position=title_position,
|
||
font_size=title_font_size,
|
||
)
|
||
|
||
storage_key = f"covers/{plan_id}/{task_id}/mediakit_frame_{i}.jpg"
|
||
url = upload_to_oss(tmp.name, storage_key)
|
||
if url:
|
||
seek_time = frame.get("timestamp", 0.0)
|
||
candidates.append({"url": url, "position": round(seek_time, 2)})
|
||
except Exception as e:
|
||
logger.warning("[thumbnail] MediaKit 帧 %d 处理失败: %s", i, e)
|
||
finally:
|
||
Path(tmp.name).unlink(missing_ok=True)
|
||
|
||
if len(candidates) >= num_frames:
|
||
logger.info("[thumbnail] MediaKit 智能抽帧完成: %d 帧", len(candidates))
|
||
return candidates[:num_frames]
|
||
|
||
logger.warning("[thumbnail] MediaKit 抽帧不足 %d 帧,降级到 ffmpeg", num_frames)
|
||
|
||
# Fallback: ffmpeg 直接抽帧
|
||
logger.info("[thumbnail] 使用 ffmpeg 抽帧")
|
||
# 均匀分布抽帧点:从 10% 到 90%
|
||
for i in range(num_frames):
|
||
ratio = 0.1 + 0.8 * i / max(num_frames - 1, 1)
|
||
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
||
tmp.close()
|
||
try:
|
||
frame_path = extract_first_frame(
|
||
video_path,
|
||
output_path=tmp.name,
|
||
seek_ratio=ratio,
|
||
min_seek_seconds=0.5,
|
||
)
|
||
# 从源素材抽帧时叠加标题文字;已渲染视频标题已烧录时传空字符串跳过
|
||
if title_text and title_text.strip():
|
||
apply_title_overlay(
|
||
frame_path,
|
||
title_text,
|
||
color=title_color,
|
||
position=title_position,
|
||
font_size=title_font_size,
|
||
)
|
||
storage_key = f"covers/{plan_id}/{task_id}/frame_{i}.jpg"
|
||
url = upload_to_oss(frame_path, storage_key)
|
||
if url:
|
||
seek_time = max(0.5, duration * ratio) if duration > 0 else 0.0
|
||
candidates.append({"url": url, "position": round(seek_time, 2)})
|
||
except Exception as e:
|
||
logger.warning("[thumbnail] 封面候选帧 %d 提取失败: %s", i, e)
|
||
finally:
|
||
Path(tmp.name).unlink(missing_ok=True)
|
||
|
||
return candidates
|