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..56ca89c0e 100644 --- a/tests/unit/test_generation_common.py +++ b/tests/unit/test_generation_common.py @@ -107,29 +107,68 @@ 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 +178,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"