diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py index df4d73e51..d6afc7219 100755 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -169,22 +169,8 @@ 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/web/src/pages/generate/components/material/ManualMaterialList.tsx b/apps/web/src/pages/generate/components/material/ManualMaterialList.tsx index a067e1098..dbee4a1b0 100644 --- a/apps/web/src/pages/generate/components/material/ManualMaterialList.tsx +++ b/apps/web/src/pages/generate/components/material/ManualMaterialList.tsx @@ -1,5 +1,5 @@ /** - * 手动选择素材列表 + * 手动选择素材列表 — 竖屏 9:16 卡片网格 */ import React, { useRef, useCallback } from "react" import { Typography } from "antd" @@ -14,18 +14,24 @@ interface ManualMaterialListProps { onToggle: (materialId: string) => void } +/** 秒数格式化为 mm:ss */ +const fmtDuration = (seconds?: number): string => { + if (!seconds && seconds !== 0) return "--:--" + const m = Math.floor(seconds / 60) + const s = Math.floor(seconds % 60) + return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` +} + const ManualMaterialList: React.FC = ({ materials, materialsLoading, selectedMaterials, onToggle, }) => { - // 追踪当前正在播放的视频元素,确保同时只有一个视频播放 const activeVideoRef = useRef(null) const handleVideoMouseEnter = useCallback((e: React.MouseEvent) => { const video = e.currentTarget - // 暂停之前正在播放的视频(检查是否仍在 DOM 中) if ( activeVideoRef.current && activeVideoRef.current !== video && @@ -56,143 +62,188 @@ const ManualMaterialList: React.FC = ({ 暂无素材,请先在视频库中上传 ) : ( -
+
{materials.items.map((m) => { const checked = selectedMaterials.includes(m.id) const isVideo = m.mime_type?.startsWith("video/") ?? false const thumbSrc = m.thumbnail_url || undefined return ( -
diff --git a/apps/web/src/pages/generate/generate.css b/apps/web/src/pages/generate/generate.css index c2fbd8b32..2e74af669 100644 --- a/apps/web/src/pages/generate/generate.css +++ b/apps/web/src/pages/generate/generate.css @@ -203,7 +203,7 @@ background: var(--bg-primary); border: 2px solid var(--border-color); border-radius: var(--radius-md); - padding: 14px; + padding: 10px; cursor: pointer; transition: 0.18s ease; text-align: center; @@ -219,14 +219,14 @@ } .xx-choice-thumb { - height: 60px; + height: 36px; border-radius: var(--radius-sm); display: grid; place-items: center; color: var(--text-inverse); - font-size: 24px; + font-size: 16px; font-weight: 700; - margin-bottom: 10px; + margin-bottom: 6px; } .xx-choice-item h4 { diff --git a/apps/worker/video_processing/render_adapter.py b/apps/worker/video_processing/render_adapter.py index 4f5cb5444..295d32464 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, task_id=job_id, num_frames=3, title_text="" + str(result.output_path), plan_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 4282c0027..a53163d4d 100755 --- a/apps/worker/video_processing/thumbnail_generator.py +++ b/apps/worker/video_processing/thumbnail_generator.py @@ -278,7 +278,6 @@ 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", @@ -292,7 +291,6 @@ 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 叠加到每帧。 从已渲染视频抽帧时通常传空(标题已烧录);从源素材抽帧时传标题。 @@ -340,7 +338,7 @@ def extract_and_upload_cover_frames( font_size=title_font_size, ) - storage_key = f"covers/{plan_id}/{task_id}/mediakit_frame_{i}.jpg" + storage_key = f"covers/{plan_id}/mediakit_frame_{i}.jpg" url = upload_to_oss(tmp.name, storage_key) if url: seek_time = frame.get("timestamp", 0.0) @@ -379,7 +377,7 @@ def extract_and_upload_cover_frames( position=title_position, font_size=title_font_size, ) - storage_key = f"covers/{plan_id}/{task_id}/frame_{i}.jpg" + storage_key = f"covers/{plan_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 4e79a52d4..ed4d2733f 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 - # 换行计算使用原始 font_size,与 CSS 预览一致;1.35x 补偿仅用于 ASS Fontsize 渲染 - + # 换行宽度必须与实际渲染(补偿后的 ASS Fontsize)一致,否则换行位置会错位 + ass_font_size = _compensate_ass_fontsize(font_size) # 先按已有 \N 分段,每段独立自动换行,最后用 \N 拼回 segments = text.split("\\N") @@ -238,7 +238,7 @@ def _wrap_title_text( for ch in seg: # CJK 字符按全角估算,其他按半角 - char_width = float(font_size) if ord(ch) > 0x2E80 else font_size * 0.55 + char_width = float(ass_font_size) if ord(ch) > 0x2E80 else ass_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 15cb76256..a9a9c8e68 100644 --- a/tests/unit/domain/test_ass_subtitle_builder.py +++ b/tests/unit/domain/test_ass_subtitle_builder.py @@ -15,7 +15,6 @@ 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, @@ -580,44 +579,3 @@ 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