diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py index d6afc7219..df4d73e51 100755 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -169,8 +169,22 @@ def _writeback_edit_plan_config( current_config = plan_model.config if isinstance(plan_model.config, dict) else {} merged = dict(current_config) merged["generation_task_id"] = task_id + + # 检查标题是否发生变化,如果变化则清除 cover 字段强制重新生成封面 if title_config: + old_title_config = merged.get("title_config", {}) or {} + old_title_text = (old_title_config.get("text") or "").strip() + new_title_text = (title_config.get("text") or "").strip() + if old_title_text != new_title_text: + # 标题变化,清除旧封面 + if "cover" in merged: + del merged["cover"] + logger.info( + "[生成任务] 标题变化,清除旧封面: plan_id=%s old_title=%s new_title=%s", + plan_id, old_title_text, new_title_text, + ) merged["title_config"] = title_config + plan_model.config = merged db.commit() logger.info( diff --git a/apps/worker/video_processing/render_adapter.py b/apps/worker/video_processing/render_adapter.py index 295d32464..4f5cb5444 100755 --- a/apps/worker/video_processing/render_adapter.py +++ b/apps/worker/video_processing/render_adapter.py @@ -598,7 +598,7 @@ class RenderAdapter: # 抽帧天然带标题,因此这里传空字符串,避免 Pillow 二次叠加导致重影。 # Pillow 叠加仅用于 API 从源素材抽帧(源素材本身无标题)的兜底场景。 cover_candidates = extract_and_upload_cover_frames( - str(result.output_path), plan_id, num_frames=3, title_text="" + str(result.output_path), plan_id, task_id=job_id, num_frames=3, title_text="" ) if cover_candidates: logger.info( diff --git a/apps/worker/video_processing/thumbnail_generator.py b/apps/worker/video_processing/thumbnail_generator.py index a53163d4d..4282c0027 100755 --- a/apps/worker/video_processing/thumbnail_generator.py +++ b/apps/worker/video_processing/thumbnail_generator.py @@ -278,6 +278,7 @@ def extract_and_upload_cover_frames( video_path: str, plan_id: str, *, + task_id: str = "", num_frames: int = 3, title_text: str = "", title_color: str = "#ffffff", @@ -291,6 +292,7 @@ def extract_and_upload_cover_frames( Args: video_path: 视频文件路径 plan_id: 编辑计划 ID(用于生成 storage key) + task_id: 任务 ID(用于生成独立的 storage key,避免标题变更时封面冲突) num_frames: 抽取帧数(默认 3) title_text: 标题文字;非空时用 Pillow 叠加到每帧。 从已渲染视频抽帧时通常传空(标题已烧录);从源素材抽帧时传标题。 @@ -338,7 +340,7 @@ def extract_and_upload_cover_frames( font_size=title_font_size, ) - storage_key = f"covers/{plan_id}/mediakit_frame_{i}.jpg" + storage_key = f"covers/{plan_id}/{task_id}/mediakit_frame_{i}.jpg" url = upload_to_oss(tmp.name, storage_key) if url: seek_time = frame.get("timestamp", 0.0) @@ -377,7 +379,7 @@ def extract_and_upload_cover_frames( position=title_position, font_size=title_font_size, ) - storage_key = f"covers/{plan_id}/frame_{i}.jpg" + storage_key = f"covers/{plan_id}/{task_id}/frame_{i}.jpg" url = upload_to_oss(frame_path, storage_key) if url: seek_time = max(0.5, duration * ratio) if duration > 0 else 0.0 diff --git a/packages/domain/ass_subtitle_builder.py b/packages/domain/ass_subtitle_builder.py index ed4d2733f..4e79a52d4 100755 --- a/packages/domain/ass_subtitle_builder.py +++ b/packages/domain/ass_subtitle_builder.py @@ -224,8 +224,8 @@ def _wrap_title_text( if available_width <= 0: return text - # 换行宽度必须与实际渲染(补偿后的 ASS Fontsize)一致,否则换行位置会错位 - ass_font_size = _compensate_ass_fontsize(font_size) + # 换行计算使用原始 font_size,与 CSS 预览一致;1.35x 补偿仅用于 ASS Fontsize 渲染 + # 先按已有 \N 分段,每段独立自动换行,最后用 \N 拼回 segments = text.split("\\N") @@ -238,7 +238,7 @@ def _wrap_title_text( for ch in seg: # CJK 字符按全角估算,其他按半角 - char_width = float(ass_font_size) if ord(ch) > 0x2E80 else ass_font_size * 0.55 + char_width = float(font_size) if ord(ch) > 0x2E80 else font_size * 0.55 if current_width + char_width > available_width and current_line: lines.append(current_line) diff --git a/tests/unit/domain/test_ass_subtitle_builder.py b/tests/unit/domain/test_ass_subtitle_builder.py index a9a9c8e68..15cb76256 100644 --- a/tests/unit/domain/test_ass_subtitle_builder.py +++ b/tests/unit/domain/test_ass_subtitle_builder.py @@ -15,6 +15,7 @@ from packages.domain.ass_subtitle_builder import ( TITLE_MARGIN_BOTTOM, TITLE_MARGIN_SIDE, TITLE_MARGIN_TOP, + _wrap_title_text, build_ass_content, build_ass_style, escape_ass_text, @@ -579,3 +580,44 @@ class TestConstants: assert isinstance(TITLE_MARGIN_TOP, int) assert isinstance(TITLE_MARGIN_BOTTOM, int) assert isinstance(TITLE_MARGIN_SIDE, int) + +# ============================================================ +# _wrap_title_text 换行逻辑验证 +# ============================================================ + + +class TestWrapTitleTextFontSizeConsistency: + """验证换行计算使用原始 font_size,与 CSS 预览一致。""" + + def test_font_size_113_uses_original_not_compensated(self): + """font_size=113 时,每行应容纳8个字(113px字宽),而非6个字(153px字宽)。""" + # 标题"永康拾掇脚阔头"共7个字 + # 可用宽度 = 1080 - 60 - 60 = 960px + # 用 font_size=113:char_width=113,960/113 ≈ 8.5,每行8个字 + # 7个字 < 8个字,应该在一行内 + title = "永康拾掇脚阔头" + result = _wrap_title_text(title, video_width=1080, font_size=113, margin_l=60, margin_r=60) + # 不应该有换行 + assert "\\N" not in result + assert result == title + + def test_long_title_wraps_correctly(self): + """长标题应该按 font_size 字宽正确换行。""" + # 16个中文字,每行8个字,应该换行为2行 + title = "永康拾掇脚阔头来一个笑一个哈哈哈" + result = _wrap_title_text(title, video_width=1080, font_size=113, margin_l=60, margin_r=60) + # 应该有一个换行 + assert result.count("\\N") == 1 + # 每行8个字 + lines = result.split("\\N") + assert len(lines) == 2 + assert len(lines[0]) == 8 + assert len(lines[1]) == 8 + + def test_mixed_cjk_and_ascii(self): + """混合中英文时,英文按半角宽度计算。""" + # "测试test" = 2个中文(2*113=226) + 4个英文(4*113*0.55=248.6) = 474.6px + title = "测试test" + result = _wrap_title_text(title, video_width=1080, font_size=113, margin_l=60, margin_r=60) + # 总宽度474.6px < 960px,应该在一行内 + assert "\\N" not in result