diff --git a/apps/api/app/api/routes/generation_cover.py b/apps/api/app/api/routes/generation_cover.py index c9bd2bf4d..3e0e99035 100644 --- a/apps/api/app/api/routes/generation_cover.py +++ b/apps/api/app/api/routes/generation_cover.py @@ -185,27 +185,39 @@ def generate_cover( detail=f"获取预览视频URL失败: {e}", ) from e - # 优先使用渲染时预抽的封面候选帧(跳过 MediaKit,秒级返回) - cover_candidates = (plan.config or {}).get("cover_candidates", []) - if cover_candidates and body.cover_type in ("ai_frame", "ai_regenerate"): - logger.info( - "[封面生成] 使用预存封面候选帧: plan_id=%s count=%d", - plan_id, - len(cover_candidates), - ) - first_frame = cover_candidates[0] - cover_data = { - "type": "ai_frame", - "image_url": first_frame.get("image_url", ""), - "frame_time": first_frame.get("frame_time", 0.0), - "confidence": 0.9, - } - if cover_data["image_url"]: - current_config = dict(plan.config) if plan.config else {} - current_config["cover"] = cover_data - normalized = normalize_plan_config(current_config) - plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]}) - return GenerateCoverResponse(plan_id=plan_id, cover=cover_data) + # 统一封面管道:优先从 GenerationTask.cover_url 读取渲染后视频抽帧的封面 + if body.cover_type in ("ai_frame", "ai_regenerate"): + # 尝试从 GenerationTask 读取已持久化的封面 URL + generation_task_id = (plan.config or {}).get("generation_task_id", "") + if generation_task_id: + try: + gen_task_repo = SQLAlchemyGenerationTaskRepository(db) + task = gen_task_repo.get(generation_task_id) + if task and getattr(task, "cover_url", ""): + cover_data = { + "type": "ai_frame", + "image_url": task.cover_url, + "frame_time": 0.0, + "confidence": 0.95, + } + logger.info( + "[封面生成] 使用统一管道封面: plan_id=%s task_id=%s url=%s", + plan_id, + generation_task_id, + task.cover_url[:80], + ) + current_config = dict(plan.config) if plan.config else {} + current_config["cover"] = cover_data + normalized = normalize_plan_config(current_config) + plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]}) + return GenerateCoverResponse(plan_id=plan_id, cover=cover_data) + except Exception: + logger.warning( + "[封面生成] 读取 GenerationTask.cover_url 失败: plan_id=%s task_id=%s", + plan_id, + generation_task_id, + exc_info=True, + ) from packages.shared.ai_service import run_generate_cover diff --git a/apps/worker/video_processing/dedup_helpers.py b/apps/worker/video_processing/dedup_helpers.py index 4912031f1..d339f7144 100755 --- a/apps/worker/video_processing/dedup_helpers.py +++ b/apps/worker/video_processing/dedup_helpers.py @@ -85,19 +85,9 @@ def create_video_record_and_dedup( if thumbnail_url: generated_video.thumbnail_url = thumbnail_url video_repo.update_thumbnail(video_id, thumbnail_url) - logger.info("Thumbnail reused (pre-generated) for video %s", video_id) + logger.info("Thumbnail set for video %s: %s", video_id, thumbnail_url[:80] if thumbnail_url else "") else: - thumbnail_storage_key = f"generated/projects/{project_id}/thumbnails/{video_id}.jpg" - try: - from video_processing.thumbnail_generator import generate_and_upload_thumbnail - - _thumbnail_url = generate_and_upload_thumbnail(video_path, thumbnail_storage_key) - if _thumbnail_url: - generated_video.thumbnail_url = _thumbnail_url - video_repo.update_thumbnail(video_id, _thumbnail_url) - logger.info("Thumbnail generated for video %s: %s", video_id, _thumbnail_url) - except Exception as thumb_err: - logger.warning("Thumbnail generation failed for %s: %s", video_id, thumb_err) + logger.debug("No thumbnail_url provided for video %s, skipping", video_id) # 计算视频指纹 deduplicator = VideoDeduplicator() diff --git a/apps/worker/video_processing/render_adapter.py b/apps/worker/video_processing/render_adapter.py index bd14d13a0..ce5907b61 100755 --- a/apps/worker/video_processing/render_adapter.py +++ b/apps/worker/video_processing/render_adapter.py @@ -81,9 +81,7 @@ class RenderAdapterResult: failed_clip_ids: list[str] = None # 失败的 clip id 列表 error_message: str = "" error_detail: str = "" # 详细错误信息(如 ffmpeg stderr),用于排查 - cover_candidates: list[dict] | None = ( - None # 封面候选帧 [{"image_url": "...", "frame_time": 5.0, "storage_key": "..."}] - ) + cover_url: str = "" # 封面图片 URL(从渲染后视频抽帧,天然带标题) def __post_init__(self): if self.rendered_clip_ids is None: @@ -562,43 +560,33 @@ class RenderAdapter: storage_key = f"rendered/{plan_id}/{job_id or plan_id}.mp4" output_url = upload_to_oss(result.output_path, storage_key) - self._report_progress(progress_cb, 90.0, "生成封面缩略图") + self._report_progress(progress_cb, 90.0, "抽取封面帧") - # 6. 生成封面缩略图 - thumbnail_url = "" + # 6. 从已渲染视频抽取封面帧(标题已通过 ASS 字幕烧录,封面天然带标题) + cover_url = "" + cover_frame_path = None try: - from video_processing.thumbnail_generator import generate_and_upload_thumbnail + from video_processing.thumbnail_generator import extract_first_frame - thumb_storage_key = f"rendered/{plan_id}/thumbnail.jpg" - thumbnail_url = generate_and_upload_thumbnail(str(result.output_path), thumb_storage_key) - except Exception as thumb_err: - logger.warning( - "[render-adapter] 缩略图生成失败(不影响主流程): plan_id=%s error=%s", - plan_id, - thumb_err, - ) - - # 7. 抽取封面候选帧并上传 OSS(失败不阻断主流程) - cover_candidates = None - try: - from video_processing.thumbnail_generator import extract_and_upload_cover_frames - - # 从 plan config 提取标题文字,叠加到封面候选帧上 - _title_cfg = (plan_config or {}).get("title", {}) or {} - _title_text = (_title_cfg.get("text", "") or "").strip() if _title_cfg.get("enabled", True) else "" - - cover_candidates = extract_and_upload_cover_frames( - str(result.output_path), plan_id, num_frames=3, title_text=_title_text - ) - if cover_candidates: + cover_frame_path = extract_first_frame(str(result.output_path), width=640) + cover_storage_key = f"rendered/{plan_id}/cover.jpg" + try: + cover_url = upload_to_oss(cover_frame_path, cover_storage_key) or "" + finally: + if cover_frame_path: + try: + Path(cover_frame_path).unlink(missing_ok=True) + except Exception: + pass + if cover_url: logger.info( - "[render-adapter] 封面候选帧生成成功: plan_id=%s count=%d", + "[render-adapter] 封面帧提取成功: plan_id=%s url=%s", plan_id, - len(cover_candidates), + cover_url[:80], ) except Exception as cover_err: logger.warning( - "[render-adapter] 封面候选帧生成失败(不影响主流程): plan_id=%s error=%s", + "[render-adapter] 封面帧提取失败(不影响主流程): plan_id=%s error=%s", plan_id, cover_err, ) @@ -626,7 +614,7 @@ class RenderAdapter: success=True, output_url=output_url or "", output_path=result.output_path, - thumbnail_url=thumbnail_url, + thumbnail_url=cover_url, duration=result.duration, file_size=result.file_size, width=result.width, @@ -634,7 +622,7 @@ class RenderAdapter: clip_count=len(clips), rendered_clip_ids=final_rendered_ids, failed_clip_ids=final_failed_ids, - cover_candidates=cover_candidates, + cover_url=cover_url, ) def render_from_memory( diff --git a/apps/worker/video_processing/thumbnail_generator.py b/apps/worker/video_processing/thumbnail_generator.py index 55cdc28c9..86f4a32ac 100755 --- a/apps/worker/video_processing/thumbnail_generator.py +++ b/apps/worker/video_processing/thumbnail_generator.py @@ -1,4 +1,8 @@ -"""视频缩略图生成工具 — 抽取首帧上传到 OSS。""" +"""视频封面抽帧工具 — 从已渲染视频中抽取帧作为封面。 + +统一封面管道:视频渲染时标题已通过 ASS 字幕烧进视频, +渲染完成后直接从此视频抽帧,封面天然带标题,无需额外叠加逻辑。 +""" from __future__ import annotations @@ -13,28 +17,30 @@ def extract_first_frame( video_path: str, output_path: str | None = None, *, - width: int = 640, + width: int = -1, height: int = -1, timeout: int = 30, seek_ratio: float = 0.15, min_seek_seconds: float = 1.0, ) -> str: - """抽取视频封面图(默认取视频时长 15% 处的帧,避开片头纯色画面)。 + """抽取视频封面帧(默认取视频时长 15% 处的帧,避开片头纯色画面)。 + + 因为视频渲染时标题已通过 ASS 字幕烧录,抽取的帧天然带标题。 Args: video_path: 视频文件路径 output_path: 输出图片路径,不传则用临时文件 - width: 输出宽度(默认 640,-1 表示按比例缩放) - height: 输出高度(默认 -1,按比例缩放) + width: 输出宽度(默认 -1,保持原始分辨率) + height: 输出高度(默认 -1,保持原始分辨率) timeout: 超时时间(秒) seek_ratio: 抽帧位置占视频时长的比例(默认 0.15,即 15% 处) min_seek_seconds: 最小抽帧时间(秒),避免极短视频 seek 到 0 Returns: - 生成的缩略图文件路径 + 生成的封面帧文件路径 Raises: - subprocess.CalledProcessError: ffmpeg 执行失败 + RuntimeError: ffmpeg 执行失败或输出文件为空 """ from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg @@ -57,10 +63,20 @@ def extract_first_frame( # 格式化为 HH:MM:SS.xx seek_str = _format_seek_time(seek_time) - # -ss 放在 -i 前面(input seeking,更快但精度稍低,缩略图够用) + # 构建 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 高质量 - scale_filter = f"scale={width}:{height}:force_original_aspect_ratio=decrease,format=yuvj420p" cmd = [ FFMPEG_BIN, "-y", @@ -99,7 +115,7 @@ def extract_first_frame( 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"Thumbnail generation failed: {output_path}") + raise RuntimeError(f"Cover frame extraction failed: {output_path}") return output_path except Exception: @@ -118,263 +134,3 @@ def _format_seek_time(seconds: float) -> str: m = int((seconds % 3600) // 60) s = seconds % 60 return f"{h:02d}:{m:02d}:{s:05.2f}" - - -def _overlay_title_on_image( - image_path: str, - title_text: str, - *, - timeout: int = 15, -) -> str: - """在封面图上叠加标题文字(居中、白色、带阴影)。 - - 使用 FFmpeg drawtext 滤镜,原地覆盖 image_path。 - - Args: - image_path: 输入图片路径(覆盖写入) - title_text: 要叠加的标题文字 - timeout: 超时时间(秒) - - Returns: - 处理后的图片路径(与输入相同) - """ - from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg - - if not title_text or not title_text.strip(): - return image_path - - # 转义 drawtext 特殊字符 - # FFmpeg drawtext 需要转义: ' : % \ [ ] - escaped = ( - title_text.replace("\\", "\\\\") - .replace("'", "’") - .replace(":", "\\:") - .replace("%", "%%") - .replace("[", "\\[") - .replace("]", "\\]") - ) - # 截断过长标题 - if len(escaped) > 60: - escaped = escaped[:57] + "..." - - # 使用中文字体 - font_path = "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc" - - # drawtext 滤镜参数: - # - 白色文字,字号按画面宽度自适应(约 1/18 宽度) - # - 黑色阴影偏移 2px - # - 水平居中,垂直偏下(距底部约 15%) - drawtext_filter = ( - f"drawtext=fontfile='{font_path}'" - f":text='{escaped}'" - f":fontsize=h/14" - f":fontcolor=white" - f":shadowcolor=black@0.7" - f":shadowx=2:shadowy=2" - f":x=(w-text_w)/2" - f":y=h*0.82-text_h/2" - f":borderw=0" - ) - - tmp_out = image_path + ".tmp.jpg" - cmd = [ - FFMPEG_BIN, - "-y", - "-i", - image_path, - "-vf", - drawtext_filter, - "-q:v", - "2", - tmp_out, - ] - - try: - run_ffmpeg(cmd, capture_output=True, timeout=timeout) - # 覆盖原文件 - import shutil - - shutil.move(tmp_out, image_path) - logger.info("封面标题叠加成功: text=%s", title_text[:30]) - except Exception as e: - logger.warning("封面标题叠加失败(使用原图): %s", e) - try: - Path(tmp_out).unlink(missing_ok=True) - except Exception: - pass - - return image_path - - -def generate_and_upload_thumbnail( - video_path: str, - storage_key: str, -) -> str | None: - """生成缩略图并上传到 OSS,返回 URL。 - - Args: - video_path: 本地视频路径 - storage_key: OSS 存储 key(如 generated/projects/xxx/thumbnails/yyy.jpg) - - Returns: - 上传成功返回 URL,失败返回 None - """ - thumbnail_path = None - try: - thumbnail_path = extract_first_frame(video_path) - except Exception as e: - logger.warning("Failed to extract thumbnail from %s: %s", video_path, e) - return None - - try: - from video_processing.oss_helpers import upload_to_oss - - url = upload_to_oss(thumbnail_path, storage_key) - return url - except Exception as e: - logger.warning("Failed to upload thumbnail to OSS: %s", e) - return None - finally: - # 清理临时文件 - if thumbnail_path: - try: - Path(thumbnail_path).unlink(missing_ok=True) - except Exception: - pass - - -def extract_cover_candidates( - video_path: str, - num_frames: int = 3, - *, - width: int = 640, - timeout: int = 30, - title_text: str = "", -) -> list[dict]: - """在视频时长 25%/50%/75% 处各抽一帧,返回候选帧信息列表。 - - Args: - video_path: 视频文件路径 - num_frames: 抽帧数量(默认 3) - width: 输出宽度 - timeout: 单帧超时(秒) - - Returns: - [{"local_path": "...", "frame_time": 5.0}, ...] - """ - from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg - - try: - duration = probe_duration(video_path) - except Exception: - duration = 0.0 - - if duration <= 0: - duration = 5.0 # fallback - - # 计算抽帧时间点:25%, 50%, 75% - ratios = [] - for i in range(1, num_frames + 1): - ratios.append(i / (num_frames + 1)) - - results = [] - for _idx, ratio in enumerate(ratios): - frame_time = max(0.5, duration * ratio) - tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) - tmp.close() - output_path = tmp.name - - try: - seek_str = _format_seek_time(frame_time) - scale_filter = f"scale={width}:-1:force_original_aspect_ratio=decrease,format=yuvj420p" - cmd = [ - FFMPEG_BIN, - "-y", - "-ss", - seek_str, - "-i", - video_path, - "-vframes", - "1", - "-vf", - scale_filter, - "-q:v", - "2", - output_path, - ] - run_ffmpeg(cmd, capture_output=True, timeout=timeout) - - if Path(output_path).exists() and Path(output_path).stat().st_size > 0: - # 叠加标题文字 - if title_text and title_text.strip(): - _overlay_title_on_image(output_path, title_text, timeout=timeout) - results.append( - { - "local_path": output_path, - "frame_time": round(frame_time, 2), - } - ) - else: - Path(output_path).unlink(missing_ok=True) - except Exception as e: - logger.warning("封面候选帧抽取失败 ratio=%.2f: %s", ratio, e) - Path(output_path).unlink(missing_ok=True) - - return results - - -def extract_and_upload_cover_frames( - video_path: str, - plan_id: str, - num_frames: int = 3, - *, - title_text: str = "", -) -> list[dict]: - """抽取封面候选帧并上传到 OSS。 - - Args: - video_path: 本地视频路径 - plan_id: 剪辑计划 ID(用于 OSS 路径) - num_frames: 抽帧数量 - - Returns: - [{"image_url": "https://...", "frame_time": 5.0, "storage_key": "covers/xxx/frame_0.jpg"}, ...] - """ - candidates = extract_cover_candidates(video_path, num_frames=num_frames, title_text=title_text) - if not candidates: - logger.warning("封面候选帧抽取为空: plan_id=%s", plan_id) - return [] - - results = [] - for idx, cand in enumerate(candidates): - local_path = cand["local_path"] - frame_time = cand["frame_time"] - storage_key = f"covers/{plan_id}/frame_{idx}.jpg" - - try: - from video_processing.oss_helpers import upload_to_oss - - url = upload_to_oss(local_path, storage_key) - if url: - results.append( - { - "image_url": url, - "frame_time": frame_time, - "storage_key": storage_key, - } - ) - logger.info( - "封面候选帧上传成功: plan_id=%s idx=%d frame_time=%.2f", - plan_id, - idx, - frame_time, - ) - except Exception as e: - logger.warning("封面候选帧上传失败: plan_id=%s idx=%d error=%s", plan_id, idx, e) - finally: - try: - Path(local_path).unlink(missing_ok=True) - except Exception: - pass - - return results diff --git a/apps/worker/worker_app/tasks/generation.py b/apps/worker/worker_app/tasks/generation.py index e422739e9..33981b8ff 100644 --- a/apps/worker/worker_app/tasks/generation.py +++ b/apps/worker/worker_app/tasks/generation.py @@ -1124,14 +1124,14 @@ def _render_video( bgm_config: dict | None = None, voice_ids: list[str] | None = None, custom_title: str = "", -) -> tuple[Path, float]: +) -> tuple[Path, float, str]: """渲染视频(含配音混音)。 - 使用 RenderAdapter 统一渲染入口,复用 BGM/ASR/分辨率/缩略图逻辑。 + 使用 RenderAdapter 统一渲染入口,复用 BGM/ASR/分辨率/封面抽取逻辑。 Args: Returns: - (output_path, render_duration) + (output_path, render_duration, cover_url) """ if not downloaded_videos: raise RuntimeError(f"素材下载结果为空: task_id={task_id}") @@ -1259,8 +1259,9 @@ def _render_video( # 配音素材库音频已在统一渲染引擎内部通过 audio 图层混音处理 output_path = render_output_path + cover_url = getattr(render_result, "cover_url", "") or "" - return output_path, render_duration + return output_path, render_duration, cover_url def _upload_and_record( @@ -1271,6 +1272,7 @@ def _upload_and_record( editing_mode, user_id: str = "", video_name: str = "", + thumbnail_url: str = "", ) -> tuple[str, float, int, int]: """上传 OSS、创建视频记录并查重。 @@ -1531,7 +1533,7 @@ def generate_video(self, task_id: str) -> dict: else: _resolved_resolution = task_info.get("resolution", "") - output_path, render_duration = _render_video( + output_path, render_duration, cover_url = _render_video( task_id=task_id, downloaded_videos=downloaded_videos, voice_path=audio_path, @@ -1551,6 +1553,35 @@ def generate_video(self, task_id: str) -> dict: gen_task.append_log("渲染", f"渲染完成, 时长={render_duration:.1f}s") _flush_logs(task_id, gen_task) + # 持久化封面 URL 到 GenerationTask(统一封面管道:从渲染后视频抽帧) + if cover_url: + _cover_session = None + try: + _cover_session = SessionLocal() + try: + from packages.adapters.sqlalchemy_impl.models import GenerationTaskModel + + _cover_model = ( + _cover_session.query(GenerationTaskModel).filter(GenerationTaskModel.id == task_id).first() + ) + if _cover_model: + _cover_model.cover_url = cover_url + _cover_session.commit() + logger.info( + "[task_id=%s] 封面URL已持久化: %s", + task_id, + cover_url[:80], + ) + finally: + if _cover_session: + _cover_session.close() + except Exception as cover_err: + logger.warning( + "[task_id=%s] 封面URL持久化失败(不影响主流程): %s", + task_id, + cover_err, + ) + _update_task_progress(task_id, 80, "渲染完成") # ── 4. 上传 OSS + 查重记录 ─────────────────────────────────────── @@ -1563,6 +1594,7 @@ def generate_video(self, task_id: str) -> dict: editing_mode=editing_mode, user_id=user_id, video_name=task_info.get("video_title", ""), + thumbnail_url=cover_url, ) if gen_task: diff --git a/apps/worker/worker_app/tasks/ingest.py b/apps/worker/worker_app/tasks/ingest.py index 82ddb686b..6449b8d0f 100755 --- a/apps/worker/worker_app/tasks/ingest.py +++ b/apps/worker/worker_app/tasks/ingest.py @@ -206,11 +206,21 @@ def ingest_asset(job_id: str) -> dict: # 视频类型:生成缩略图(文件还在的时候生成) thumbnail_url = None if media_type == "video" and extract_success: + frame_path = None try: - from video_processing.thumbnail_generator import generate_and_upload_thumbnail + from video_processing.oss_helpers import upload_to_oss + from video_processing.thumbnail_generator import extract_first_frame + frame_path = extract_first_frame(str(local_file), width=640) thumb_storage_key = f"assets/{job.project_id}/thumbnails/{job_id}.jpg" - thumbnail_url = generate_and_upload_thumbnail(str(local_file), thumb_storage_key) + try: + thumbnail_url = upload_to_oss(frame_path, thumb_storage_key) + finally: + if frame_path: + try: + Path(frame_path).unlink(missing_ok=True) + except Exception: + pass if thumbnail_url: logger.info( "素材缩略图生成成功: job_id=%s url=%s", diff --git a/packages/shared/ai_service.py b/packages/shared/ai_service.py index 2ae55042e..390a32a68 100755 --- a/packages/shared/ai_service.py +++ b/packages/shared/ai_service.py @@ -13,8 +13,6 @@ import random import time from typing import Any, Dict, List, Optional -import requests as http_requests - from packages.domain.config_schemas import DEFAULT_EDIT_PLAN_CONFIG from packages.shared.ai_client import get_doubao_client @@ -354,93 +352,6 @@ def _transfer_cover_frame_to_storage(frame_url: str, plan_id: str) -> str: return frame_url -def _extract_frames_with_ffmpeg( - video_url: str, - num_frames: int = 3, - timeout: int = 30, -) -> list[dict]: - """用 FFmpeg 从远程视频 URL 流式 seek 抽帧(HTTP range request,不下载整个视频)。 - - Args: - video_url: 视频 URL - num_frames: 抽帧数量 - timeout: 单帧超时(秒) - - Returns: - [{"local_path": "...", "frame_time": 5.0}, ...] - """ - import re as _re - import tempfile - from pathlib import Path as _Path - - from packages.shared.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg - - video_url = _re.sub(r"(? 0: - results.append({"local_path": output_path, "frame_time": round(frame_time, 2)}) - else: - _Path(output_path).unlink(missing_ok=True) - except Exception as e: - logger.warning("FFmpeg 远程抽帧失败 ratio=%.2f: %s", ratio, e) - _Path(output_path).unlink(missing_ok=True) - - return results - - def _call_ai_cover_service( plan_id: str, asset_ids: List[str], @@ -450,9 +361,9 @@ def _call_ai_cover_service( ) -> Dict[str, Any]: """调用 AI 封面生成服务. - 优先级: - 1. 检查 plan.config 中的 cover_candidates(渲染时预抽帧)——由调用方处理 - 2. FFmpeg 本地从 URL 流式 seek 抽帧(HTTP range request,不下载整个视频) + 统一封面管道下,封面已由渲染后视频抽帧生成并持久化到 GenerationTask.cover_url。 + 此函数仅处理 manual/upload 等需要前端交互的类型, + ai_frame/ai_regenerate 类型应由调用方直接从持久化的封面 URL 读取。 失败时抛出 RuntimeError。 @@ -484,88 +395,14 @@ def _call_ai_cover_service( "frame_time": frame_time, } - # ai_frame / ai_regenerate - 使用 FFmpeg 本地抽帧 - if primary_video_url: - import re as _re - - primary_video_url = _re.sub(r"(?