Files
xiaoxia 3af65c2743
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1m4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m22s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API 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 3m2s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m16s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 3m25s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m28s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m55s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 2m48s
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 Worker Image (pull_request) Successful in 34s
CI/CD Pipeline / Build Staging API Image (push) Successful in 5m7s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m53s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m1s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m9s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m17s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m0s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m27s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m26s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 12m19s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 12m24s
CI/CD Pipeline / Unit Tests (push) Successful in 14m44s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 13m38s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Integration Tests (push) Successful in 5m53s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m58s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
fix: 渲染产物临时目录不在render_plan中提前清理,改由调用方上传后清理 (#1484)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-24 21:21:00 +08:00

752 lines
29 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""统一渲染引擎适配层 — Phase 2.
将 EditPlan + EditPlanClips(来自 DB)适配为 UnifiedRenderService 的输入格式,
封装素材下载、BGM 准备、ASR 字幕、渲染执行、结果上传的完整流程。
职责:
1. 从 DB 读取 EditPlan + EditPlanClips
2. 下载素材到本地,构建 asset_path_map
3. 准备 BGM 音频(URL / 素材库 / 预设库)
4. 初始化 ASR 服务(自动字幕)
5. 调用 UnifiedRenderService 执行渲染
6. 上传渲染结果到 OSS
7. 支持进度回调(对接 JobService
"""
from __future__ import annotations
import logging
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
from sqlalchemy.orm import Session
from video_processing.oss_helpers import download_asset, upload_to_oss
from video_processing.unified_render_service import UnifiedRenderService
from video_processing.video_validation import validate_video_output
from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import SQLAlchemyEditPlanClipRepository
from packages.adapters.sqlalchemy_impl.edit_plan_repository import SQLAlchemyEditPlanRepository
from packages.adapters.sqlalchemy_impl.models import AssetModel
from packages.domain.edit_plan import EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
logger = logging.getLogger(__name__)
DEFAULT_OUTPUT_WIDTH = 1080
DEFAULT_OUTPUT_HEIGHT = 1920
def _parse_resolution(resolution_str: str | None) -> tuple[int, int]:
"""解析分辨率字符串,如 '1080x1920' → (1080, 1920)。解析失败返回默认值。"""
if not resolution_str or "x" not in resolution_str:
return DEFAULT_OUTPUT_WIDTH, DEFAULT_OUTPUT_HEIGHT
try:
w, h = resolution_str.lower().split("x", 1)
width = int(w.strip())
height = int(h.strip())
# 最小 100px 防护:避免前端传入宽高比(如 "9:16")被 parseInt 截断为极小值
if width < 100 or height < 100:
logger.warning(
"分辨率异常小 (%dx%d),使用默认值。原始值: %s",
width,
height,
resolution_str,
)
return DEFAULT_OUTPUT_WIDTH, DEFAULT_OUTPUT_HEIGHT
return width, height
except (ValueError, TypeError):
return DEFAULT_OUTPUT_WIDTH, DEFAULT_OUTPUT_HEIGHT
# ── 数据结构 ──────────────────────────────────────────────────────────────────
@dataclass
class RenderAdapterResult:
"""渲染适配结果。"""
success: bool
output_url: str = ""
output_path: Path | None = None
thumbnail_url: str = ""
duration: float = 0.0
file_size: int = 0
width: int = 0
height: int = 0
clip_count: int = 0
rendered_clip_ids: list[str] = None # 成功渲染的 clip id 列表
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": "..."}]
)
temp_dir: str | None = None # 渲染临时目录,成功时由调用方清理,失败时由 finally 清理
def __post_init__(self):
if self.rendered_clip_ids is None:
self.rendered_clip_ids = []
if self.failed_clip_ids is None:
self.failed_clip_ids = []
ProgressCallback = Callable[[float, str], None]
"""进度回调:(progress_0_100, stage_description) → None"""
# ── 适配层主体 ────────────────────────────────────────────────────────────────
class RenderAdapter:
"""统一渲染引擎适配层。
桥接 EditPlan 领域模型与 UnifiedRenderService 图层模型。
用法::
adapter = RenderAdapter(db)
result = adapter.render_plan(
plan_id=plan_id,
job_id=job_id,
progress_cb=lambda p, s: job_service.update_progress(job_id, p, s),
)
"""
def __init__(self, db: Session) -> None:
self._db = db
self._plan_repo = SQLAlchemyEditPlanRepository(db)
self._clip_repo = SQLAlchemyEditPlanClipRepository(db)
# ── 公开方法 ──────────────────────────────────────────────────────────
def render_plan(
self,
plan_id: str,
*,
job_id: str = "",
work_dir: Path | None = None,
progress_cb: ProgressCallback | None = None,
voiceover_audio_path: str | None = None,
) -> RenderAdapterResult:
"""渲染一个 EditPlan。
完整流程:
1. 加载计划与片段
2. 下载素材
3. 执行统一渲染
4. 上传结果
Args:
plan_id: EditPlan ID
job_id: 关联的 Job ID(用于结果存储路径)
work_dir: 工作目录,不传则使用临时目录
progress_cb: 进度回调函数
voiceover_audio_path: 配音音频本地路径(一键生成场景使用)
Returns:
RenderAdapterResult
"""
temp_dir = None
try:
# 0. 准备工作目录
if work_dir is None:
temp_dir = tempfile.mkdtemp(prefix="render_")
work_dir = Path(temp_dir)
work_dir.mkdir(parents=True, exist_ok=True)
self._report_progress(progress_cb, 5.0, "加载剪辑计划")
# 1. 加载计划与片段
plan = self._plan_repo.get(plan_id)
if plan is None:
return RenderAdapterResult(
success=False,
error_message=f"剪辑计划不存在: {plan_id}",
)
clips = self._clip_repo.list_by_plan(plan_id, skip=0, limit=10000)
ready_clips = [c for c in clips if c.status == EditPlanClipStatus.READY and c.asset_id]
ready_clips.sort(key=lambda c: c.order)
if not ready_clips:
return RenderAdapterResult(
success=False,
error_message="没有可渲染的就绪片段",
clip_count=0,
)
logger.info(
"开始渲染: plan_id=%s job_id=%s ready_clips=%d engine=unified",
plan_id,
job_id,
len(ready_clips),
)
self._report_progress(progress_cb, 15.0, f"下载素材({len(ready_clips)} 个)")
# 2. 下载素材
asset_path_map, rendered_clip_ids, failed_clip_ids = self._download_assets(ready_clips, work_dir)
if not asset_path_map:
return RenderAdapterResult(
success=False,
error_message="所有素材下载失败",
clip_count=len(ready_clips),
rendered_clip_ids=[],
failed_clip_ids=failed_clip_ids,
)
self._report_progress(progress_cb, 35.0, "准备 BGM 音频")
# 3~6. 统一渲染核心流程(BGM + ASR + 渲染 + 缩略图 + 上传)
result = self._do_render(
plan=plan,
clips=ready_clips,
asset_path_map=asset_path_map,
work_dir=work_dir,
plan_id=plan_id,
job_id=job_id,
progress_cb=progress_cb,
rendered_clip_ids=rendered_clip_ids,
failed_clip_ids=failed_clip_ids,
voiceover_audio_path=voiceover_audio_path,
)
# 成功时将临时目录所有权转移给调用方,阻止 finally 清理
if result.success and temp_dir:
result.temp_dir = temp_dir
temp_dir = None # 阻止 finally 块清理
return result
except subprocess.CalledProcessError as exc:
stderr_text = (exc.stderr or "").strip()
logger.error(
"[render-adapter] ffmpeg渲染失败: plan_id=%s job_id=%s exit_code=%d\nstderr:\n%s",
plan_id,
job_id,
exc.returncode,
stderr_text[-2000:] if len(stderr_text) > 2000 else stderr_text,
)
return RenderAdapterResult(
success=False,
error_message=f"FFmpeg渲染失败(exit={exc.returncode}): {stderr_text[:200]}",
error_detail=stderr_text[-2000:] if len(stderr_text) > 2000 else stderr_text,
)
except Exception as exc:
logger.exception(
"[render-adapter] render failed: plan_id=%s job_id=%s engine=unified error=%s",
plan_id,
job_id,
str(exc)[:200],
)
return RenderAdapterResult(
success=False,
error_message=str(exc)[:500],
)
finally:
# 清理临时目录
if temp_dir:
import shutil
try:
shutil.rmtree(temp_dir, ignore_errors=True)
except Exception as cleanup_err:
logger.warning("临时目录清理失败: path=%s error=%s", temp_dir, cleanup_err)
def validate_plan(self, plan_id: str) -> tuple[bool, list[str], list[str], int, int]:
"""校验计划是否可渲染(兼容 VideoComposeService.validate_compose 接口)。
Returns:
(valid, errors, warnings, ready_clip_count, total_clip_count)
"""
errors: list[str] = []
warnings: list[str] = []
plan = self._plan_repo.get(plan_id)
if plan is None:
return False, [f"剪辑计划不存在: {plan_id}"], [], 0, 0
if plan.status not in (EditPlanStatus.EDITING, EditPlanStatus.RENDERING):
errors.append(f"计划状态不正确,需要 editing 或 rendering,当前: {plan.status}")
clips = self._clip_repo.list_by_plan(plan_id, skip=0, limit=10000)
if not clips:
errors.append("计划没有任何片段")
return False, errors, warnings, 0, 0
clips.sort(key=lambda c: c.order)
ready_count = 0
pending_count = 0
no_asset_count = 0
for clip in clips:
if clip.status == EditPlanClipStatus.READY:
ready_count += 1
if not clip.asset_id:
errors.append(f"片段 {clip.id} (order={clip.order}) 没有分配素材")
no_asset_count += 1
elif clip.status == EditPlanClipStatus.PENDING:
pending_count += 1
elif clip.status == EditPlanClipStatus.FAILED:
warnings.append(f"片段 {clip.id} (order={clip.order}) 状态为 failed,已跳过")
if ready_count == 0:
errors.append("没有就绪(ready)的片段可以合成")
if pending_count > 0:
warnings.append(f"有 {pending_count} 个片段仍处于 pending 状态")
return len(errors) == 0, errors, warnings, ready_count, len(clips)
# ── 内部方法 ──────────────────────────────────────────────────────────
@staticmethod
def _report_progress(progress_cb: ProgressCallback | None, progress: float, stage: str) -> None:
"""上报进度。"""
if progress_cb is not None:
try:
progress_cb(progress, stage)
except Exception:
logger.exception("进度回调失败")
def _download_assets(
self, clips: list[EditPlanClip], work_dir: Path
) -> tuple[dict[str, Path], list[str], list[str]]:
"""下载片段素材到本地。
先通过 asset_id 批量查询 assets 表获取 file_urlOSS存储路径),
再用 file_url 作为 OSS key 下载。asset_id 是 UUID 主键,
不能直接当作 OSS 存储路径使用。
Returns:
(asset_path_map, rendered_clip_ids, failed_clip_ids)
- asset_path_map: asset_id → local_path 映射(下载成功的)
- rendered_clip_ids: 下载成功的 clip id 列表
- failed_clip_ids: 下载失败的 clip id 列表
"""
asset_dir = work_dir / "assets"
asset_dir.mkdir(exist_ok=True)
asset_path_map: dict[str, Path] = {}
rendered_clip_ids: list[str] = []
failed_clip_ids: list[str] = []
seen_asset_ids: set[str] = set()
# 批量查询素材的 storage_keyOSS 存储路径)
# 兼容存量数据:storage_key 为空时 fallback 到 file_url
clip_asset_ids = [c.asset_id for c in clips if c.asset_id]
asset_storage_map: dict[str, str] = {}
if clip_asset_ids:
assets = self._db.query(AssetModel).filter(AssetModel.id.in_(clip_asset_ids)).all()
asset_storage_map = {
a.id: (a.storage_key or a.file_url or "") for a in assets if a.storage_key or a.file_url
}
for clip in clips:
asset_id = clip.asset_id
if not asset_id:
failed_clip_ids.append(clip.id)
continue
# 同一素材已下载过(多个 clip 共享同一素材)
if asset_id in seen_asset_ids:
if asset_id in asset_path_map:
rendered_clip_ids.append(clip.id)
else:
failed_clip_ids.append(clip.id)
continue
seen_asset_ids.add(asset_id)
# 从素材表获取 OSS 存储路径(file_url
storage_key = asset_storage_map.get(asset_id)
if not storage_key:
logger.warning(
"素材无 file_url,跳过下载: clip_id=%s asset_id=%s",
clip.id,
asset_id,
)
failed_clip_ids.append(clip.id)
continue
# 生成安全的本地文件名(保留原始扩展名)
ext = Path(storage_key).suffix or ".mp4"
safe_name = f"clip_{clip.order:04d}_{abs(hash(asset_id)) % 100000:05d}{ext}"
local_path = asset_dir / safe_name
if download_asset(storage_key, local_path):
asset_path_map[asset_id] = local_path
rendered_clip_ids.append(clip.id)
logger.debug("素材下载成功: clip_id=%s asset_id=%s", clip.id, asset_id[:60])
else:
failed_clip_ids.append(clip.id)
logger.warning("素材下载失败: clip_id=%s asset_id=%s", clip.id, asset_id[:60])
return asset_path_map, rendered_clip_ids, failed_clip_ids
def _prepare_bgm(self, plan, work_dir: Path, plan_id: str) -> str | None:
"""准备 BGM 音频文件(从 plan.config.bgm 读取配置)。
支持 3 种来源(按优先级):
1. audio_url — 外部直链 URL
2. asset_id — 素材库中的音频素材
3. preset_id — 预设 BGM 库
失败不阻断主流程,返回 None。
"""
from urllib.parse import urlparse
plan_config = plan.config or {}
bgm_config = plan_config.get("bgm", {}) or {}
if not bgm_config.get("enabled", False):
return None
audio_url = bgm_config.get("audio_url", "") or ""
asset_id = bgm_config.get("asset_id", "") or ""
preset_id = bgm_config.get("preset_id", "") or ""
bgm_file = work_dir / "bgm.mp3"
# 优先级1:外部直链 URL
if audio_url:
try:
parsed = urlparse(audio_url)
if parsed.scheme in ("http", "https"):
from video_processing.url_security import (
ALLOWED_AUDIO_MIME_TYPES,
safe_download_file,
)
logger.info("[plan_id=%s] [BGM] 从URL下载: %s", plan_id, audio_url[:80])
safe_download_file(
audio_url,
str(bgm_file),
purpose="bgm_download",
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
timeout=60.0,
)
if bgm_file.exists() and bgm_file.stat().st_size > 0:
return str(bgm_file)
except Exception as e:
logger.warning("[plan_id=%s] [BGM] URL下载失败: %s", plan_id, e)
# 优先级2:素材库素材
if asset_id:
try:
from packages.adapters.sqlalchemy_impl.models import AssetModel
model = self._db.query(AssetModel).filter(AssetModel.id == asset_id).first()
if model and (model.storage_key or model.file_url):
# 兼容存量数据:storage_key 为空时 fallback 到 file_url
storage_key = model.storage_key or model.file_url
logger.info("[plan_id=%s] [BGM] 从素材库下载: asset_id=%s", plan_id, asset_id)
ok = download_asset(storage_key, bgm_file)
if ok and bgm_file.exists() and bgm_file.stat().st_size > 0:
return str(bgm_file)
except Exception as e:
logger.warning("[plan_id=%s] [BGM] 素材库下载失败: %s", plan_id, e)
# 优先级3:预设 BGM 库
if preset_id:
try:
from packages.domain.preset_bgm import get_preset_bgm
preset = get_preset_bgm(preset_id)
if preset and preset.audio_url:
from video_processing.url_security import (
ALLOWED_AUDIO_MIME_TYPES,
safe_download_file,
)
logger.info("[plan_id=%s] [BGM] 从预设库下载: preset_id=%s", plan_id, preset_id)
safe_download_file(
preset.audio_url,
str(bgm_file),
purpose="bgm_preset_download",
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
timeout=60.0,
)
if bgm_file.exists() and bgm_file.stat().st_size > 0:
return str(bgm_file)
except Exception as e:
logger.warning("[plan_id=%s] [BGM] 预设库下载失败: %s", plan_id, e)
logger.warning("[plan_id=%s] [BGM] 所有来源都无法获取BGM,跳过", plan_id)
return None
@staticmethod
def _get_asr_service() -> Any | None:
"""获取 ASR 服务实例(用于自动生成字幕)。
失败不阻断主流程,返回 None。
"""
try:
from apps.worker.services.asr_service_factory import get_asr_service
return get_asr_service()
except Exception as e:
logger.warning("ASR 服务初始化失败,自动字幕将不可用: %s", e)
return None
def _do_render(
self,
plan: Any,
clips: list[Any],
asset_path_map: dict[str, Path],
work_dir: Path,
*,
plan_id: str,
job_id: str = "",
progress_cb: ProgressCallback | None = None,
rendered_clip_ids: list[str] | None = None,
failed_clip_ids: list[str] | None = None,
voiceover_audio_path: str | None = None,
) -> RenderAdapterResult:
"""执行统一渲染核心流程(BGM + ASR + 渲染 + 缩略图 + 上传)。
render_plan 和 render_from_memory 共用此方法。
Args:
rendered_clip_ids: 成功下载/准备的 clip id 列表(render_plan 从下载阶段传入)
failed_clip_ids: 失败的 clip id 列表
voiceover_audio_path: 配音素材库音频本地路径(一键生成场景使用)
Returns:
RenderAdapterResult
"""
# 1. 准备 BGM
bgm_path = self._prepare_bgm(plan, work_dir, plan_id)
self._report_progress(progress_cb, 40.0, "执行视频渲染")
# 2. 初始化 ASR
plan_config = plan.config or {}
asr_service = self._get_asr_service()
# 3. 读取输出分辨率
export_config = plan_config.get("export", {}) or {}
if not isinstance(export_config, dict):
export_config = {}
output_width, output_height = _parse_resolution(export_config.get("resolution"))
logger.info(
"渲染输出分辨率: plan_id=%s resolution=%dx%d source=%s",
plan_id,
output_width,
output_height,
"config" if export_config.get("resolution") else "default",
)
# 4. 执行统一渲染
render_svc = UnifiedRenderService(
plan=plan,
clips=clips,
asset_path_map=asset_path_map,
work_dir=work_dir,
output_width=output_width,
output_height=output_height,
bgm_path=bgm_path,
asr_service=asr_service,
voiceover_audio_path=voiceover_audio_path,
)
result = render_svc.render()
# 4.5 渲染后校验输出完整性
validation = validate_video_output(result.output_path)
if not validation.valid:
logger.error(
"[render-adapter] 渲染输出校验失败: plan_id=%s job_id=%s error=%s",
plan_id,
job_id,
validation.error_message,
)
return RenderAdapterResult(
success=False,
error_message=f"渲染输出校验失败: {validation.error_message}",
error_detail=validation.error_message,
)
self._report_progress(progress_cb, 80.0, "上传渲染结果")
# 5. 上传结果
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, "生成封面缩略图")
# 6. 生成封面缩略图
thumbnail_url = ""
try:
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
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
# 已渲染视频在统一渲染阶段已通过 ASS 字幕把标题烧录进画面,
# 抽帧天然带标题,因此这里传空字符串,避免 Pillow 二次叠加导致重影。
# Pillow 叠加仅用于 API 从源素材抽帧(源素材本身无标题)的兜底场景。
cover_candidates = extract_and_upload_cover_frames(
str(result.output_path), plan_id, num_frames=3, title_text=""
)
if cover_candidates:
logger.info(
"[render-adapter] 封面候选帧生成成功: plan_id=%s count=%d",
plan_id,
len(cover_candidates),
)
except Exception as cover_err:
logger.warning(
"[render-adapter] 封面候选帧生成失败(不影响主流程): plan_id=%s error=%s",
plan_id,
cover_err,
)
self._report_progress(progress_cb, 100.0, "渲染完成")
logger.info(
"[render-adapter] render success: plan_id=%s job_id=%s engine=unified "
"duration=%.2fs file_size=%d resolution=%dx%d clip_count=%d",
plan_id,
job_id,
result.duration,
result.file_size,
result.width,
result.height,
len(clips),
)
final_rendered_ids = (
rendered_clip_ids if rendered_clip_ids is not None else [c.id for c in clips if hasattr(c, "id")]
)
final_failed_ids = failed_clip_ids if failed_clip_ids is not None else []
return RenderAdapterResult(
success=True,
output_url=output_url or "",
output_path=result.output_path,
thumbnail_url=thumbnail_url,
duration=result.duration,
file_size=result.file_size,
width=result.width,
height=result.height,
clip_count=len(clips),
rendered_clip_ids=final_rendered_ids,
failed_clip_ids=final_failed_ids,
cover_candidates=cover_candidates,
)
def render_from_memory(
self,
plan: Any,
clips: list[Any],
asset_path_map: dict[str, Path],
*,
plan_id: str = "",
job_id: str = "",
work_dir: Path | None = None,
progress_cb: ProgressCallback | None = None,
voiceover_audio_path: str | None = None,
) -> RenderAdapterResult:
"""使用内存中的 plan/clips/asset_path_map 直接渲染。
适用于一键生成等不写DB剪辑计划的场景,复用统一的 BGM/ASR/分辨率/渲染/缩略图逻辑。
Args:
plan: 类 EditPlan 的对象(鸭子类型,需有 id/config 等属性)
clips: 类 EditPlanClip 的对象列表
asset_path_map: asset_id → local_path 映射
plan_id: 用于日志的计划标识(不传则用 plan.id)
job_id: 关联的 Job ID
work_dir: 工作目录,不传则用临时目录
progress_cb: 进度回调
voiceover_audio_path: 配音素材库音频本地路径
Returns:
RenderAdapterResult
"""
actual_plan_id = plan_id or getattr(plan, "id", "memory_plan")
temp_dir = None
try:
if work_dir is None:
temp_dir = tempfile.mkdtemp(prefix="render_mem_")
work_dir = Path(temp_dir)
work_dir.mkdir(parents=True, exist_ok=True)
if not clips:
return RenderAdapterResult(
success=False,
error_message="没有可渲染的片段",
clip_count=0,
)
if not asset_path_map:
return RenderAdapterResult(
success=False,
error_message="素材路径映射为空",
clip_count=len(clips),
)
logger.info(
"开始内存模式渲染: plan_id=%s job_id=%s clip_count=%d engine=unified",
actual_plan_id,
job_id,
len(clips),
)
self._report_progress(progress_cb, 35.0, "准备 BGM 音频")
return self._do_render(
plan=plan,
clips=clips,
asset_path_map=asset_path_map,
work_dir=work_dir,
plan_id=actual_plan_id,
job_id=job_id,
progress_cb=progress_cb,
voiceover_audio_path=voiceover_audio_path,
)
except subprocess.CalledProcessError as exc:
stderr_text = (exc.stderr or "").strip()
logger.error(
"[render-adapter] 内存模式渲染失败: plan_id=%s exit_code=%d\nstderr:\n%s",
actual_plan_id,
exc.returncode,
stderr_text[-2000:] if len(stderr_text) > 2000 else stderr_text,
)
return RenderAdapterResult(
success=False,
error_message=f"FFmpeg渲染失败(exit={exc.returncode}): {stderr_text[:200]}",
error_detail=stderr_text[-2000:] if len(stderr_text) > 2000 else stderr_text,
)
except Exception as exc:
logger.exception(
"[render-adapter] 内存模式渲染失败: plan_id=%s error=%s",
actual_plan_id,
str(exc)[:200],
)
return RenderAdapterResult(
success=False,
error_message=str(exc)[:500],
)
finally:
if temp_dir:
import shutil
try:
shutil.rmtree(temp_dir, ignore_errors=True)
except Exception as cleanup_err:
logger.warning("临时目录清理失败: path=%s error=%s", temp_dir, cleanup_err)