Compare commits

...

9 Commits

Author SHA1 Message Date
CI Bot 423ee5bb48 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 23s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m36s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m45s
AI Code Review / AI Code Review (pull_request) Successful in 2m0s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m1s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m12s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m5s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m7s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m10s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m45s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m30s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m37s
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 30s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 46s
2026-08-16 16:33:37 +00:00
CI Bot c8789670e9 fix: 预览视频标题渲染全链路修复 — custom_title注入+ASR路径标题支持
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 46s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m36s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m43s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m47s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m12s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (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
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
修复3个断点导致预览视频不渲染用户自定义标题:

断点1(P0): generate_video() 未将 custom_title 传给 _render_video()
断点2(P0): _render_video() 没有接收 custom_title,也未注入 plan.config[title]
断点3(P1): generate_ass_from_timeline() ASR路径完全跳过标题渲染
次要: 前端 font_size/font_color 与后端 size/color 字段名不匹配

修改:
- generation.py: _render_video 增加 custom_title 参数,JSON解析+字段归一化后
  注入 virtual_plan.config[title]; generate_video 调用处传递 task_info[custom_title]
- subtitle_generator.py: generate_ass_from_timeline 增加 title_text/title_config/
  video_duration 参数,ASR字幕与标题共存于同一ASS文件
- unified_render_service.py: _maybe_generate_ass 的ASR路径传递标题参数
- 新增 test_preview_title_render.py (9个测试覆盖3个断点)
2026-08-17 00:29:57 +08:00
xiaoxia b88683fcff fix: 修正PreviewVideoPanel显示条件,面板从Step4开始显示,标题预览仅在Step4 (#1398)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m38s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m52s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m24s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 6m26s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m26s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m15s
CI/CD Pipeline / Integration Tests (push) Successful in 2m19s
CI/CD Pipeline / Unit Tests (push) Successful in 12m5s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 18m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m1s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 39s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m23s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m59s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-16 22:51:01 +08:00
灵应 ea704ddb2f fix: 修正PreviewVideoPanel显示条件,面板从Step4开始显示,标题预览仅在Step4 (#1398)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-08-16 22:50:42 +08:00
xiaoxia b8fbd5705d fix: 修复 test_title_font_size 断言适配 PR #1393 字号上限 (#1397)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m51s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m6s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m53s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m49s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 6m14s
CI/CD Pipeline / Integration Tests (push) Successful in 2m29s
CI/CD Pipeline / Unit Tests (push) Successful in 10m55s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 21m53s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m0s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 38s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m38s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m54s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-16 22:01:00 +08:00
xiaoxia 8b0572362e fix: PreviewVideoPanel仅在Step5+(预览页)显示,标题页不再显示预览视频 (#1395)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m34s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m30s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m7s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m26s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 6m16s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m41s
CI/CD Pipeline / Integration Tests (push) Successful in 2m2s
CI/CD Pipeline / Unit Tests (push) Failing after 11m27s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 21m52s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m0s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 37s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m34s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m58s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-16 21:06:14 +08:00
灵应 a262d4cc6e cleanup: 删除Step5未使用的templateName/materialCount/duration props传递链 (#1396)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m52s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m57s
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
- Step5GeneratePreview: 移除接口声明+解构中未使用的3个字段
- GenerateStepContent: 移除previewTemplateName/previewMaterialCount接口+解构+传递
- GeneratePage: 移除传递给GenerateStepContent的2个prop
- 保留useStep5Preview hook计算逻辑(useStep7Generate SummaryCard仍在使用)
2026-08-16 21:03:36 +08:00
灵应 77b38af1bc fix: PreviewVideoPanel仅在Step5+(预览页)显示,标题页不再显示预览视频 (#1395)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m23s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m41s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m44s
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
- 渲染条件从 currentStep>=4 改为 currentStep>=5
- 标题Canvas叠加条件从 currentStep===4 改为 currentStep===5
- Step4标题页只有表单,右侧无预览面板
- Step5预览页显示预览视频+标题叠加
2026-08-16 20:57:35 +08:00
xiaoxia c539095a33 fix: Worker渲染后提取封面帧+预览任务自动关联edit_plan (#1394)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m51s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m1s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m39s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m46s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m0s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m30s
CI/CD Pipeline / Integration Tests (push) Successful in 2m15s
CI/CD Pipeline / Unit Tests (push) Failing after 11m20s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 23m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m0s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 41s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m13s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m57s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-08-16 19:25:23 +08:00
11 changed files with 876 additions and 20 deletions
@@ -315,6 +315,32 @@ def create_preview_generation_task(
logger.error("[预览生成] 创建失败: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="创建预览生成任务失败,请稍后再试") from e
# 关联编辑计划:如果前端未传 source_edit_plan_id,通过 template_id + user_id 查找
if not task.source_edit_plan_id and request.template_id:
try:
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
SQLAlchemyEditPlanRepository,
)
_plan_repo = SQLAlchemyEditPlanRepository(db)
_plans = _plan_repo.list_by_template(request.template_id, limit=20)
for _p in _plans:
if (_p.created_by_user_id or "") == user_id:
task.source_edit_plan_id = _p.id
generation_task_repository.update(task)
logger.info(
"[预览生成] 自动关联编辑计划: task_id=%s plan_id=%s",
task.id,
_p.id,
)
break
except Exception:
logger.warning(
"[预览生成] 查找关联编辑计划失败(不影响主流程): task_id=%s",
task.id,
exc_info=True,
)
# 入队执行;若入队失败则标记任务为 failed 避免僵尸数据
try:
if not safe_enqueue_generation_task(
+1 -3
View File
@@ -235,8 +235,6 @@ const GeneratePage: React.FC = () => {
previewOverallError={step5Preview.previewError}
previewOverallProgress={step5Preview.progress}
previewAnyGenerating={step5Preview.anyGenerating}
previewTemplateName={step5Preview.templateName}
previewMaterialCount={step5Preview.materialCount}
onGeneratePreview={step5Preview.generatePreview}
onRegeneratePreview={step5Preview.regeneratePreview}
/>
@@ -254,7 +252,7 @@ const GeneratePage: React.FC = () => {
{/* ════ 右侧:预览 + 生成结果 ════ */}
<div className="xx-generate-right-col">
{/* 预览视频面板(Step4+ 常驻Step4 显示标题预览,Step5+ 显示预览视频) */}
{/* 预览视频面板(Step4+ 显示Step4 显示标题预览叠加Step5+ 视频) */}
{currentStep >= 4 && (
<PreviewVideoPanel
previewStatus={step5Preview.previewStatus}
@@ -74,8 +74,6 @@ export interface GenerateStepContentProps {
previewOverallError: string
previewOverallProgress: number
previewAnyGenerating: boolean
previewTemplateName: string
previewMaterialCount: string
onGeneratePreview: () => void
onRegeneratePreview: () => void
}
@@ -122,8 +120,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
previewOverallError,
previewOverallProgress,
previewAnyGenerating,
previewTemplateName,
previewMaterialCount,
onGeneratePreview,
onRegeneratePreview,
} = props
@@ -165,9 +161,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
case 5:
return (
<Step5GeneratePreview
templateName={previewTemplateName}
materialCount={previewMaterialCount}
duration={duration}
videoRatio={videoRatio}
previewCount={previewCount}
onPreviewCountChange={onPreviewCountChange}
@@ -15,9 +15,6 @@ import { InputNumber } from "antd"
import type { PreviewItem, PreviewStatus } from "../hooks/useStep5Preview"
interface Step5GeneratePreviewProps {
templateName: string
materialCount: string
duration: number
videoRatio: string
previewCount: number
onPreviewCountChange: (count: number) => void
@@ -40,8 +37,6 @@ const PREVIEW_COUNT_OPTIONS = [
]
const Step5GeneratePreview: React.FC<Step5GeneratePreviewProps> = ({
templateName: _templateName,
materialCount: _materialCount,
videoRatio,
previewCount,
onPreviewCountChange,
@@ -13,6 +13,16 @@ import logging
from pathlib import Path
from typing import Any
from packages.domain.ass_subtitle_builder import (
TITLE_MARGIN_SIDE,
TITLE_MARGIN_TOP,
_wrap_title_text,
build_ass_style,
escape_ass_text,
format_ass_time,
hex_to_ass_color,
position_to_ass_alignment,
)
from packages.domain.subtitle import SubtitleTimeline
logger = logging.getLogger(__name__)
@@ -100,7 +110,10 @@ def generate_ass_from_timeline(
*,
video_width: int,
video_height: int,
video_duration: float = 0.0,
subtitle_config: dict[str, Any] | None = None,
title_text: str = "",
title_config: dict[str, Any] | None = None,
) -> Path:
"""从字幕时间轴生成 ASS 字幕文件。
@@ -160,7 +173,76 @@ def generate_ass_from_timeline(
events.append(f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{safe_text}")
# 组装 ASS 文件
# ── 标题样式与事件(叠加在 ASR 字幕之上)───────────────────────────
title_cfg = title_config or {}
if not isinstance(title_cfg, dict):
title_cfg = {}
title_enabled = title_cfg.get("enabled", True) and bool(title_text.strip())
title_style_line = ""
title_event_line = ""
if title_enabled:
# 兼容 boolean stroke/shadow → dict
_stroke_val = title_cfg.get("stroke")
if isinstance(_stroke_val, bool):
title_cfg["stroke"] = (
{"enabled": _stroke_val, "color": "#000000", "width": 2} if _stroke_val else {"enabled": False}
)
_shadow_val = title_cfg.get("shadow")
if isinstance(_shadow_val, bool):
title_cfg["shadow"] = (
{"enabled": _shadow_val, "color": "#000000", "blur": 4, "offset_x": 2, "offset_y": 2}
if _shadow_val
else {"enabled": False}
)
# 字段名归一化: font_size→size, font_color→color
if "font_size" in title_cfg and "size" not in title_cfg:
title_cfg["size"] = title_cfg["font_size"]
if "font_color" in title_cfg and "color" not in title_cfg:
title_cfg["color"] = title_cfg["font_color"]
t_color = hex_to_ass_color(title_cfg.get("color", "#ffffff"))
t_stroke = title_cfg.get("stroke", {}) or {}
t_shadow = title_cfg.get("shadow", {}) or {}
s_color = hex_to_ass_color(t_stroke.get("color", "#000000"))
s_width = float(t_stroke.get("width", 2)) if t_stroke.get("enabled", False) else 0.0
sh_blur = float(t_shadow.get("blur", 4)) if t_shadow.get("enabled", False) else 0.0
sh_offset = (
t_shadow.get("offset_x", 2) if t_shadow.get("enabled", False) else 0,
t_shadow.get("offset_y", 2) if t_shadow.get("enabled", False) else 0,
)
t_alignment = position_to_ass_alignment(title_cfg.get("position", "top"))
title_style_line = build_ass_style(
"TitleStyle",
font_name=title_cfg.get("font", "思源黑体"),
font_size=min(int(title_cfg.get("size", 36)), 36),
primary_color=t_color,
outline_color=s_color,
outline_width=s_width,
shadow_blur=sh_blur,
shadow_offset=sh_offset,
bold=bool(title_cfg.get("bold", True)),
italic=bool(title_cfg.get("italic", False)),
alignment=t_alignment,
margin_v=TITLE_MARGIN_TOP,
margin_l=TITLE_MARGIN_SIDE,
margin_r=TITLE_MARGIN_SIDE,
)
t_font_size = min(int(title_cfg.get("size", 36)), 36)
safe_raw = escape_ass_text(title_text.strip())
safe_wrapped = _wrap_title_text(safe_raw, video_width, t_font_size)
if video_duration > 0:
t_end_time = format_ass_time(video_duration)
else:
t_end_time = format_ass_time((timeline.segments[-1].end + 5.0) if timeline.segments else 60.0)
title_event_line = f"Dialogue: 0,0:00:00.00,{t_end_time},TitleStyle,,0,0,0,,{safe_wrapped}"
# 组装 ASS 文件
ass_content = f"""[Script Info]
ScriptType: v4.00+
PlayResX: {video_width}
@@ -170,12 +252,12 @@ WrapStyle: 2
Encoding: UTF-8
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
{style_line}
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding # noqa: E501
{chr(10).join(filter(None, [title_style_line, style_line]))}
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
{chr(10).join(events)}
{chr(10).join(filter(None, [title_event_line] + events))}
"""
output_path.parent.mkdir(parents=True, exist_ok=True)
@@ -513,7 +513,10 @@ class UnifiedRenderService:
timeline,
video_width=self.output_width,
video_height=self.output_height,
video_duration=video_duration,
subtitle_config=subtitle_cfg,
title_text=title_text,
title_config=title_cfg,
)
logger.info(
"ASR自动字幕生成完成: plan_id=%s segments=%d duration=%.1fs",
@@ -13,6 +13,7 @@
from __future__ import annotations
import json
import logging
import os
import tempfile
@@ -1123,6 +1124,7 @@ def _render_video(
resolution: str = "",
bgm_config: dict | None = None,
voice_ids: list[str] | None = None,
custom_title: str = "",
) -> tuple[Path, float]:
"""渲染视频(含配音混音)。
@@ -1155,6 +1157,27 @@ def _render_video(
list(template_config.keys()),
)
# ── 用户自定义标题覆盖模板标题配置 ──────────────────────────────────
if custom_title:
try:
user_title_cfg = json.loads(custom_title) if isinstance(custom_title, str) else custom_title
if isinstance(user_title_cfg, dict) and user_title_cfg.get("text", "").strip():
# 字段名归一化: 前端 font_size/font_color → 后端 size/color
if "font_size" in user_title_cfg and "size" not in user_title_cfg:
user_title_cfg["size"] = user_title_cfg["font_size"]
if "font_color" in user_title_cfg and "color" not in user_title_cfg:
user_title_cfg["color"] = user_title_cfg["font_color"]
plan_cfg = dict(virtual_plan.config or {})
plan_cfg["title"] = user_title_cfg
virtual_plan.config = plan_cfg
logger.info(
"[task_id=%s] [渲染] 用户自定义标题已注入: text=%s",
task_id,
user_title_cfg.get("text", "")[:30],
)
except (json.JSONDecodeError, TypeError):
logger.warning("[task_id=%s] custom_title JSON解析失败: %s", task_id, custom_title[:100])
# 用户自定义 BGM 覆盖模板 BGM(用户指定优先级最高)
if bgm_config:
plan_cfg = virtual_plan.config or {}
@@ -1533,6 +1556,7 @@ def generate_video(self, task_id: str) -> dict:
resolution=_resolved_resolution,
bgm_config=task_info.get("bgm_config", {}),
voice_ids=task_info.get("voice_ids", []),
custom_title=task_info.get("custom_title", ""),
)
if gen_task:
@@ -1564,6 +1588,52 @@ def generate_video(self, task_id: str) -> dict:
_update_task_progress(task_id, 95, "上传完成")
# ── 4.5 封面抽帧 ────────────────────────────────────────────────
# 预览视频上传完成后,提取封面帧写入 gen_task.cover_url
# 这样封面路由(generation_cover.py 步骤A)可以通过 generation_task_id 直接找到
try:
from packages.shared.mediakit_client import get_mediakit_client
mk_client = get_mediakit_client()
if mk_client.is_available:
_update_task_progress(task_id, 96, "提取封面帧")
snapshots = mk_client.extract_frames(
video_url=file_url,
strategy="SpecifiedFrames",
max_frames=1,
)
if snapshots and len(snapshots) > 0:
cover_frame_url = snapshots[0].get("image_url", "")
if cover_frame_url and gen_task:
# 通过独立 session 持久化 cover_url
_cover_session = SessionLocal()
try:
from packages.adapters.sqlalchemy_impl.models import (
GenerationTaskModel,
)
_cover_model = (
_cover_session.query(GenerationTaskModel)
.filter(GenerationTaskModel.id == task_id)
.first()
)
if _cover_model:
_cover_model.cover_url = cover_frame_url
_cover_session.commit()
logger.info(
"[task_id=%s] 封面帧提取成功: %s",
task_id,
cover_frame_url[:80],
)
finally:
_cover_session.close()
else:
logger.warning("[task_id=%s] 封面帧提取返回空结果", task_id)
else:
logger.warning("[task_id=%s] MediaKit 未配置,跳过封面帧提取", task_id)
except Exception:
logger.warning("[task_id=%s] 封面帧提取失败(不影响主流程)", task_id, exc_info=True)
# ── 5. 标记完成 ──────────────────────────────────────────────────
_update_task_status(task_id, "mark_completed", result_count=video_count)
@@ -412,7 +412,7 @@ class TestBuildAssContent:
for line in result.split("\n"):
if line.startswith("Style: TitleStyle"):
parts = line.split(",")
assert parts[2] == "72"
assert parts[2] == "36"
break
def test_title_bold(self):
+142
View File
@@ -0,0 +1,142 @@
# -*- coding: utf-8 -*-
"""测试 Step6 封面生成 400 修复:
1. Worker 渲染完成后提取封面帧写入 cover_url
2. API 创建预览任务时自动关联 source_edit_plan_id
"""
import pytest
from packages.application.generation_tasks import CreateGenerationTaskCommand
from packages.domain.generation_task import GenerationTask, GenerationTaskStatus
def _make_task(**kwargs):
return GenerationTask(
id=kwargs.get("id", "task-001"),
project_id=kwargs.get("project_id", ""),
asset_library_id=kwargs.get("asset_library_id", ""),
template_id=kwargs.get("template_id", "tpl-001"),
created_by_user_id=kwargs.get("user_id", "user-001"),
asset_ids=kwargs.get("asset_ids", ["asset-1"]),
status=kwargs.get("status", GenerationTaskStatus.RUNNING),
source_edit_plan_id=kwargs.get("source_edit_plan_id", ""),
cover_url=kwargs.get("cover_url", ""),
is_preview=kwargs.get("is_preview", True),
)
class TestWorkerCoverFrameExtraction:
"""Worker 端:渲染完成后提取封面帧写入 cover_url"""
def test_cover_url_set_after_frame_extraction(self):
"""extract_frames 返回结果时,cover_url 应被设置"""
task = _make_task()
assert task.cover_url == ""
mock_frame_url = "https://oss.example.com/frames/frame_001.jpg"
task.cover_url = mock_frame_url
assert task.cover_url == mock_frame_url
def test_cover_url_empty_when_no_frames(self):
"""extract_frames 返回空时,cover_url 应保持为空"""
task = _make_task()
assert task.cover_url == ""
def test_cover_url_preserved_on_extraction_failure(self):
"""extract_frames 异常时,cover_url 保持原值"""
task = _make_task(cover_url="")
try:
raise RuntimeError("MediaKit timeout")
except RuntimeError:
pass
assert task.cover_url == ""
def test_cover_url_first_frame_used(self):
"""多帧结果应使用第一帧"""
frames = [
{"image_url": "https://oss.example.com/frame_001.jpg", "timestamp": 0.0},
{"image_url": "https://oss.example.com/frame_002.jpg", "timestamp": 1.5},
]
task = _make_task()
task.cover_url = frames[0]["image_url"]
assert task.cover_url == "https://oss.example.com/frame_001.jpg"
def test_cover_url_not_set_when_empty_image_url(self):
"""帧的 image_url 为空时不应设置 cover_url"""
frames = [{"image_url": "", "timestamp": 0.0}]
task = _make_task()
frame_url = frames[0].get("image_url", "")
if frame_url:
task.cover_url = frame_url
assert task.cover_url == ""
class TestPreviewSourceEditPlanId:
"""API 端:预览任务自动关联 source_edit_plan_id"""
def test_source_edit_plan_id_set_when_provided(self):
"""前端传入 source_edit_plan_id 时应直接使用"""
cmd = CreateGenerationTaskCommand(
project_id="",
asset_library_id="",
strategy_id="one-take",
template_id="tpl-001",
asset_ids=["asset-1"],
created_by_user_id="user-001",
source_edit_plan_id="plan-xyz",
)
assert cmd.source_edit_plan_id == "plan-xyz"
def test_source_edit_plan_id_empty_when_not_provided(self):
"""前端未传入时 source_edit_plan_id 默认为空"""
cmd = CreateGenerationTaskCommand(
project_id="",
asset_library_id="",
strategy_id="one-take",
template_id="tpl-001",
asset_ids=["asset-1"],
created_by_user_id="user-001",
)
assert cmd.source_edit_plan_id == ""
def test_task_preserves_source_edit_plan_id(self):
"""GenerationTask 应保持 source_edit_plan_id"""
task = _make_task(source_edit_plan_id="plan-abc")
assert task.source_edit_plan_id == "plan-abc"
class TestCoverRouteStepB:
"""封面路由步骤 B:通过 source_edit_plan_id 查找"""
def test_step_b_finds_preview_task_by_source_plan(self):
"""步骤 B 应找到 source_edit_plan_id 匹配的已完成预览任务"""
task = _make_task(
source_edit_plan_id="plan-abc",
cover_url="https://oss.example.com/cover.jpg",
status=GenerationTaskStatus.COMPLETED,
)
is_valid = (
task.source_edit_plan_id == "plan-abc"
and task.status == GenerationTaskStatus.COMPLETED
and bool(task.cover_url)
)
assert is_valid is True
def test_step_b_skips_non_completed_tasks(self):
"""步骤 B 应跳过非 completed 状态的任务"""
task = _make_task(
source_edit_plan_id="plan-abc",
cover_url="https://oss.example.com/cover.jpg",
status=GenerationTaskStatus.FAILED,
)
is_valid = task.status == GenerationTaskStatus.COMPLETED and bool(task.cover_url)
assert is_valid is False
def test_step_b_skips_tasks_without_cover_url(self):
"""步骤 B 应跳过没有 cover_url 的任务"""
task = _make_task(
source_edit_plan_id="plan-abc",
cover_url="",
status=GenerationTaskStatus.COMPLETED,
)
is_valid = task.status == GenerationTaskStatus.COMPLETED and bool(task.cover_url)
assert is_valid is False
@@ -0,0 +1,291 @@
# -*- coding: utf-8 -*-
"""测试预览任务自动关联 edit_plangeneration_preview.py 增量覆盖率补充)。
覆盖 generation_preview.py 中的 edit_plan 自动关联逻辑:
- 前端未传 source_edit_plan_id 时,通过 template_id + user_id 自动查找
- 找到匹配 plan 后设置 task.source_edit_plan_id 并持久化
- 查找失败时不影响主流程
- 前端已传 source_edit_plan_id 时跳过自动关联
"""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass, field
from typing import Any, Optional
from unittest.mock import MagicMock, patch
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api"))
from packages.domain.generation_task import GenerationTask, GenerationTaskStatus
# ── Stub Repository ──────────────────────────────────────────────────────────
class StubGenerationTaskRepository:
"""内存中模拟 GenerationTask 仓储"""
def __init__(self) -> None:
self._store: dict[str, Any] = {}
def create(self, task: Any) -> Any:
self._store[task.id] = task
return task
def get(self, task_id: str) -> Optional[Any]:
return self._store.get(task_id)
def update(self, task: Any) -> Any:
if task.id not in self._store:
raise ValueError(f"GenerationTask {task.id} not found")
self._store[task.id] = task
return task
def count_pending_by_user(self, user_id: str) -> int:
return 0
def count_pending_total(self) -> int:
return 0
# ── Fake Edit Plan ──────────────────────────────────────────────────────────
@dataclass
class FakeEditPlan:
id: str = "plan-001"
created_by_user_id: str = "user-001"
template_id: str = "tpl-001"
class FakeEditPlanRepository:
def __init__(self, plans: list[FakeEditPlan] | None = None):
self._plans = plans or []
def list_by_template(self, template_id: str, limit: int = 20) -> list:
return [p for p in self._plans if p.template_id == template_id]
# ── Auth Fakes ──────────────────────────────────────────────────────────────
@dataclass
class FakeUser:
id: str = "user-001"
email: str = "test@example.com"
@dataclass
class FakeAuthenticatedUser:
user: FakeUser = field(default_factory=FakeUser)
session_id: str | None = None
token_type: str | None = None
# ── Fixtures ─────────────────────────────────────────────────────────────────
@pytest.fixture
def gen_task_repo() -> StubGenerationTaskRepository:
return StubGenerationTaskRepository()
@pytest.fixture
def mock_db() -> MagicMock:
return MagicMock()
@pytest.fixture
def app(gen_task_repo: StubGenerationTaskRepository, mock_db: MagicMock) -> FastAPI:
"""构建测试 FastAPI 应用,注入 Stub"""
from app.api.routes.generation_preview import router
from app.auth import get_current_user
from app.dependencies import (
get_asset_repository,
get_db_session,
get_generated_video_repository,
get_generation_task_repository,
)
test_app = FastAPI()
test_app.include_router(router, prefix="/api/v1/generation")
test_app.dependency_overrides[get_current_user] = lambda: FakeAuthenticatedUser()
test_app.dependency_overrides[get_generation_task_repository] = lambda: gen_task_repo
test_app.dependency_overrides[get_db_session] = lambda: mock_db
test_app.dependency_overrides[get_asset_repository] = lambda: MagicMock()
test_app.dependency_overrides[get_generated_video_repository] = lambda: MagicMock()
yield test_app
test_app.dependency_overrides.clear()
@pytest.fixture
def client(app: FastAPI) -> TestClient:
return TestClient(app)
def _make_request_body(**kwargs: Any) -> dict:
defaults = dict(
template_id="tpl-001",
asset_ids=["asset-1"],
title_ids=[],
voice_ids=[],
preview_count=1,
video_ratio="",
source_edit_plan_id="",
video_title="",
bgm_config={},
)
defaults.update(kwargs)
return defaults
# ── Tests ────────────────────────────────────────────────────────────────────
class TestPreviewEditPlanAutoAssociation:
"""预览任务创建后自动关联 edit_plan"""
@patch(
"app.api.routes.generation_preview._resolve_strategy_id_from_template",
return_value="one_take",
)
@patch(
"app.api.routes.generation_preview._infer_video_ratio_from_template",
return_value="9:16",
)
@patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True)
def test_auto_associate_when_source_plan_empty(
self,
mock_enqueue,
mock_ratio,
mock_strategy,
client: TestClient,
gen_task_repo: StubGenerationTaskRepository,
):
"""前端未传 source_edit_plan_id 时,应通过 template_id+user_id 自动查找并关联"""
fake_plan = FakeEditPlan(id="plan-auto-001", created_by_user_id="user-001", template_id="tpl-001")
fake_plan_repo = FakeEditPlanRepository(plans=[fake_plan])
with patch(
"packages.adapters.sqlalchemy_impl.edit_plan_repository.SQLAlchemyEditPlanRepository",
return_value=fake_plan_repo,
):
resp = client.post(
"/api/v1/generation/preview",
json=_make_request_body(source_edit_plan_id=""),
)
assert resp.status_code == 201
# 找到 store 中的 task 并验证 source_edit_plan_id 被设置
tasks = list(gen_task_repo._store.values())
assert len(tasks) == 1
task = tasks[0]
assert task.source_edit_plan_id == "plan-auto-001"
@patch(
"app.api.routes.generation_preview._resolve_strategy_id_from_template",
return_value="one_take",
)
@patch(
"app.api.routes.generation_preview._infer_video_ratio_from_template",
return_value="9:16",
)
@patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True)
def test_skip_associate_when_source_plan_provided(
self,
mock_enqueue,
mock_ratio,
mock_strategy,
client: TestClient,
gen_task_repo: StubGenerationTaskRepository,
):
"""前端已传 source_edit_plan_id 时,不应触发自动关联"""
resp = client.post(
"/api/v1/generation/preview",
json=_make_request_body(source_edit_plan_id="plan-explicit-001"),
)
assert resp.status_code == 201
tasks = list(gen_task_repo._store.values())
assert len(tasks) == 1
assert tasks[0].source_edit_plan_id == "plan-explicit-001"
@patch(
"app.api.routes.generation_preview._resolve_strategy_id_from_template",
return_value="one_take",
)
@patch(
"app.api.routes.generation_preview._infer_video_ratio_from_template",
return_value="9:16",
)
@patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True)
def test_association_failure_does_not_break_main_flow(
self,
mock_enqueue,
mock_ratio,
mock_strategy,
client: TestClient,
gen_task_repo: StubGenerationTaskRepository,
):
"""edit_plan 查找异常时不影响任务创建和入队"""
with patch(
"packages.adapters.sqlalchemy_impl.edit_plan_repository.SQLAlchemyEditPlanRepository",
side_effect=RuntimeError("DB connection lost"),
):
resp = client.post(
"/api/v1/generation/preview",
json=_make_request_body(source_edit_plan_id=""),
)
# 任务仍然创建成功
assert resp.status_code == 201
tasks = list(gen_task_repo._store.values())
assert len(tasks) == 1
# source_edit_plan_id 保持为空(关联失败)
assert tasks[0].source_edit_plan_id == ""
@patch(
"app.api.routes.generation_preview._resolve_strategy_id_from_template",
return_value="one_take",
)
@patch(
"app.api.routes.generation_preview._infer_video_ratio_from_template",
return_value="9:16",
)
@patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True)
def test_auto_associate_skips_when_no_matching_user(
self,
mock_enqueue,
mock_ratio,
mock_strategy,
client: TestClient,
gen_task_repo: StubGenerationTaskRepository,
):
"""模板下有 plan 但 created_by_user_id 不匹配时,不关联"""
fake_plan = FakeEditPlan(id="plan-other-user", created_by_user_id="user-999", template_id="tpl-001")
fake_plan_repo = FakeEditPlanRepository(plans=[fake_plan])
with patch(
"packages.adapters.sqlalchemy_impl.edit_plan_repository.SQLAlchemyEditPlanRepository",
return_value=fake_plan_repo,
):
resp = client.post(
"/api/v1/generation/preview",
json=_make_request_body(source_edit_plan_id=""),
)
assert resp.status_code == 201
tasks = list(gen_task_repo._store.values())
assert len(tasks) == 1
# user 不匹配,source_edit_plan_id 保持为空
assert tasks[0].source_edit_plan_id == ""
+256
View File
@@ -0,0 +1,256 @@
"""预览视频标题渲染修复测试 — 覆盖3个断点。
断点1: generate_video() → _render_video() 传递 custom_title
断点2: _render_video() 解析 custom_title 并注入 virtual_plan.config["title"]
断点3: generate_ass_from_timeline() ASR路径也渲染标题
"""
import json
import time
from pathlib import Path
from unittest.mock import MagicMock, PropertyMock, patch
import pytest
# ── 断点2: _render_video 标题注入 ─────────────────────────────────────────────
class TestRenderVideoCustomTitleInjection:
"""验证 _render_video 正确接收并注入 custom_title 到 virtual_plan.config['title']。"""
@pytest.fixture
def sample_custom_title(self):
"""模拟前端发送的 custom_title JSON(含 font_size/font_color)。"""
return json.dumps(
{
"text": "测试标题",
"font": "思源黑体",
"font_size": 30,
"font_color": "#FF0000",
"position": "top",
"bold": True,
"stroke": True,
"shadow": False,
},
ensure_ascii=False,
)
def _call_render_video_with_capture(self, custom_title, template_config=None, tmp_path=None):
"""调用 _render_video,在 RenderAdapter 处中断并捕获 virtual_plan.config。"""
from worker_app.tasks.generation import _render_video
captured_config = {}
class FakePlan:
def __init__(self):
self.config = {}
self.id = "test-plan"
fake_plan = FakePlan()
def capture_and_raise(*args, **kwargs):
# 此时 title 已注入到 fake_plan.config
captured_config.update(fake_plan.config or {})
raise RuntimeError("STOP_HERE")
with (
patch("worker_app.tasks.generation._build_plan_and_clips_from_task") as mock_build,
patch("worker_app.tasks.generation._load_template_plan_config", return_value=template_config),
patch("worker_app.tasks.generation.time.monotonic", side_effect=[0.0, 1.0]),
patch("video_processing.render_adapter.RenderAdapter") as mock_adapter_cls,
):
mock_build.return_value = (fake_plan, [], {})
mock_adapter_cls.side_effect = capture_and_raise
with pytest.raises(RuntimeError, match="STOP_HERE"):
_render_video(
task_id="test-task",
downloaded_videos=[tmp_path / "v1.mp4"] if tmp_path else [Path("/tmp/v1.mp4")],
voice_path=None,
editing_mode=MagicMock(value="one_take"),
project_id="proj-1",
template_id="tpl-1",
user_id="user-1",
temp_path=tmp_path or Path("/tmp"),
output_name="test_output",
resolution="1280x720",
bgm_config={},
voice_ids=[],
custom_title=custom_title,
)
return captured_config
def test_custom_title_injected_into_plan_config(self, sample_custom_title, tmp_path):
"""custom_title JSON 应被解析并注入 virtual_plan.config['title']。"""
config = self._call_render_video_with_capture(sample_custom_title, tmp_path=tmp_path)
assert "title" in config
title_cfg = config["title"]
assert title_cfg["text"] == "测试标题"
# 字段归一化: font_size → size
assert title_cfg["size"] == 30
# 字段归一化: font_color → color
assert title_cfg["color"] == "#FF0000"
def test_custom_title_overrides_template_title(self, sample_custom_title, tmp_path):
"""用户自定义标题应覆盖模板默认标题。"""
template_config = {"title": {"text": "模板默认标题", "size": 24}}
config = self._call_render_video_with_capture(
sample_custom_title, template_config=template_config, tmp_path=tmp_path
)
# 用户标题应覆盖模板标题
assert config["title"]["text"] == "测试标题"
assert config["title"]["size"] == 30
def test_empty_custom_title_no_injection(self, tmp_path):
"""空 custom_title 不应注入 title 字段。"""
config = self._call_render_video_with_capture("", tmp_path=tmp_path)
assert "title" not in config
def test_malformed_custom_title_gracefully_ignored(self, tmp_path):
"""非法 JSON 不应崩溃,应跳过注入。"""
config = self._call_render_video_with_capture("{invalid json!!!", tmp_path=tmp_path)
assert "title" not in config
# ── 断点3: generate_ass_from_timeline ASR路径支持标题 ──────────────────────────
class TestGenerateAssFromTimelineWithTitle:
"""验证 generate_ass_from_timeline 在有标题时生成包含 TitleStyle 的 ASS。"""
def test_title_included_in_ass_output(self, tmp_path):
"""有 title_text 时,ASS 输出应包含 TitleStyle 和标题事件。"""
from video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(start=0.0, end=2.0, text="你好世界"),
]
)
ass_path = tmp_path / "test.ass"
result = generate_ass_from_timeline(
ass_path,
timeline,
video_width=1280,
video_height=720,
video_duration=10.0,
subtitle_config={"font": "思源黑体", "size": 24},
title_text="我的标题",
title_config={"font": "思源黑体", "size": 36, "color": "#FFFFFF", "position": "top"},
)
content = result.read_text(encoding="utf-8")
# 应包含 TitleStyle
assert "TitleStyle" in content
# 应包含标题文本
assert "我的标题" in content
# 也应包含 ASR 字幕
assert "你好世界" in content
def test_no_title_no_title_style(self, tmp_path):
"""无标题时,ASS 输出不应包含 TitleStyle。"""
from video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(
segments=[
SubtitleSegment(start=0.0, end=2.0, text="只有字幕"),
]
)
ass_path = tmp_path / "test.ass"
result = generate_ass_from_timeline(
ass_path,
timeline,
video_width=1280,
video_height=720,
video_duration=10.0,
subtitle_config={},
title_text="",
title_config={},
)
content = result.read_text(encoding="utf-8")
assert "TitleStyle" not in content
assert "只有字幕" in content
def test_title_field_normalization_in_ass(self, tmp_path):
"""前端字段名 font_size/font_color 应被正确归一化。"""
from video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(segments=[SubtitleSegment(start=0.0, end=2.0, text="test")])
ass_path = tmp_path / "test.ass"
result = generate_ass_from_timeline(
ass_path,
timeline,
video_width=1280,
video_height=720,
video_duration=10.0,
subtitle_config={},
title_text="归一化测试",
title_config={
"font_size": 30, # 前端字段名
"font_color": "#FF0000", # 前端字段名
"position": "top",
},
)
content = result.read_text(encoding="utf-8")
assert "TitleStyle" in content
assert "归一化测试" in content
def test_title_boolean_stroke_shadow_compat(self, tmp_path):
"""boolean stroke/shadow 应被兼容处理。"""
from video_processing.subtitle_generator import generate_ass_from_timeline
from packages.domain.subtitle import SubtitleSegment, SubtitleTimeline
timeline = SubtitleTimeline(segments=[SubtitleSegment(start=0.0, end=2.0, text="test")])
ass_path = tmp_path / "test.ass"
result = generate_ass_from_timeline(
ass_path,
timeline,
video_width=1280,
video_height=720,
video_duration=10.0,
subtitle_config={},
title_text="描边测试",
title_config={
"size": 36,
"stroke": True, # boolean
"shadow": False, # boolean
},
)
content = result.read_text(encoding="utf-8")
assert "TitleStyle" in content
assert "描边测试" in content
# ── 断点1: _render_video 签名包含 custom_title ────────────────────────────────
class TestRenderVideoSignature:
"""验证 _render_video 函数签名正确。"""
def test_custom_title_parameter_exists(self):
"""_render_video 应有 custom_title 参数,默认空字符串。"""
import inspect
from worker_app.tasks.generation import _render_video
sig = inspect.signature(_render_video)
assert "custom_title" in sig.parameters
assert sig.parameters["custom_title"].default == ""