diff --git a/apps/worker/video_processing/render_adapter.py b/apps/worker/video_processing/render_adapter.py index 7007cd2a4..512344952 100755 --- a/apps/worker/video_processing/render_adapter.py +++ b/apps/worker/video_processing/render_adapter.py @@ -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( diff --git a/apps/worker/video_processing/thumbnail_generator.py b/apps/worker/video_processing/thumbnail_generator.py index f57a6b684..a33be8d51 100755 --- a/apps/worker/video_processing/thumbnail_generator.py +++ b/apps/worker/video_processing/thumbnail_generator.py @@ -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) diff --git a/packages/config/base.py b/packages/config/base.py index cbe4a4d47..2b34d2152 100755 --- a/packages/config/base.py +++ b/packages/config/base.py @@ -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:暂停积分系统但保留全部代码/表/接口)。