feat(cover): 默认本地ffmpeg抽帧+cv2打分,砍掉MediaKit封面路径
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
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 / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
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 / PR Build API Image (pull_request) Successful in 47s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m17s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m4s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m14s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m17s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 3m21s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m12s
AI Code Review / AI Code Review (pull_request) Successful in 6m46s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 8m7s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 9m17s
CI/CD Pipeline / CI Gate (pull_request) Failing after 7s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 6m41s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
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 / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
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 / PR Build API Image (pull_request) Successful in 47s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m17s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m4s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m14s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m17s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 3m21s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m12s
AI Code Review / AI Code Review (pull_request) Successful in 6m46s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 8m7s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 9m17s
CI/CD Pipeline / CI Gate (pull_request) Failing after 7s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 6m41s
P1优化:之前封面抽帧走火山MediaKit北京节点(公网拉视频+SceneChange算法+ 轮询等待),实测1080p视频耗时86s占总渲染92%。 改动: 1. 默认路径改为本地ffmpeg -ss单帧seek抽取5帧+cv2清晰度/亮度/色彩三维 评分选最优,预期1-2s完成(本地<100ms/帧×5 + cv2评分0.05s + OSS上传1s) 2. MediaKit路径保留为fallback,配置开关MEDIAKIT_COVER_ENABLED(默认false) 3. 新增clip_boundaries参数:基于clip分段边界取各段中点抽帧,效果优于 均匀分布;render_adapter自动传入ready_clips的(start_time,duration) 4. 跳过片头8%/片尾8%,避免黑屏/开场logo/片尾字幕区 5. extract_first_frame新增seek_seconds参数支持指定时间点 6. 封面完成日志加elapsed和path字段方便观测耗时
This commit is contained in:
@@ -650,8 +650,22 @@ class RenderAdapter:
|
||||
# 已渲染视频在统一渲染阶段已通过 ASS 字幕把标题烧录进画面,
|
||||
# 抽帧天然带标题,因此这里传空字符串,避免 Pillow 二次叠加导致重影。
|
||||
# Pillow 叠加仅用于 API 从源素材抽帧(源素材本身无标题)的兜底场景。
|
||||
# 构造clip分段边界 [(start, duration), ...] 供封面抽帧智能取各段中点
|
||||
try:
|
||||
_clip_boundaries = [
|
||||
(float(getattr(c, "start_time", 0.0) or 0.0), float(getattr(c, "duration", 0.0) or 0.0))
|
||||
for c in clips
|
||||
if float(getattr(c, "duration", 0.0) or 0.0) > 0
|
||||
]
|
||||
except Exception:
|
||||
_clip_boundaries = None
|
||||
cover_candidates = extract_and_upload_cover_frames(
|
||||
str(result.output_path), plan_id, task_id=job_id, num_frames=5, title_text=""
|
||||
str(result.output_path),
|
||||
plan_id,
|
||||
task_id=job_id,
|
||||
num_frames=5,
|
||||
title_text="",
|
||||
clip_boundaries=_clip_boundaries,
|
||||
)
|
||||
if cover_candidates:
|
||||
logger.info(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""视频封面抽帧工具 — 从视频中抽取帧作为封面,支持标题文字叠加。
|
||||
|
||||
统一封面管道:
|
||||
统一封面管道(P1 优化后默认本地路径):
|
||||
- 默认路径:本地 ffmpeg -ss 单帧 seek 抽取 + cv2 质量评分(清晰度/亮度/色彩),1-2s 完成
|
||||
- 可选 MediaKit 路径:配置 MEDIAKIT_COVER_ENABLED=true 时启用火山 MediaKit SceneChange 抽帧
|
||||
- 从已渲染视频抽帧:标题已通过 ASS 字幕烧进视频,帧天然带标题,无需再叠加。
|
||||
- 从源素材抽帧(API E2 兜底):源素材无标题,通过 Pillow 在帧上绘制标题文字。
|
||||
"""
|
||||
@@ -10,12 +12,10 @@ from __future__ import annotations
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 标题叠加(Pillow)──────────────────────────────────────────────────────
|
||||
# 实现统一放在 packages/shared/title_overlay.py,API 和 Worker 共用。
|
||||
|
||||
|
||||
def apply_title_overlay(
|
||||
image_path: str,
|
||||
@@ -27,11 +27,7 @@ def apply_title_overlay(
|
||||
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():
|
||||
@@ -56,26 +52,19 @@ def extract_first_frame(
|
||||
height: int = -1,
|
||||
timeout: int = 30,
|
||||
seek_ratio: float = 0.15,
|
||||
seek_seconds: float | None = None,
|
||||
min_seek_seconds: float = 1.0,
|
||||
) -> str:
|
||||
"""抽取视频封面帧(默认取视频时长 15% 处的帧,避开片头纯色画面)。
|
||||
|
||||
因为视频渲染时标题已通过 ASS 字幕烧录,抽取的帧天然带标题。
|
||||
"""抽取视频封面帧(ffmpeg -ss 单帧 seek,<100ms/帧)。
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
output_path: 输出图片路径,不传则用临时文件
|
||||
width: 输出宽度(默认 -1,保持原始分辨率)
|
||||
height: 输出高度(默认 -1,保持原始分辨率)
|
||||
timeout: 超时时间(秒)
|
||||
seek_ratio: 抽帧位置占视频时长的比例(默认 0.15,即 15% 处)
|
||||
min_seek_seconds: 最小抽帧时间(秒),避免极短视频 seek 到 0
|
||||
|
||||
Returns:
|
||||
生成的封面帧文件路径
|
||||
|
||||
Raises:
|
||||
RuntimeError: ffmpeg 执行失败或输出文件为空
|
||||
width/height: 输出宽高(默认保持原始分辨率)
|
||||
timeout: 超时(秒)
|
||||
seek_ratio: 抽帧位置占视频时长的比例
|
||||
seek_seconds: 指定具体抽帧时间点(秒),优先于 seek_ratio
|
||||
min_seek_seconds: 最小抽帧时间
|
||||
"""
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
|
||||
|
||||
@@ -87,31 +76,25 @@ def extract_first_frame(
|
||||
_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
|
||||
if seek_seconds is not None:
|
||||
seek_time = max(0.0, float(seek_seconds))
|
||||
else:
|
||||
try:
|
||||
duration = probe_duration(video_path)
|
||||
seek_time = max(min_seek_seconds, duration * seek_ratio)
|
||||
except Exception:
|
||||
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 高质量
|
||||
# -ss 放在 -i 前面(input seeking,极快),-vframes 1 只取一帧
|
||||
cmd = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
@@ -154,7 +137,6 @@ def extract_first_frame(
|
||||
|
||||
return output_path
|
||||
except Exception:
|
||||
# 失败时清理自己创建的临时文件
|
||||
if _is_temp_output and output_path:
|
||||
try:
|
||||
Path(output_path).unlink(missing_ok=True)
|
||||
@@ -164,7 +146,6 @@ def extract_first_frame(
|
||||
|
||||
|
||||
def _format_seek_time(seconds: float) -> str:
|
||||
"""将秒数格式化为 HH:MM:SS.xx 格式。"""
|
||||
h = int(seconds // 3600)
|
||||
m = int((seconds % 3600) // 60)
|
||||
s = seconds % 60
|
||||
@@ -177,19 +158,7 @@ def generate_and_upload_thumbnail(
|
||||
*,
|
||||
seek_ratio: float = 0.15,
|
||||
) -> str:
|
||||
"""从视频中提取一帧缩略图并上传到 OSS。
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
storage_key: OSS 存储 key
|
||||
seek_ratio: 抽帧位置比例(默认 0.15)
|
||||
|
||||
Returns:
|
||||
上传后的 URL 字符串
|
||||
|
||||
Raises:
|
||||
RuntimeError: 抽帧或上传失败
|
||||
"""
|
||||
"""从视频中提取一帧缩略图并上传到 OSS。"""
|
||||
from video_processing.oss_helpers import upload_to_oss
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
||||
@@ -204,24 +173,84 @@ def generate_and_upload_thumbnail(
|
||||
Path(tmp.name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _compute_clip_boundary_seek_points(
|
||||
duration: float,
|
||||
clip_boundaries: Optional[list[tuple[float, float]]] = None,
|
||||
num_frames: int = 5,
|
||||
head_skip_ratio: float = 0.08,
|
||||
tail_skip_ratio: float = 0.08,
|
||||
) -> list[float]:
|
||||
"""基于clip分段边界计算抽帧时间点(取每段中间帧,效果比均匀抽更好)。
|
||||
|
||||
策略:
|
||||
- 如果传入 clip_boundaries(每个元素是 (clip_start_in_timeline, clip_duration)),
|
||||
取每个片段的中点作为抽帧候选点
|
||||
- 候选点不足 num_frames 时,均匀补充
|
||||
- 跳过片头 head_skip_ratio(8%,避免片头黑屏/开场标题)和片尾 tail_skip_ratio(8%)
|
||||
- 返回按时间排序的 num_frames 个抽帧点(秒)
|
||||
"""
|
||||
if duration <= 0:
|
||||
# 无法probe,均匀分布兜底
|
||||
return [max(1.0, duration * (0.1 + 0.8 * i / max(num_frames - 1, 1))) for i in range(num_frames)]
|
||||
|
||||
head_skip = duration * head_skip_ratio
|
||||
tail_skip = duration * tail_skip_ratio
|
||||
valid_start = head_skip
|
||||
valid_end = max(valid_start + 1.0, duration - tail_skip)
|
||||
|
||||
candidates: list[float] = []
|
||||
|
||||
if clip_boundaries:
|
||||
# 累加timeline start,取每clip中点
|
||||
cur = 0.0
|
||||
for _clip_start, clip_dur in clip_boundaries:
|
||||
if clip_dur <= 0:
|
||||
continue
|
||||
mid = cur + clip_dur / 2.0
|
||||
if valid_start <= mid <= valid_end:
|
||||
candidates.append(mid)
|
||||
cur += clip_dur
|
||||
# 去重+排序
|
||||
candidates = sorted(set(round(c, 3) for c in candidates))
|
||||
|
||||
# 如果候选点不足,均匀补充
|
||||
if len(candidates) < num_frames:
|
||||
needed = num_frames - len(candidates)
|
||||
existing = set(round(c, 1) for c in candidates)
|
||||
for i in range(needed * 3):
|
||||
ratio = 0.1 + 0.8 * (i + 0.5) / (needed * 3)
|
||||
t = valid_start + (valid_end - valid_start) * ratio
|
||||
if round(t, 1) not in existing:
|
||||
candidates.append(t)
|
||||
existing.add(round(t, 1))
|
||||
if len(candidates) >= num_frames:
|
||||
break
|
||||
|
||||
# 如果还不够,强制均匀
|
||||
while len(candidates) < num_frames:
|
||||
idx = len(candidates)
|
||||
ratio = 0.1 + 0.8 * idx / max(num_frames - 1, 1)
|
||||
candidates.append(valid_start + (valid_end - valid_start) * ratio)
|
||||
|
||||
candidates.sort()
|
||||
|
||||
# 如果超过num_frames,均匀选取
|
||||
if len(candidates) > num_frames:
|
||||
step = len(candidates) / num_frames
|
||||
candidates = [candidates[int(i * step)] for i in range(num_frames)]
|
||||
|
||||
return [round(t, 3) for t in candidates[:num_frames]]
|
||||
|
||||
|
||||
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
|
||||
"""
|
||||
"""使用 MediaKit 智能抽帧 API 提取封面帧(fallback 路径,默认不启用)。"""
|
||||
import uuid
|
||||
|
||||
from video_processing.oss_helpers import get_signed_download_url, upload_to_oss
|
||||
from video_processing.oss_helpers import delete_from_oss, get_signed_download_url, upload_to_oss
|
||||
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
|
||||
@@ -231,47 +260,37 @@ def _extract_frames_via_mediakit(
|
||||
return None
|
||||
|
||||
video_storage_key: str = ""
|
||||
# 1. 上传视频到 OSS,并生成预签名下载 URL(bucket 私有读,公网 URL 会 403)
|
||||
try:
|
||||
video_storage_key = f"temp/{plan_id}/{uuid.uuid4().hex[:8]}_{Path(video_path).name}"
|
||||
public_url = upload_to_oss(video_path, video_storage_key)
|
||||
if not public_url:
|
||||
logger.warning("[thumbnail] 视频上传 OSS 失败,无法使用 MediaKit")
|
||||
return None
|
||||
# MediaKit 从公网拉取视频,必须使用预签名 URL;签名 1h 足够完成抽帧
|
||||
video_url = get_signed_download_url(video_storage_key, expires_seconds=3600) or public_url
|
||||
logger.info("[thumbnail] 视频已上传 OSS 并生成签名 URL: key=%s", video_storage_key[:80])
|
||||
except Exception as e:
|
||||
logger.warning("[thumbnail] 视频上传 OSS 异常: %s,降级到 ffmpeg", 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, # 多取一些帧供选择
|
||||
max_frames=num_frames * 2,
|
||||
)
|
||||
if not frames:
|
||||
logger.warning("[thumbnail] MediaKit 抽帧返回空,降级到 ffmpeg")
|
||||
logger.warning("[thumbnail] MediaKit 抽帧返回空")
|
||||
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)
|
||||
logger.warning("[thumbnail] MediaKit 抽帧异常: %s", e)
|
||||
return None
|
||||
finally:
|
||||
# 清理临时视频文件
|
||||
try:
|
||||
from video_processing.oss_helpers import delete_from_oss
|
||||
|
||||
delete_from_oss(video_storage_key)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -282,100 +301,96 @@ def extract_and_upload_cover_frames(
|
||||
plan_id: str,
|
||||
*,
|
||||
task_id: str = "",
|
||||
num_frames: int = 5, # 抽 5 帧候选,通过质量评分选出最佳帧
|
||||
num_frames: int = 5,
|
||||
title_text: str = "",
|
||||
title_color: str = "#ffffff",
|
||||
title_position: str = "bottom",
|
||||
title_font_size: int | None = None,
|
||||
clip_boundaries: Optional[list[tuple[float, float]]] = None,
|
||||
) -> list[dict]:
|
||||
"""从视频中抽取多帧作为封面候选,通过质量评分选出最佳帧,上传到 OSS。
|
||||
|
||||
流程:
|
||||
1. 优先使用 MediaKit 智能抽帧(多抽一些供选择)
|
||||
2. MediaKit 不足时降级到 ffmpeg 均匀抽帧
|
||||
3. 对所有候选帧进行质量评分(清晰度/亮度/色彩丰富度)
|
||||
4. 按分数从高到低排序返回
|
||||
默认路径(P1优化):本地 ffmpeg 单帧 seek 抽帧 + cv2 评分,预期 <2s 完成。
|
||||
- 基于 clip 分段边界取各段中间帧(clip_boundaries 参数),效果优于均匀抽帧
|
||||
- 无边界信息时均匀分布(10%~90% 之间)
|
||||
- 所有帧本地 cv2 清晰度/亮度/色彩三维评分,最高分自动选出
|
||||
|
||||
Fallback(MEDIAKIT_COVER_ENABLED=true):火山 MediaKit SceneChange 抽帧(~60-90s)。
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
plan_id: 编辑计划 ID(用于生成 storage key)
|
||||
task_id: 任务 ID(用于生成独立的 storage key,避免标题变更时封面冲突)
|
||||
num_frames: 抽取候选帧数(默认 5,通过质量评分选出最佳帧)
|
||||
title_text: 标题文字;非空时用 Pillow 叠加到每帧。
|
||||
从已渲染视频抽帧时通常传空(标题已烧录);从源素材抽帧时传标题。
|
||||
title_color: 标题字体颜色(#RRGGBB)
|
||||
title_position: 标题位置 top/center/bottom
|
||||
title_font_size: 标题字号,None 时自动计算
|
||||
|
||||
Returns:
|
||||
封面候选列表(按质量分数降序),每项包含 {"url": str, "position": float, "score": float}
|
||||
clip_boundaries: 片段边界列表 [(clip_start, clip_duration), ...],用于智能取点
|
||||
"""
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from video_processing.ffmpeg_utils import probe_duration
|
||||
from video_processing.oss_helpers import upload_to_oss
|
||||
|
||||
from packages.shared.config import get_shared_settings
|
||||
|
||||
t0 = time.monotonic()
|
||||
|
||||
try:
|
||||
duration = probe_duration(video_path)
|
||||
except Exception:
|
||||
duration = 0.0
|
||||
|
||||
candidates: list[dict] = []
|
||||
_temp_paths: list[str] = [] # 收集所有临时文件路径,最后统一清理
|
||||
_temp_paths: list[str] = []
|
||||
|
||||
try:
|
||||
# ── 阶段 1:抽帧 ──────────────────────────────────────────────
|
||||
# 优先尝试 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()
|
||||
_temp_paths.append(tmp.name)
|
||||
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)
|
||||
settings = get_shared_settings()
|
||||
use_mediakit = getattr(settings, "mediakit_cover_enabled", False)
|
||||
|
||||
# 叠加标题文字(如需要)
|
||||
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,
|
||||
)
|
||||
if use_mediakit:
|
||||
logger.info("[thumbnail] MEDIAKIT_COVER_ENABLED=true,走 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()
|
||||
_temp_paths.append(tmp.name)
|
||||
try:
|
||||
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:
|
||||
candidates.append(
|
||||
{
|
||||
"url": url,
|
||||
"position": round(frame.get("timestamp", 0.0), 2),
|
||||
"image_path": tmp.name,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[thumbnail] MediaKit 帧 %d 处理失败: %s", i, e)
|
||||
if len(candidates) >= num_frames:
|
||||
logger.info("[thumbnail] MediaKit 抽帧完成: %d 帧", len(candidates))
|
||||
|
||||
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),
|
||||
"image_path": tmp.name,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[thumbnail] MediaKit 帧 %d 处理失败: %s", i, e)
|
||||
|
||||
if len(candidates) >= num_frames:
|
||||
logger.info("[thumbnail] MediaKit 智能抽帧完成: %d 帧", len(candidates))
|
||||
else:
|
||||
logger.warning("[thumbnail] MediaKit 抽帧不足 %d 帧,降级到 ffmpeg", num_frames)
|
||||
|
||||
# Fallback: ffmpeg 直接抽帧(仅当 MediaKit 不足时)
|
||||
# ── 默认路径:本地 ffmpeg 单帧 seek ───────────────────────────
|
||||
if len(candidates) < num_frames:
|
||||
logger.info("[thumbnail] 使用 ffmpeg 抽帧补充")
|
||||
# 均匀分布抽帧点:从 10% 到 90%
|
||||
for i in range(num_frames):
|
||||
ratio = 0.1 + 0.8 * i / max(num_frames - 1, 1)
|
||||
if candidates:
|
||||
logger.info("[thumbnail] MediaKit 不足 %d 帧,本地 ffmpeg 补充", num_frames)
|
||||
else:
|
||||
logger.info("[thumbnail] 使用本地 ffmpeg 抽帧(num=%d, duration=%.1fs)", num_frames, duration)
|
||||
|
||||
seek_points = _compute_clip_boundary_seek_points(duration, clip_boundaries, num_frames)
|
||||
|
||||
for i, seek_t in enumerate(seek_points):
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
||||
tmp.close()
|
||||
_temp_paths.append(tmp.name)
|
||||
@@ -383,10 +398,9 @@ def extract_and_upload_cover_frames(
|
||||
frame_path = extract_first_frame(
|
||||
video_path,
|
||||
output_path=tmp.name,
|
||||
seek_ratio=ratio,
|
||||
seek_seconds=seek_t,
|
||||
min_seek_seconds=0.5,
|
||||
)
|
||||
# 从源素材抽帧时叠加标题文字;已渲染视频标题已烧录时传空字符串跳过
|
||||
if title_text and title_text.strip():
|
||||
apply_title_overlay(
|
||||
frame_path,
|
||||
@@ -398,11 +412,10 @@ def extract_and_upload_cover_frames(
|
||||
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),
|
||||
"position": seek_t,
|
||||
"image_path": tmp.name,
|
||||
}
|
||||
)
|
||||
@@ -415,28 +428,23 @@ def extract_and_upload_cover_frames(
|
||||
from packages.shared.cover_frame_scorer import score_frames
|
||||
|
||||
candidates = score_frames(candidates)
|
||||
elapsed = time.monotonic() - t0
|
||||
logger.info(
|
||||
"[thumbnail] 封面帧质量评分完成: plan_id=%s count=%d best_score=%.1f",
|
||||
"[thumbnail] 封面帧评分完成: plan_id=%s count=%d best_score=%.1f elapsed=%.2fs path=%s",
|
||||
plan_id,
|
||||
len(candidates),
|
||||
candidates[0].get("score", 0.0) if candidates else 0.0,
|
||||
elapsed,
|
||||
"mediakit" if use_mediakit else "local",
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[thumbnail] 封面帧质量评分失败,保持原始顺序: plan_id=%s",
|
||||
plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
logger.warning("[thumbnail] 封面帧质量评分失败,保持原始顺序", exc_info=True)
|
||||
|
||||
# ── 阶段 3:清理临时文件 ────────────────────────────────────────
|
||||
# 移除 image_path(不再需要),但临时文件统一清理
|
||||
for c in candidates:
|
||||
c.pop("image_path", None)
|
||||
|
||||
return candidates
|
||||
|
||||
finally:
|
||||
# 统一清理所有临时文件
|
||||
for path in _temp_paths:
|
||||
try:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
|
||||
@@ -101,6 +101,7 @@ class SharedSettings(BaseSettings):
|
||||
mediakit_api_key: str = ""
|
||||
mediakit_base_url: str = "https://mediakit.cn-beijing.volces.com/api/v1"
|
||||
mediakit_timeout: int = 60
|
||||
mediakit_cover_enabled: bool = False # 封面抽帧是否走MediaKit(默认false走本地ffmpeg+cv2,<2s完成)
|
||||
|
||||
# ── 积分/会员系统 (#1895) ────────────────────────────────────────────
|
||||
# 积分系统总开关(产品要求 #1895:暂停积分系统但保留全部代码/表/接口)。
|
||||
|
||||
Reference in New Issue
Block a user