From 30094bc591301b61647ab7fe75bfda87cd76f362 Mon Sep 17 00:00:00 2001 From: saas-backend-agent Date: Mon, 14 Sep 2026 19:27:29 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(backend):=20#1901=20=E7=BB=9F=E4=B8=80t?= =?UTF-8?q?itle=E5=AD=97=E6=AE=B5=E5=90=8D=E4=BF=AE=E5=A4=8D=E6=A0=87?= =?UTF-8?q?=E9=A2=98=E6=B8=B2=E6=9F=93=E4=B8=8D=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: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 继续渲染 --- apps/api/app/api/routes/generation_cover.py | 5 ++ apps/api/app/services/generation_common.py | 20 +++++-- .../subtitle_render_engine.py | 10 +++- .../unified_render_service.py | 5 ++ tests/unit/test_generation_common.py | 53 ++++++++++++++++--- tests/unit/test_writeback_edit_plan_config.py | 14 +++-- 6 files changed, 90 insertions(+), 17 deletions(-) diff --git a/apps/api/app/api/routes/generation_cover.py b/apps/api/app/api/routes/generation_cover.py index 766637849..258c9b786 100644 --- a/apps/api/app/api/routes/generation_cover.py +++ b/apps/api/app/api/routes/generation_cover.py @@ -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 "" diff --git a/apps/api/app/services/generation_common.py b/apps/api/app/services/generation_common.py index fd39fbe6b..7f4ac971a 100644 --- a/apps/api/app/services/generation_common.py +++ b/apps/api/app/services/generation_common.py @@ -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() diff --git a/apps/worker/video_processing/subtitle_render_engine.py b/apps/worker/video_processing/subtitle_render_engine.py index f37f8ba6f..d266aa741 100755 --- a/apps/worker/video_processing/subtitle_render_engine.py +++ b/apps/worker/video_processing/subtitle_render_engine.py @@ -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) diff --git a/apps/worker/video_processing/unified_render_service.py b/apps/worker/video_processing/unified_render_service.py index f03b3788f..c6dfa532d 100755 --- a/apps/worker/video_processing/unified_render_service.py +++ b/apps/worker/video_processing/unified_render_service.py @@ -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 {} diff --git a/tests/unit/test_generation_common.py b/tests/unit/test_generation_common.py index baa1b9d38..554110bfa 100644 --- a/tests/unit/test_generation_common.py +++ b/tests/unit/test_generation_common.py @@ -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 diff --git a/tests/unit/test_writeback_edit_plan_config.py b/tests/unit/test_writeback_edit_plan_config.py index e218f07c0..363764826 100644 --- a/tests/unit/test_writeback_edit_plan_config.py +++ b/tests/unit/test_writeback_edit_plan_config.py @@ -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" From ffb40038d36a4357f34bb54bdd70c7d5111cd26a Mon Sep 17 00:00:00 2001 From: CI Bot Date: Mon, 14 Sep 2026 11:38:53 +0000 Subject: [PATCH 2/2] style: auto-format with black + isort + ruff + prettier [skip ci-format-check] --- tests/unit/test_generation_common.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_generation_common.py b/tests/unit/test_generation_common.py index 554110bfa..56ca89c0e 100644 --- a/tests/unit/test_generation_common.py +++ b/tests/unit/test_generation_common.py @@ -157,7 +157,8 @@ class TestWritebackEditPlanConfig: db = MagicMock() db.query.return_value.filter.return_value.first.return_value = plan writeback_edit_plan_config( - "p1", "t1", + "p1", + "t1", {"text": "hi", "font_size": 32, "font_preset": "楷体", "font_color": "#ff0000", "bold": True}, db, )