fix(backend): #1901 统一title字段名修复标题渲染不一致
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m20s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m7s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m41s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m32s
AI Code Review / AI Code Review (pull_request) Successful in 6m31s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 6m50s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 8m12s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 8m19s
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 43h50m31s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 43h50m37s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 43h51m57s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 43h50m21s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 43h49m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 43h49m59s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 43h50m2s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 43h50m3s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 43h50m8s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 43h50m8s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 43h49m39s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 43h49m39s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 43h51m28s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 43h52m50s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m20s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m7s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m41s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m32s
AI Code Review / AI Code Review (pull_request) Successful in 6m31s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 6m50s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 8m12s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 8m19s
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Failing after 43h50m31s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 43h50m37s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 43h51m57s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 43h50m21s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 43h49m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 43h49m59s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Failing after 43h50m2s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Failing after 43h50m3s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 43h50m8s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 43h50m8s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 43h49m39s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 43h49m39s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 43h51m28s
CI/CD Pipeline / Check push changed paths (pull_request) Failing after 43h52m50s
问题:plan.config中标题配置字段名双轨不一致——Worker sync_configs_to_plan 写 plan.config['title'](归一化 size/font/color),API writeback_edit_plan_config 写 plan.config['title_config'](不归一化),三个渲染引擎读取不一致: - video_compose_service(FFmpeg drawtext):已读 title+fallback title_config ✅ - unified_render_service(ASS字幕):只 title 无 fallback ❌ - subtitle_render_engine(字幕渲染):只读 title_config ❌ 修复: - packages/application/generation_common.py writeback_edit_plan_config:统一写 plan.config['title'](而非 title_config),并做字段名归一化 font_size→size/font_preset→font/font_color→color,与 Worker sync_configs_to_plan 行为一致 - apps/worker/video_processing/subtitle_render_engine.py:_resolve_title_config 先读 plan.config['title'],fallback plan.config['title_config'](兼容存量老数据) - apps/worker/video_processing/unified_render_service.py:同样先读 'title',text 为空时 fallback 'title_config' - apps/api/app/api/routes/generation_cover.py:封面E2标题配置读取加 title_config fallback - 单测适配:test_generation_common.py/test_writeback_edit_plan_config.py 断言改 'title' 并验证归一化 - 所有渲染路径(智能剪辑/AI数字人/封面E2)统一读 'title',老数据通过 fallback 继续渲染
This commit is contained in:
@@ -766,7 +766,12 @@ def generate_cover(
|
||||
storage_svc = get_shared_storage_service()
|
||||
mk_client = get_mediakit_client()
|
||||
# 从 plan.config 读取完整标题样式,E2 从源素材抽帧时叠加(源素材本身无标题)
|
||||
# #1901 统一读 "title",兼容老数据 "title_config"
|
||||
_e2_title_cfg = (plan.config or {}).get("title", {}) or {}
|
||||
if not isinstance(_e2_title_cfg, dict) or not (_e2_title_cfg.get("text") or "").strip():
|
||||
_alt = (plan.config or {}).get("title_config", {}) or {}
|
||||
if isinstance(_alt, dict):
|
||||
_e2_title_cfg = _alt
|
||||
if not isinstance(_e2_title_cfg, dict):
|
||||
_e2_title_cfg = {}
|
||||
_e2_title_text = (_e2_title_cfg.get("text", "") or "").strip() if _e2_title_cfg.get("enabled", True) else ""
|
||||
|
||||
@@ -82,8 +82,12 @@ def writeback_edit_plan_config(
|
||||
merged["generation_task_id"] = task_id
|
||||
|
||||
if title_config:
|
||||
old_title_config = merged.get("title_config", {}) or {}
|
||||
old_title_text = (old_title_config.get("text") or "").strip()
|
||||
# #1901 统一字段名为 "title"(worker sync_configs_to_plan 写的是 "title")
|
||||
# 先读取新旧两个 key,判断标题文字是否变化
|
||||
old_title_cfg = merged.get("title", {}) or {}
|
||||
if not isinstance(old_title_cfg, dict) or not (old_title_cfg.get("text") or "").strip():
|
||||
old_title_cfg = merged.get("title_config", {}) or {}
|
||||
old_title_text = (old_title_cfg.get("text") or "").strip() if isinstance(old_title_cfg, dict) else ""
|
||||
new_title_text = (title_config.get("text") or "").strip()
|
||||
if old_title_text != new_title_text:
|
||||
if "cover" in merged:
|
||||
@@ -94,7 +98,17 @@ def writeback_edit_plan_config(
|
||||
old_title_text,
|
||||
new_title_text,
|
||||
)
|
||||
merged["title_config"] = title_config
|
||||
# 字段名归一化(font_size→size, font_preset→font, font_color→color),与 worker sync_configs_to_plan 保持一致
|
||||
normalized = dict(title_config)
|
||||
if "font_size" in normalized and "size" not in normalized:
|
||||
normalized["size"] = normalized["font_size"]
|
||||
if "font_preset" in normalized and "font" not in normalized:
|
||||
normalized["font"] = normalized["font_preset"]
|
||||
if "font_color" in normalized and "color" not in normalized:
|
||||
normalized["color"] = normalized["font_color"]
|
||||
merged["title"] = normalized
|
||||
# 清掉旧 key,避免双字段并存
|
||||
merged.pop("title_config", None)
|
||||
|
||||
plan_model.config = merged
|
||||
db.commit()
|
||||
|
||||
@@ -321,8 +321,14 @@ def build_subtitles_from_plan(
|
||||
|
||||
has_any = False
|
||||
|
||||
# 1. 标题
|
||||
title_cfg = plan_config.get("title_config") or {}
|
||||
# 1. 标题(#1901 统一读 "title",兼容老数据 "title_config")
|
||||
title_cfg = plan_config.get("title") or {}
|
||||
if not isinstance(title_cfg, dict) or not (title_cfg.get("text") or "").strip():
|
||||
_alt = plan_config.get("title_config") or {}
|
||||
if isinstance(_alt, dict):
|
||||
title_cfg = _alt
|
||||
if not isinstance(title_cfg, dict):
|
||||
title_cfg = {}
|
||||
if isinstance(title_cfg, dict):
|
||||
title_text = str(title_cfg.get("text", ""))
|
||||
title_enabled = title_cfg.get("enabled", True)
|
||||
|
||||
@@ -572,7 +572,12 @@ class UnifiedRenderService:
|
||||
ASS 文件路径,没有字幕时返回 None
|
||||
"""
|
||||
config = self.plan.config or {}
|
||||
# #1901 统一读 "title",兼容老数据 "title_config"
|
||||
title_cfg = config.get("title", {}) or {}
|
||||
if not isinstance(title_cfg, dict) or not (title_cfg.get("text") or "").strip():
|
||||
_alt = config.get("title_config") or {}
|
||||
if isinstance(_alt, dict):
|
||||
title_cfg = _alt
|
||||
if not isinstance(title_cfg, dict):
|
||||
title_cfg = {}
|
||||
subtitle_cfg = config.get("subtitle", {}) or {}
|
||||
|
||||
@@ -107,29 +107,67 @@ class TestWritebackEditPlanConfig:
|
||||
writeback_edit_plan_config("p1", "task-xyz", None, db)
|
||||
assert plan.config["generation_task_id"] == "task-xyz"
|
||||
assert plan.config["other"] == "keep-me"
|
||||
assert "title_config" not in plan.config
|
||||
assert "title" not in plan.config or not plan.config.get("title")
|
||||
db.commit.assert_called_once()
|
||||
|
||||
def test_merges_title_config_without_title_change(self):
|
||||
def test_merges_title_without_title_change(self):
|
||||
"""#1901: 写 'title' 字段,未变标题保留 cover。"""
|
||||
from app.services.generation_common import writeback_edit_plan_config
|
||||
|
||||
plan = _make_plan_model({"title": {"text": "old"}, "cover": "x"})
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.first.return_value = plan
|
||||
writeback_edit_plan_config("p1", "t1", {"text": "old"}, db)
|
||||
assert plan.config["title"] == {"text": "old"}
|
||||
# 旧 key 不应残留
|
||||
assert "title_config" not in plan.config
|
||||
# 标题未变 → cover 保留
|
||||
assert plan.config.get("cover") == "x"
|
||||
|
||||
def test_merges_title_fallback_to_old_title_config_key(self):
|
||||
"""#1901: 老数据存在 title_config(无 title)时,也能正确识别旧标题文字。"""
|
||||
from app.services.generation_common import writeback_edit_plan_config
|
||||
|
||||
plan = _make_plan_model({"title_config": {"text": "old"}, "cover": "x"})
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.first.return_value = plan
|
||||
writeback_edit_plan_config("p1", "t1", {"text": "old"}, db)
|
||||
assert plan.config["title_config"] == {"text": "old"}
|
||||
# 标题未变 → cover 保留
|
||||
# 写入新 key "title",旧 key 被清除
|
||||
assert plan.config["title"] == {"text": "old"}
|
||||
assert "title_config" not in plan.config
|
||||
assert plan.config.get("cover") == "x"
|
||||
|
||||
def test_title_change_clears_cover(self):
|
||||
"""#1901: 标题变化时清 cover,新配置写到 'title'。"""
|
||||
from app.services.generation_common import writeback_edit_plan_config
|
||||
|
||||
plan = _make_plan_model({"title_config": {"text": "old"}, "cover": "x"})
|
||||
plan = _make_plan_model({"title": {"text": "old"}, "cover": "x"})
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.first.return_value = plan
|
||||
writeback_edit_plan_config("p1", "t1", {"text": "new-title"}, db)
|
||||
assert "cover" not in plan.config
|
||||
assert plan.config["title_config"] == {"text": "new-title"}
|
||||
assert plan.config["title"] == {"text": "new-title"}
|
||||
assert "title_config" not in plan.config
|
||||
|
||||
def test_title_config_normalizes_legacy_keys(self):
|
||||
"""#1901: 写入时归一化 font_size/font_preset/font_color → size/font/color,与 worker 对齐。"""
|
||||
from app.services.generation_common import writeback_edit_plan_config
|
||||
|
||||
plan = _make_plan_model({})
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.first.return_value = plan
|
||||
writeback_edit_plan_config(
|
||||
"p1", "t1",
|
||||
{"text": "hi", "font_size": 32, "font_preset": "楷体", "font_color": "#ff0000", "bold": True},
|
||||
db,
|
||||
)
|
||||
title = plan.config["title"]
|
||||
assert title["text"] == "hi"
|
||||
assert title["size"] == 32
|
||||
assert title["font"] == "楷体"
|
||||
assert title["color"] == "#ff0000"
|
||||
# 原始 key 保留(方便调用方排查,但归一化后的 key 必须存在)
|
||||
assert title["font_size"] == 32
|
||||
|
||||
def test_config_not_dict_treated_as_empty(self):
|
||||
from app.services.generation_common import writeback_edit_plan_config
|
||||
@@ -139,7 +177,8 @@ class TestWritebackEditPlanConfig:
|
||||
db.query.return_value.filter.return_value.first.return_value = plan
|
||||
writeback_edit_plan_config("p1", "t1", {"text": "hi"}, db)
|
||||
assert plan.config["generation_task_id"] == "t1"
|
||||
assert plan.config["title_config"] == {"text": "hi"}
|
||||
assert plan.config["title"] == {"text": "hi"}
|
||||
assert "title_config" not in plan.config
|
||||
|
||||
def test_exception_triggers_rollback_and_logs(self, caplog):
|
||||
from app.services.generation_common import writeback_edit_plan_config
|
||||
|
||||
@@ -57,7 +57,7 @@ class TestWritebackEditPlanConfig:
|
||||
mock_db.query.assert_called_once()
|
||||
mock_db.commit.assert_not_called()
|
||||
|
||||
# ---- 行 170-182: 正常写入 + title_config ----
|
||||
# ---- 行 170-182: 正常写入 + title(#1901 统一字段名) ----
|
||||
def test_success_with_title_config(self, mock_db, mock_plan):
|
||||
mock_db.first.return_value = mock_plan
|
||||
|
||||
@@ -69,7 +69,11 @@ class TestWritebackEditPlanConfig:
|
||||
)
|
||||
|
||||
assert mock_plan.config["generation_task_id"] == "task_456"
|
||||
assert mock_plan.config["title_config"] == {"text": "标题", "font_size": 36}
|
||||
# #1901: 统一写到 "title" 字段,且 font_size 已归一化为 size
|
||||
assert mock_plan.config["title"]["text"] == "标题"
|
||||
assert mock_plan.config["title"]["size"] == 36
|
||||
assert mock_plan.config["title"]["font_size"] == 36
|
||||
assert "title_config" not in mock_plan.config
|
||||
assert mock_plan.config["existing_key"] == "existing_value"
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
@@ -80,7 +84,7 @@ class TestWritebackEditPlanConfig:
|
||||
_writeback_edit_plan_config(plan_id="plan_123", task_id="task_789", title_config=None, db=mock_db)
|
||||
|
||||
assert mock_plan.config["generation_task_id"] == "task_789"
|
||||
assert "title_config" not in mock_plan.config
|
||||
assert "title" not in mock_plan.config or not mock_plan.config.get("title")
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
# ---- 行 170: config 不是 dict → 兜底空 dict ----
|
||||
@@ -115,12 +119,12 @@ class TestWritebackEditPlanConfig:
|
||||
_writeback_edit_plan_config(plan_id="plan_123", task_id="task_1", title_config=None, db=mock_db)
|
||||
mock_db.rollback.assert_called_once()
|
||||
|
||||
# ---- 行 173: title_config 为空 dict → 不写入 title_config ----
|
||||
# ---- 行 173: title_config 为空 dict → 不写入 title ----
|
||||
def test_empty_title_config_not_written(self, mock_db, mock_plan):
|
||||
mock_db.first.return_value = mock_plan
|
||||
|
||||
_writeback_edit_plan_config(plan_id="plan_123", task_id="task_1", title_config={}, db=mock_db)
|
||||
|
||||
# 空 dict 为 falsy,不写入
|
||||
assert "title_config" not in mock_plan.config
|
||||
assert "title" not in mock_plan.config or not mock_plan.config.get("title")
|
||||
assert mock_plan.config["generation_task_id"] == "task_1"
|
||||
|
||||
Reference in New Issue
Block a user