Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 423ee5bb48 | |||
| c8789670e9 | |||
| b88683fcff | |||
| ea704ddb2f | |||
| b8fbd5705d | |||
| 8b0572362e | |||
| a262d4cc6e | |||
| 77b38af1bc | |||
| c539095a33 |
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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_plan(generation_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 == ""
|
||||
@@ -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 == ""
|
||||
Reference in New Issue
Block a user