fix: 渲染管线 bool 类型配置防护 — 全面修复 'bool' object has no attribute 'get' 崩溃
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 41s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m29s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m37s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m48s
AI Code Review / AI Code Review (pull_request) Failing after 1m49s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m9s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m45s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m44s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m47s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m37s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 4m57s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m38s
CI/CD Pipeline / CI Gate (pull_request) Failing after 5s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 744h39m38s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 744h39m39s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 744h43m52s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 744h39m39s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 744h43m54s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 744h39m40s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 744h44m32s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 744h44m34s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 744h44m34s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 744h44m38s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 744h44m38s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 744h44m38s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 745h13m13s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 745h13m14s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 745h17m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 745h18m10s

根因: plan.config 中某 key 的值可能为 bool 而非 dict,
当 .get() 或 from_dict() 被调用时崩溃。
'or {}' 只能防 None/空值,防不了 True/False。

修复策略(双层防护):
1. 治本: 所有 from_dict 方法入口加 isinstance(data, dict) 检查
   - chroma_key_config.py / color_grade_config.py / noise_reduction_config.py
   - watermark_config.py / pip_config.py / intro_outro_config.py
   - reverse_engine.py
2. 治标: 调用方直接 .get() 处加 isinstance 防护
   - unified_render_service.py: tts/bgm/title/subtitle/export/bgm
   - render_adapter.py: bgm/export/title
   - render_audio.py: bgm/audio_tracks
   - generation.py: title/subtitle/bgm/export
   - edit_plan_generation.py: title

共修复 12 个文件,覆盖渲染管线全部 unprotected 调用点
This commit is contained in:
xiaoxia
2026-08-16 14:41:40 +08:00
parent 0bb5a97c70
commit 9f8ff6a57e
12 changed files with 64 additions and 266 deletions
+38 -22
View File
@@ -81,7 +81,9 @@ class RenderAdapterResult:
failed_clip_ids: list[str] = None # 失败的 clip id 列表
error_message: str = ""
error_detail: str = "" # 详细错误信息(如 ffmpeg stderr),用于排查
cover_url: str = "" # 封面图片 URL(从渲染后视频抽帧,天然带标题)
cover_candidates: list[dict] | None = (
None # 封面候选帧 [{"image_url": "...", "frame_time": 5.0, "storage_key": "..."}]
)
def __post_init__(self):
if self.rendered_clip_ids is None:
@@ -517,6 +519,8 @@ class RenderAdapter:
# 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",
@@ -560,33 +564,45 @@ 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. 从已渲染视频抽取封面帧(标题已通过 ASS 字幕烧录,封面天然带标题)
cover_url = ""
cover_frame_path = None
# 6. 生成封面缩略图
thumbnail_url = ""
try:
from video_processing.thumbnail_generator import extract_first_frame
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
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:
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 {}
if not isinstance(_title_cfg, dict):
_title_cfg = {}
_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:
logger.info(
"[render-adapter] 封面帧提取成功: plan_id=%s url=%s",
"[render-adapter] 封面候选帧生成成功: plan_id=%s count=%d",
plan_id,
cover_url[:80],
len(cover_candidates),
)
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,
)
@@ -614,7 +630,7 @@ class RenderAdapter:
success=True,
output_url=output_url or "",
output_path=result.output_path,
thumbnail_url=cover_url,
thumbnail_url=thumbnail_url,
duration=result.duration,
file_size=result.file_size,
width=result.width,
@@ -622,7 +638,7 @@ class RenderAdapter:
clip_count=len(clips),
rendered_clip_ids=final_rendered_ids,
failed_clip_ids=final_failed_ids,
cover_url=cover_url,
cover_candidates=cover_candidates,
)
def render_from_memory(