Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33c4caf9ba | |||
| cf55d475b9 | |||
| 631591ff88 | |||
| 7d4e1b1b2f | |||
| 9c7bf7f67f | |||
| 734508bea5 | |||
| 765725b878 | |||
| 66024c12b6 | |||
| 2cb5f10d8e | |||
| 88e9311fb0 | |||
| ec8d1786ac | |||
| 563131917e | |||
| b9ee5288dd | |||
| d5bec6ebe5 | |||
| 8de71c86bf | |||
| 20d7e02a75 | |||
| 877d454e80 | |||
| 975a094a0c | |||
| a3cc325bd8 | |||
| 09ac626505 | |||
| d7315544d3 | |||
| e2e7c88f22 | |||
| 99d83cc21e | |||
| b2f97c70ae |
@@ -0,0 +1,82 @@
|
||||
"""确认生成 API 改造:为 generation_tasks 表添加 source_task_id、output_width、output_height、cover_url、custom_title 字段
|
||||
|
||||
Revision ID: 054_confirm_gen_fields
|
||||
Revises: 053_generation_task_is_preview
|
||||
Create Date: 2026-08-16
|
||||
|
||||
Changes:
|
||||
1. generation_tasks 表新增 source_task_id(来源预览任务 ID,带索引)
|
||||
2. generation_tasks 表新增 output_width / output_height(动态输出分辨率)
|
||||
3. generation_tasks 表新增 cover_url / custom_title(自定义封面和标题)
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "054_confirm_gen_fields"
|
||||
down_revision = "053_generation_task_is_preview"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
is_pg = conn.dialect.name == "postgresql"
|
||||
|
||||
if is_pg:
|
||||
# 幂等检查:source_task_id 列是否已存在
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_name = 'generation_tasks' AND column_name = 'source_task_id'"
|
||||
)
|
||||
)
|
||||
if result.scalar() is not None:
|
||||
return
|
||||
|
||||
# source_task_id
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("source_task_id", sa.String(32), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
# output_width
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("output_width", sa.Integer, nullable=False, server_default=sa.text("1280")),
|
||||
)
|
||||
|
||||
# output_height
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("output_height", sa.Integer, nullable=False, server_default=sa.text("720")),
|
||||
)
|
||||
|
||||
# cover_url
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("cover_url", sa.String(1000), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
# custom_title
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("custom_title", sa.String(500), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
# 索引
|
||||
op.create_index(
|
||||
"ix_generation_tasks_source_task_id",
|
||||
"generation_tasks",
|
||||
["source_task_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_generation_tasks_source_task_id", table_name="generation_tasks")
|
||||
op.drop_column("generation_tasks", "custom_title")
|
||||
op.drop_column("generation_tasks", "cover_url")
|
||||
op.drop_column("generation_tasks", "output_height")
|
||||
op.drop_column("generation_tasks", "output_width")
|
||||
op.drop_column("generation_tasks", "source_task_id")
|
||||
@@ -26,6 +26,7 @@ from app.schemas.generated_video import (
|
||||
)
|
||||
from app.schemas.generation_task import (
|
||||
BatchGenerationTaskResponse,
|
||||
ConfirmGenerationRequest,
|
||||
CreateGenerationTaskRequest,
|
||||
GenerationTaskResponse,
|
||||
ListGenerationTasksResponse,
|
||||
@@ -62,6 +63,12 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
video_title=getattr(task, "video_title", ""),
|
||||
resolution=getattr(task, "resolution", ""),
|
||||
bgm_config=getattr(task, "bgm_config", {}) or {},
|
||||
is_preview=getattr(task, "is_preview", False),
|
||||
source_task_id=getattr(task, "source_task_id", ""),
|
||||
output_width=getattr(task, "output_width", 1280),
|
||||
output_height=getattr(task, "output_height", 720),
|
||||
cover_url=getattr(task, "cover_url", ""),
|
||||
custom_title=getattr(task, "custom_title", ""),
|
||||
logs=getattr(task, "logs", "[]"),
|
||||
status=task.status,
|
||||
progress=task.progress,
|
||||
@@ -291,6 +298,12 @@ def create_generation_task(
|
||||
bgm_config=request.bgm_config,
|
||||
auto_retry_enabled=request.auto_retry_enabled,
|
||||
auto_retry_max=request.auto_retry_max,
|
||||
is_preview=request.is_preview,
|
||||
source_task_id=request.source_task_id,
|
||||
output_width=request.output_width,
|
||||
output_height=request.output_height,
|
||||
cover_url=request.cover_url,
|
||||
custom_title=request.custom_title,
|
||||
)
|
||||
)
|
||||
try:
|
||||
@@ -331,6 +344,83 @@ def create_generation_task(
|
||||
return BatchGenerationTaskResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/confirm", response_model=BatchGenerationTaskResponse)
|
||||
def confirm_generation(
|
||||
task_id: str,
|
||||
request: ConfirmGenerationRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> BatchGenerationTaskResponse:
|
||||
"""确认生成 — 基于预览任务创建正式生成任务。
|
||||
|
||||
查找预览任务,复制其配置,创建新的正式生成任务(is_preview=False),
|
||||
使用高分辨率,复用 worker.generate_video 渲染路径。
|
||||
"""
|
||||
# 1. 查找源预览任务
|
||||
source_task = generation_task_repository.get(task_id)
|
||||
if source_task is None:
|
||||
raise HTTPException(status_code=404, detail=f"Preview task {task_id} not found")
|
||||
|
||||
# 2. 权限检查
|
||||
if source_task.created_by_user_id and source_task.created_by_user_id != authenticated_user.user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied to this task")
|
||||
if source_task.project_id:
|
||||
check_project_access(source_task.project_id, authenticated_user.user.id, project_repository)
|
||||
|
||||
# 3. 创建正式生成任务,复制预览任务的配置
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
new_task = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=source_task.project_id,
|
||||
asset_library_id=source_task.asset_library_id,
|
||||
strategy_id=source_task.strategy_id,
|
||||
voice_library_id=source_task.voice_library_id,
|
||||
template_id=source_task.template_id,
|
||||
asset_ids=source_task.asset_ids,
|
||||
title_ids=source_task.title_ids,
|
||||
voice_ids=source_task.voice_ids,
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
source_edit_plan_id=source_task.source_edit_plan_id or "",
|
||||
asset_select_mode=source_task.asset_select_mode,
|
||||
video_title=getattr(source_task, "video_title", ""),
|
||||
resolution=getattr(source_task, "resolution", ""),
|
||||
is_preview=False,
|
||||
source_task_id=task_id,
|
||||
output_width=request.output_width,
|
||||
output_height=request.output_height,
|
||||
cover_url=request.cover_url,
|
||||
custom_title=request.custom_title,
|
||||
)
|
||||
)
|
||||
|
||||
# 4. 调度 worker.generate_video(同一条渲染路径)
|
||||
try:
|
||||
if not safe_enqueue_generation_task(
|
||||
new_task,
|
||||
generation_task_repository,
|
||||
user_id=authenticated_user.user.id,
|
||||
log_prefix="[确认生成]",
|
||||
log_task_status=True,
|
||||
):
|
||||
logger.warning("[确认生成] 入队失败: task_id=%s", new_task.id)
|
||||
except UserPendingLimitExceeded:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="您的待处理任务过多,请等待完成后再提交",
|
||||
) from None
|
||||
except GlobalQueueFull:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from None
|
||||
|
||||
return BatchGenerationTaskResponse(
|
||||
items=[_to_generation_task_response(new_task)],
|
||||
total=1,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=ListGenerationTasksResponse)
|
||||
def list_generation_tasks(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -428,6 +518,12 @@ def retry_generation_task(
|
||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||
video_title=getattr(task, "video_title", ""),
|
||||
resolution=getattr(task, "resolution", ""),
|
||||
is_preview=getattr(task, "is_preview", False),
|
||||
source_task_id=getattr(task, "source_task_id", ""),
|
||||
output_width=getattr(task, "output_width", 1280),
|
||||
output_height=getattr(task, "output_height", 720),
|
||||
cover_url=getattr(task, "cover_url", ""),
|
||||
custom_title=getattr(task, "custom_title", ""),
|
||||
)
|
||||
)
|
||||
try:
|
||||
|
||||
@@ -4,6 +4,15 @@ from datetime import datetime
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
|
||||
class ConfirmGenerationRequest(BaseModel):
|
||||
"""确认生成请求体 — 基于预览任务创建正式生成任务"""
|
||||
|
||||
output_width: int = Field(default=1080, description="输出视频宽度")
|
||||
output_height: int = Field(default=1920, description="输出视频高度")
|
||||
cover_url: str = Field(default="", description="自定义封面图片 URL")
|
||||
custom_title: str = Field(default="", description="自定义视频标题")
|
||||
|
||||
|
||||
class CreateGenerationTaskRequest(BaseModel):
|
||||
"""创建生成任务请求。
|
||||
|
||||
@@ -57,6 +66,13 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
default_factory=dict,
|
||||
description="自定义BGM配置,覆盖模板BGM设置。支持 enabled/source/asset_id/preset_id/audio_url/volume 等字段",
|
||||
)
|
||||
# ── 预览 / 确认生成 ──
|
||||
is_preview: bool = Field(default=False, description="是否为预览任务")
|
||||
source_task_id: str = Field(default="", description="来源预览任务 ID(确认生成时传入)")
|
||||
output_width: int = Field(default=1280, description="输出视频宽度")
|
||||
output_height: int = Field(default=720, description="输出视频高度")
|
||||
cover_url: str = Field(default="", description="封面图片 URL")
|
||||
custom_title: str = Field(default="", description="自定义视频标题")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
|
||||
@@ -87,6 +103,12 @@ class GenerationTaskResponse(BaseModel):
|
||||
video_title: str = ""
|
||||
resolution: str = ""
|
||||
bgm_config: dict = Field(default_factory=dict)
|
||||
is_preview: bool = False
|
||||
source_task_id: str = ""
|
||||
output_width: int = 1280
|
||||
output_height: int = 720
|
||||
cover_url: str = ""
|
||||
custom_title: str = ""
|
||||
status: str
|
||||
progress: float
|
||||
result_count: int
|
||||
|
||||
@@ -214,6 +214,8 @@ test.describe("Core generation flow", () => {
|
||||
const aiSwitch = page.locator(".xx-title-ai-toggle .xx-switch.active")
|
||||
if (await aiSwitch.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await aiSwitch.click()
|
||||
// 等待输入框出现(条件渲染,需要等待 DOM 更新)
|
||||
await expect(page.getByPlaceholder("输入或从标题库选择…")).toBeVisible({ timeout: 5000 })
|
||||
}
|
||||
const titleText = `E2E Test ${suffix}`
|
||||
await page.getByPlaceholder("输入或从标题库选择…").fill(titleText)
|
||||
|
||||
@@ -169,6 +169,7 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
const isLoading = previewStatus === "pending" || previewStatus === "generating"
|
||||
const isError = previewStatus === "error"
|
||||
const showTitlePreview = !!titleSettings
|
||||
const videoAspectStyle = { aspectRatio: (videoRatio || "16:9").replace(":", "/") }
|
||||
|
||||
// video 模式 refs
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
@@ -310,7 +311,7 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
{/* 生成中 */}
|
||||
{isLoading && (
|
||||
<div className="xx-preview-loading-panel">
|
||||
<div className="xx-preview-video">
|
||||
<div className="xx-preview-video" style={videoAspectStyle}>
|
||||
<div className="xx-preview-loading-center">
|
||||
<LoadingOutlined style={{ fontSize: 36, color: "#fff" }} spin />
|
||||
<p style={{ marginTop: 12, color: "rgba(255,255,255,0.8)", fontSize: 14 }}>
|
||||
@@ -327,7 +328,7 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
{/* 生成失败 */}
|
||||
{isError && (
|
||||
<div className="xx-preview-error-panel">
|
||||
<div className="xx-preview-video xx-preview-video--error">
|
||||
<div className="xx-preview-video xx-preview-video--error" style={videoAspectStyle}>
|
||||
<p style={{ color: "rgba(255,255,255,0.8)", fontSize: 14 }}>预览生成失败</p>
|
||||
</div>
|
||||
<p className="xx-preview-error-msg">
|
||||
@@ -342,7 +343,7 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
{/* 预览成功 + Canvas 标题叠加 */}
|
||||
{hasPreview && (
|
||||
<div ref={containerRef} style={{ position: "relative" }}>
|
||||
<div className="xx-preview-video">
|
||||
<div className="xx-preview-video" style={videoAspectStyle}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={previewResult.videoUrl}
|
||||
|
||||
@@ -42,7 +42,7 @@ const PREVIEW_COUNT_OPTIONS = [
|
||||
const Step4GeneratePreview: React.FC<Step4GeneratePreviewProps> = ({
|
||||
templateName: _templateName,
|
||||
materialCount: _materialCount,
|
||||
videoRatio: _videoRatio,
|
||||
videoRatio,
|
||||
previewCount,
|
||||
onPreviewCountChange,
|
||||
items,
|
||||
@@ -55,6 +55,7 @@ const Step4GeneratePreview: React.FC<Step4GeneratePreviewProps> = ({
|
||||
onGeneratePreview,
|
||||
onRegeneratePreview,
|
||||
}) => {
|
||||
const aspectRatio = (videoRatio || "16:9").replace(":", "/") // "9:16" → "9/16", "16:9" → "16/9"
|
||||
const isIdle = overallStatus === "idle"
|
||||
const isError = overallStatus === "error" && !items.some((it) => it.status === "ready")
|
||||
|
||||
@@ -134,11 +135,13 @@ const Step4GeneratePreview: React.FC<Step4GeneratePreviewProps> = ({
|
||||
{/* 多预览网格(生成中/完成/部分完成) */}
|
||||
{(anyGenerating || overallStatus === "ready") && items.length > 0 && (
|
||||
<div
|
||||
className="xx-preview-grid"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${Math.min(items.length, 3)}, 1fr)`,
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
maxWidth: `${Math.min(items.length, 3) * 280 + (Math.min(items.length, 3) - 1) * 12}px`,
|
||||
margin: "0 auto 16px",
|
||||
}}
|
||||
>
|
||||
{items.map((item) => {
|
||||
@@ -161,8 +164,7 @@ const Step4GeneratePreview: React.FC<Step4GeneratePreviewProps> = ({
|
||||
{/* 缩略图/状态区域 */}
|
||||
<div
|
||||
style={{
|
||||
aspectRatio: "9/16",
|
||||
maxHeight: 180,
|
||||
aspectRatio,
|
||||
background: "#000",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -891,7 +891,7 @@
|
||||
|
||||
/* ── 视频预览 ── */
|
||||
.xx-preview-video {
|
||||
aspect-ratio: 9 / 16;
|
||||
aspect-ratio: 16 / 9;
|
||||
max-height: 400px;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(135deg, var(--color-gray-900), var(--color-primary-900));
|
||||
@@ -2356,6 +2356,8 @@
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 2px dashed var(--border-color);
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.xx-preview-generate-hint {
|
||||
@@ -2381,6 +2383,7 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
/* ── 加载中状态 ── */
|
||||
.xx-preview-loading {
|
||||
text-align: center;
|
||||
@@ -2388,6 +2391,8 @@
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.xx-preview-loading-text {
|
||||
@@ -2403,13 +2408,15 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 错误状态 ── */
|
||||
/* ── 错误状态(手机屏尺寸)── */
|
||||
.xx-preview-error {
|
||||
text-align: center;
|
||||
padding: 32px 20px;
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.xx-preview-error-text {
|
||||
@@ -2456,6 +2463,9 @@
|
||||
margin-bottom: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
max-width: 320px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.xx-preview-plan-card {
|
||||
@@ -2570,6 +2580,16 @@
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* ── 整体进度条(手机屏尺寸)── */
|
||||
.xx-preview-progress-bar {
|
||||
max-width: 320px;
|
||||
margin: 12px auto;
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-preview-progress-bar-wrap {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
@@ -2647,7 +2667,7 @@
|
||||
.xx-preview-video-wrapper .xx-preview-video {
|
||||
max-width: 300px;
|
||||
width: 100%;
|
||||
aspect-ratio: 9 / 16;
|
||||
aspect-ratio: 16 / 9;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
Regular → Executable
+6
-4
@@ -465,7 +465,7 @@ class RenderAdapter:
|
||||
失败不阻断主流程,返回 None。
|
||||
"""
|
||||
try:
|
||||
from services.asr_service_factory import get_asr_service
|
||||
from apps.worker.services.asr_service_factory import get_asr_service
|
||||
|
||||
return get_asr_service()
|
||||
except Exception as e:
|
||||
@@ -504,11 +504,13 @@ class RenderAdapter:
|
||||
|
||||
self._report_progress(progress_cb, 40.0, "执行视频渲染")
|
||||
|
||||
# 2. 初始化 ASR(预览模式跳过,节省启动开销)
|
||||
asr_service = None if is_preview else self._get_asr_service()
|
||||
# 2. 初始化 ASR
|
||||
# 预览模式下,如果 plan.config 中存在 voice_id,仍需初始化 ASR 以支持配音
|
||||
plan_config = plan.config or {}
|
||||
has_voice_id = bool(plan_config.get("voice_id"))
|
||||
asr_service = None if (is_preview and not has_voice_id) else self._get_asr_service()
|
||||
|
||||
# 3. 读取输出分辨率
|
||||
plan_config = plan.config or {}
|
||||
export_config = plan_config.get("export", {}) or {}
|
||||
output_width, output_height = _parse_resolution(export_config.get("resolution"))
|
||||
logger.info(
|
||||
|
||||
@@ -679,20 +679,40 @@ class UnifiedRenderService:
|
||||
len(top_text),
|
||||
)
|
||||
# 方式B:voice_id + 自动字幕 → 字幕对齐配音(预设配音模式)
|
||||
elif top_voice_id and subtitle_cfg.get("auto_generated", False) and self.asr_service is not None:
|
||||
tts_cfg = {
|
||||
"enabled": True,
|
||||
"voice_id": top_voice_id,
|
||||
"text": "",
|
||||
"align_mode": "subtitle",
|
||||
"overlap_mode": "replace",
|
||||
}
|
||||
use_subtitle_align = True
|
||||
logger.info(
|
||||
"[unified-render] 检测到预设配音+自动字幕,使用字幕对齐模式: plan_id=%s voice_id=%s",
|
||||
self.plan.id,
|
||||
top_voice_id,
|
||||
)
|
||||
# ASR 可用时走字幕对齐模式,不可用时降级为整段配音
|
||||
elif top_voice_id and subtitle_cfg.get("auto_generated", False):
|
||||
if self.asr_service is not None:
|
||||
# 字幕对齐模式
|
||||
tts_cfg = {
|
||||
"enabled": True,
|
||||
"voice_id": top_voice_id,
|
||||
"text": "",
|
||||
"align_mode": "subtitle",
|
||||
"overlap_mode": "replace",
|
||||
}
|
||||
use_subtitle_align = True
|
||||
logger.info(
|
||||
"[unified-render] 检测到预设配音+自动字幕,使用字幕对齐模式: plan_id=%s voice_id=%s",
|
||||
self.plan.id,
|
||||
top_voice_id,
|
||||
)
|
||||
else:
|
||||
# 降级:整段配音(预览模式 ASR 不可用时)
|
||||
# 拼接字幕文本作为配音内容
|
||||
subtitle_text_content = subtitle_cfg.get("text", "") or ""
|
||||
tts_cfg = {
|
||||
"enabled": True,
|
||||
"voice_id": top_voice_id,
|
||||
"text": subtitle_text_content,
|
||||
"align_mode": "full",
|
||||
"overlap_mode": "replace",
|
||||
}
|
||||
logger.info(
|
||||
"[unified-render] ASR 不可用,降级为整段配音模式: plan_id=%s voice_id=%s text_len=%d",
|
||||
self.plan.id,
|
||||
top_voice_id,
|
||||
len(subtitle_text_content),
|
||||
)
|
||||
|
||||
# 兼容前端顶层字段:voice_id / custom_text / voice_clone_profile_id
|
||||
# 前端一键生成页面传 config.voice_id + config.custom_text,
|
||||
|
||||
@@ -1040,6 +1040,12 @@ def _load_task_info(task_id: str) -> dict | None:
|
||||
"resolution": getattr(gen_task, "resolution", "") or "",
|
||||
"bgm_config": dict(getattr(gen_task, "bgm_config", {}) or {}),
|
||||
"is_preview": bool(getattr(gen_task, "is_preview", False)),
|
||||
"source_task_id": getattr(gen_task, "source_task_id", "") or "",
|
||||
"output_width": getattr(gen_task, "output_width", OUTPUT_WIDTH) or OUTPUT_WIDTH,
|
||||
"output_height": getattr(gen_task, "output_height", OUTPUT_HEIGHT) or OUTPUT_HEIGHT,
|
||||
"cover_url": getattr(gen_task, "cover_url", "") or "",
|
||||
"custom_title": getattr(gen_task, "custom_title", "") or "",
|
||||
"voice_ids": list(getattr(gen_task, "voice_ids", []) or []),
|
||||
}
|
||||
finally:
|
||||
session.close()
|
||||
@@ -1101,6 +1107,7 @@ def _render_video(
|
||||
resolution: str = "",
|
||||
bgm_config: dict | None = None,
|
||||
is_preview: bool = False,
|
||||
voice_ids: list[str] | None = None,
|
||||
) -> tuple[Path, float]:
|
||||
"""渲染视频(含配音混音)。
|
||||
|
||||
@@ -1175,6 +1182,20 @@ def _render_video(
|
||||
plan_cfg["export"] = export_cfg
|
||||
virtual_plan.config = plan_cfg
|
||||
|
||||
# 注入用户选择的配音 voice_id(ASR 字幕对齐模式)
|
||||
if voice_ids:
|
||||
plan_cfg = dict(virtual_plan.config or {})
|
||||
plan_cfg["voice_id"] = voice_ids[0]
|
||||
subtitle_cfg = plan_cfg.get("subtitle", {}) or {}
|
||||
subtitle_cfg["auto_generated"] = True
|
||||
plan_cfg["subtitle"] = subtitle_cfg
|
||||
virtual_plan.config = plan_cfg
|
||||
logger.info(
|
||||
"[task_id=%s] [渲染] 预览配音已注入: voice_id=%s",
|
||||
task_id,
|
||||
voice_ids[0],
|
||||
)
|
||||
|
||||
total_duration = sum(c.duration for c in virtual_clips)
|
||||
logger.info(
|
||||
"[task_id=%s] [剪辑计划] 片段数=%d, 总时长=%.1fs",
|
||||
@@ -1415,6 +1436,14 @@ def generate_video(self, task_id: str) -> dict:
|
||||
|
||||
# ── 3. 渲染 + 混音 ───────────────────────────────────────────────
|
||||
_update_task_progress(task_id, 40, "开始渲染")
|
||||
# 动态分辨率:优先使用 output_width/output_height,其次 resolution 字符串
|
||||
_ow = task_info.get("output_width", OUTPUT_WIDTH) or OUTPUT_WIDTH
|
||||
_oh = task_info.get("output_height", OUTPUT_HEIGHT) or OUTPUT_HEIGHT
|
||||
if _ow != OUTPUT_WIDTH or _oh != OUTPUT_HEIGHT:
|
||||
_resolved_resolution = f"{_ow}x{_oh}"
|
||||
else:
|
||||
_resolved_resolution = task_info.get("resolution", "")
|
||||
|
||||
output_path, render_duration = _render_video(
|
||||
task_id=task_id,
|
||||
downloaded_videos=downloaded_videos,
|
||||
@@ -1425,9 +1454,10 @@ def generate_video(self, task_id: str) -> dict:
|
||||
user_id=user_id,
|
||||
temp_path=temp_path,
|
||||
output_name=output_name,
|
||||
resolution=task_info.get("resolution", ""),
|
||||
resolution=_resolved_resolution,
|
||||
bgm_config=task_info.get("bgm_config", {}),
|
||||
is_preview=task_info.get("is_preview", False),
|
||||
voice_ids=task_info.get("voice_ids", []),
|
||||
)
|
||||
|
||||
if gen_task:
|
||||
|
||||
@@ -1659,6 +1659,54 @@
|
||||
"primary_key": false,
|
||||
"type": "DATETIME",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "is_preview",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "BOOLEAN",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": true,
|
||||
"name": "source_task_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "output_width",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "INTEGER",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "output_height",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "INTEGER",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "cover_url",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(1000)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "custom_title",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(500)",
|
||||
"unique": false
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
@@ -1717,6 +1765,13 @@
|
||||
],
|
||||
"name": "ix_generation_tasks_template_id",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"source_task_id"
|
||||
],
|
||||
"name": "ix_generation_tasks_source_task_id",
|
||||
"unique": false
|
||||
}
|
||||
],
|
||||
"primary_key": [
|
||||
@@ -3515,4 +3570,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,7 +145,7 @@ docker run -d \
|
||||
--network xiaoxia-net-production \
|
||||
-e APP_ENV=production \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e WORKER_CONCURRENCY=1 \
|
||||
-e WORKER_CONCURRENCY="${WORKER_CONCURRENCY:-4}" \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
|
||||
@@ -81,7 +81,7 @@ export WEB_DOCKERFILE=infra/docker/web-artifact.Dockerfile
|
||||
export WEB_NGINX_CONF=infra/docker/nginx-production.conf
|
||||
|
||||
docker network create xiaoxia-net-production 2>/dev/null || true
|
||||
export WORKER_CONCURRENCY="${WORKER_CONCURRENCY:-1}"
|
||||
export WORKER_CONCURRENCY="${WORKER_CONCURRENCY:-4}"
|
||||
export WORKER_MAX_TASKS_PER_CHILD="${WORKER_MAX_TASKS_PER_CHILD:-100}"
|
||||
|
||||
if [ "${ALLOW_PRODUCTION_BUILDS:-false}" = "true" ]; then
|
||||
|
||||
@@ -108,7 +108,7 @@ docker run -d \
|
||||
--network xiaoxia-net-staging \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e WORKER_CONCURRENCY=1 \
|
||||
-e WORKER_CONCURRENCY="${WORKER_CONCURRENCY:-4}" \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
|
||||
@@ -37,6 +37,11 @@ def _to_domain(model: GenerationTaskModel) -> GenerationTask:
|
||||
resolution=getattr(model, "resolution", "") or "",
|
||||
bgm_config=dict(getattr(model, "bgm_config", {}) or {}),
|
||||
is_preview=bool(getattr(model, "is_preview", False)),
|
||||
source_task_id=getattr(model, "source_task_id", "") or "",
|
||||
output_width=getattr(model, "output_width", 1280) or 1280,
|
||||
output_height=getattr(model, "output_height", 720) or 720,
|
||||
cover_url=getattr(model, "cover_url", "") or "",
|
||||
custom_title=getattr(model, "custom_title", "") or "",
|
||||
logs=model.logs or "[]",
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
@@ -76,6 +81,11 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
resolution=task.resolution or "",
|
||||
bgm_config=task.bgm_config or {},
|
||||
is_preview=task.is_preview or False,
|
||||
source_task_id=task.source_task_id or "",
|
||||
output_width=task.output_width,
|
||||
output_height=task.output_height,
|
||||
cover_url=task.cover_url or "",
|
||||
custom_title=task.custom_title or "",
|
||||
logs=task.logs,
|
||||
created_at=task.created_at,
|
||||
updated_at=task.updated_at,
|
||||
@@ -242,6 +252,11 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
model.bgm_config = task.bgm_config or {}
|
||||
if hasattr(model, "is_preview"):
|
||||
model.is_preview = task.is_preview or False
|
||||
model.source_task_id = task.source_task_id or ""
|
||||
model.output_width = task.output_width
|
||||
model.output_height = task.output_height
|
||||
model.cover_url = task.cover_url or ""
|
||||
model.custom_title = task.custom_title or ""
|
||||
model.logs = task.logs
|
||||
self.session.commit()
|
||||
return task
|
||||
|
||||
@@ -293,6 +293,11 @@ class GenerationTaskModel(Base):
|
||||
video_title = Column(String(255), nullable=False, default="")
|
||||
resolution = Column(String(20), nullable=False, default="")
|
||||
is_preview = Column(Boolean, nullable=False, default=False, index=True)
|
||||
source_task_id = Column(String(32), nullable=False, default="", index=True)
|
||||
output_width = Column(Integer, nullable=False, default=1280)
|
||||
output_height = Column(Integer, nullable=False, default=720)
|
||||
cover_url = Column(String(1000), nullable=False, default="")
|
||||
custom_title = Column(String(500), nullable=False, default="")
|
||||
bgm_config = Column(JSON, nullable=False, default=dict)
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
logs = Column(Text, nullable=False, default="[]", server_default="[]")
|
||||
|
||||
@@ -27,6 +27,11 @@ class CreateGenerationTaskCommand:
|
||||
auto_retry_enabled: bool = False
|
||||
auto_retry_max: int = 0
|
||||
is_preview: bool = False
|
||||
source_task_id: str = ""
|
||||
output_width: int = 1280
|
||||
output_height: int = 720
|
||||
cover_url: str = ""
|
||||
custom_title: str = ""
|
||||
|
||||
|
||||
class CreateGenerationTaskUseCase:
|
||||
@@ -58,6 +63,11 @@ class CreateGenerationTaskUseCase:
|
||||
auto_retry_enabled=command.auto_retry_enabled,
|
||||
auto_retry_max=command.auto_retry_max,
|
||||
is_preview=command.is_preview,
|
||||
source_task_id=command.source_task_id,
|
||||
output_width=command.output_width,
|
||||
output_height=command.output_height,
|
||||
cover_url=command.cover_url,
|
||||
custom_title=command.custom_title,
|
||||
)
|
||||
return self.generation_task_repository.create(task)
|
||||
|
||||
|
||||
@@ -116,6 +116,11 @@ class GenerationTask:
|
||||
resolution: str = ""
|
||||
bgm_config: dict = field(default_factory=dict)
|
||||
is_preview: bool = False
|
||||
source_task_id: str = ""
|
||||
output_width: int = 1280
|
||||
output_height: int = 720
|
||||
cover_url: str = ""
|
||||
custom_title: str = ""
|
||||
logs: str = "[]"
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
@@ -142,6 +147,11 @@ class GenerationTask:
|
||||
auto_retry_enabled: bool = False,
|
||||
auto_retry_max: int = 0,
|
||||
is_preview: bool = False,
|
||||
source_task_id: str = "",
|
||||
output_width: int = 1280,
|
||||
output_height: int = 720,
|
||||
cover_url: str = "",
|
||||
custom_title: str = "",
|
||||
) -> "GenerationTask":
|
||||
if not project_id.strip() and not template_id.strip():
|
||||
raise ValueError("project_id 或 template_id 至少需要提供一个")
|
||||
@@ -167,6 +177,11 @@ class GenerationTask:
|
||||
auto_retry_enabled=auto_retry_enabled,
|
||||
auto_retry_max=auto_retry_max,
|
||||
is_preview=is_preview,
|
||||
source_task_id=source_task_id,
|
||||
output_width=output_width,
|
||||
output_height=output_height,
|
||||
cover_url=cover_url,
|
||||
custom_title=custom_title,
|
||||
)
|
||||
|
||||
# ── 状态查询 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -75,7 +75,7 @@ def check_required_contexts(token, repo, sha, contexts):
|
||||
|
||||
for ctx in contexts:
|
||||
state = statuses.get(ctx, "pending")
|
||||
if state != "success":
|
||||
if state not in ("success", "skipped"):
|
||||
all_success = False
|
||||
if state == "pending":
|
||||
any_pending = True
|
||||
|
||||
@@ -217,8 +217,8 @@ class TestPreviewSkipsASR:
|
||||
source = f.read()
|
||||
|
||||
assert (
|
||||
"None if is_preview else self._get_asr_service()" in source
|
||||
), "Should skip ASR initialization in preview mode"
|
||||
"None if (is_preview and not has_voice_id) else self._get_asr_service()" in source
|
||||
), "Should skip ASR initialization in preview mode unless voice_id is provided"
|
||||
|
||||
|
||||
# ── 5. 并行下载逻辑 ──
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
"""测试 #1294 修复:预览视频配音注入。
|
||||
|
||||
验证:
|
||||
1. _load_task_info 正确加载 voice_ids
|
||||
2. _render_video 接受 voice_ids 参数
|
||||
3. voice_ids 正确注入到 plan config 中(实际执行代码路径,diff-cover 可达)
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestLoadTaskInfoVoiceIds:
|
||||
"""验证 _load_task_info 包含 voice_ids"""
|
||||
|
||||
def test_voice_ids_loaded_from_task(self):
|
||||
"""voice_ids 从 gen_task 正确加载"""
|
||||
mock_task = MagicMock()
|
||||
mock_task.project_id = "proj_1"
|
||||
mock_task.asset_library_id = "lib_1"
|
||||
mock_task.voice_library_id = "voice_lib_1"
|
||||
mock_task.template_id = "tmpl_1"
|
||||
mock_task.strategy_id = "one_take"
|
||||
mock_task.asset_ids = ["a1", "a2"]
|
||||
mock_task.batch_id = "batch_1"
|
||||
mock_task.created_by_user_id = "user_1"
|
||||
mock_task.video_title = "test"
|
||||
mock_task.resolution = "854x480"
|
||||
mock_task.bgm_config = {}
|
||||
mock_task.is_preview = True
|
||||
mock_task.voice_ids = ["voice_1", "voice_2"]
|
||||
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.generation_task_repository.SQLAlchemyGenerationTaskRepository"
|
||||
) as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = mock_task
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
from worker_app.tasks.generation import _load_task_info
|
||||
|
||||
result = _load_task_info("test_task_id")
|
||||
|
||||
assert result is not None
|
||||
assert result["voice_ids"] == ["voice_1", "voice_2"]
|
||||
|
||||
def test_voice_ids_empty_when_none(self):
|
||||
"""voice_ids 为 None 时返回空列表"""
|
||||
mock_task = MagicMock()
|
||||
mock_task.project_id = "proj_1"
|
||||
mock_task.asset_library_id = "lib_1"
|
||||
mock_task.voice_library_id = ""
|
||||
mock_task.template_id = "tmpl_1"
|
||||
mock_task.strategy_id = "one_take"
|
||||
mock_task.asset_ids = ["a1"]
|
||||
mock_task.batch_id = ""
|
||||
mock_task.created_by_user_id = "user_1"
|
||||
mock_task.video_title = ""
|
||||
mock_task.resolution = ""
|
||||
mock_task.bgm_config = {}
|
||||
mock_task.is_preview = False
|
||||
mock_task.voice_ids = None
|
||||
|
||||
with patch(
|
||||
"packages.adapters.sqlalchemy_impl.generation_task_repository.SQLAlchemyGenerationTaskRepository"
|
||||
) as MockRepo:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get.return_value = mock_task
|
||||
MockRepo.return_value = mock_repo
|
||||
|
||||
from worker_app.tasks.generation import _load_task_info
|
||||
|
||||
result = _load_task_info("test_task_id")
|
||||
assert result["voice_ids"] == []
|
||||
|
||||
|
||||
class TestRenderVideoVoiceInjection:
|
||||
"""验证 _render_video 正确注入 voice_id 到 plan config(实际执行代码路径)"""
|
||||
|
||||
def test_render_video_accepts_voice_ids(self):
|
||||
"""_render_video 签名包含 voice_ids 参数"""
|
||||
import inspect
|
||||
|
||||
from worker_app.tasks.generation import _render_video
|
||||
|
||||
sig = inspect.signature(_render_video)
|
||||
assert "voice_ids" in sig.parameters
|
||||
|
||||
def test_voice_ids_default_none(self):
|
||||
"""voice_ids 参数默认为 None"""
|
||||
import inspect
|
||||
|
||||
from worker_app.tasks.generation import _render_video
|
||||
|
||||
sig = inspect.signature(_render_video)
|
||||
param = sig.parameters["voice_ids"]
|
||||
assert param.default is None
|
||||
|
||||
def test_voice_ids_injected_into_plan_config(self):
|
||||
"""voice_ids 非空时,voice_id 和 subtitle.auto_generated 被注入到 plan config。
|
||||
|
||||
此测试实际执行 _render_video 的配音注入代码路径,确保 diff-cover 覆盖新增行。
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@dataclass
|
||||
class MockClip:
|
||||
"""模拟 VirtualClip,至少需要 duration 属性。"""
|
||||
|
||||
id: str = "clip_1"
|
||||
duration: float = 5.0
|
||||
config: dict = field(default_factory=dict)
|
||||
|
||||
@dataclass
|
||||
class MockPlan:
|
||||
"""模拟 VirtualPlan,至少需要 config 属性。"""
|
||||
|
||||
id: str = "test_plan"
|
||||
name: str = "test"
|
||||
config: dict = field(default_factory=dict)
|
||||
|
||||
mock_plan = MockPlan(config={"some_key": "some_value"})
|
||||
mock_clips = [MockClip(duration=5.0), MockClip(duration=3.0)]
|
||||
mock_asset_path_map = {"asset_1": Path("/tmp/video1.mp4")}
|
||||
|
||||
# Mock RenderAdapter 和 render 结果
|
||||
mock_render_result = MagicMock()
|
||||
mock_render_result.success = True
|
||||
mock_render_result.output_path = Path("/tmp/output.mp4")
|
||||
mock_render_result.duration = 8.0
|
||||
|
||||
mock_adapter_cls = MagicMock(return_value=MagicMock())
|
||||
mock_adapter_cls.return_value.render_from_memory.return_value = mock_render_result
|
||||
|
||||
mock_db = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"worker_app.tasks.generation._build_plan_and_clips_from_task",
|
||||
return_value=(mock_plan, mock_clips, mock_asset_path_map),
|
||||
),
|
||||
patch(
|
||||
"worker_app.tasks.generation._load_template_plan_config",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"video_processing.render_adapter.RenderAdapter",
|
||||
mock_adapter_cls,
|
||||
),
|
||||
patch(
|
||||
"worker_app.tasks.generation.SessionLocal",
|
||||
return_value=mock_db,
|
||||
),
|
||||
):
|
||||
from worker_app.tasks.generation import _render_video
|
||||
|
||||
from packages.domain import EditingMode
|
||||
|
||||
output_path, render_duration = _render_video(
|
||||
task_id="test_task_123",
|
||||
downloaded_videos=[Path("/tmp/video1.mp4")],
|
||||
voice_path=None,
|
||||
editing_mode=EditingMode.ONE_TAKE,
|
||||
project_id="proj_1",
|
||||
template_id="tmpl_1",
|
||||
user_id="user_1",
|
||||
temp_path=Path("/tmp"),
|
||||
output_name="test_output.mp4",
|
||||
resolution="854x480",
|
||||
is_preview=True,
|
||||
voice_ids=["voice_abc"],
|
||||
)
|
||||
|
||||
# 验证 voice_id 被注入到 plan config(覆盖新增代码行)
|
||||
assert mock_plan.config.get("voice_id") == "voice_abc"
|
||||
# 验证 subtitle.auto_generated 被设置为 True
|
||||
assert mock_plan.config.get("subtitle", {}).get("auto_generated") is True
|
||||
# 验证 RenderAdapter 被调用
|
||||
mock_adapter_cls.return_value.render_from_memory.assert_called_once()
|
||||
# 验证返回值
|
||||
assert output_path == Path("/tmp/output.mp4")
|
||||
assert render_duration == 8.0
|
||||
|
||||
def test_voice_ids_empty_skips_injection(self):
|
||||
"""voice_ids 为空时,不注入 voice_id 到 plan config"""
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@dataclass
|
||||
class MockClip:
|
||||
id: str = "clip_1"
|
||||
duration: float = 5.0
|
||||
|
||||
@dataclass
|
||||
class MockPlan:
|
||||
id: str = "test_plan"
|
||||
name: str = "test"
|
||||
config: dict = field(default_factory=dict)
|
||||
|
||||
mock_plan = MockPlan(config={"export": {"resolution": "854x480"}})
|
||||
mock_clips = [MockClip(duration=5.0)]
|
||||
|
||||
mock_render_result = MagicMock()
|
||||
mock_render_result.success = True
|
||||
mock_render_result.output_path = Path("/tmp/output.mp4")
|
||||
mock_render_result.duration = 5.0
|
||||
|
||||
mock_adapter_cls = MagicMock(return_value=MagicMock())
|
||||
mock_adapter_cls.return_value.render_from_memory.return_value = mock_render_result
|
||||
|
||||
with (
|
||||
patch(
|
||||
"worker_app.tasks.generation._build_plan_and_clips_from_task",
|
||||
return_value=(mock_plan, mock_clips, {}),
|
||||
),
|
||||
patch(
|
||||
"worker_app.tasks.generation._load_template_plan_config",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"video_processing.render_adapter.RenderAdapter",
|
||||
mock_adapter_cls,
|
||||
),
|
||||
patch(
|
||||
"worker_app.tasks.generation.SessionLocal",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
):
|
||||
from worker_app.tasks.generation import _render_video
|
||||
|
||||
from packages.domain import EditingMode
|
||||
|
||||
_render_video(
|
||||
task_id="test_task_456",
|
||||
downloaded_videos=[Path("/tmp/video1.mp4")],
|
||||
voice_path=None,
|
||||
editing_mode=EditingMode.ONE_TAKE,
|
||||
project_id="proj_1",
|
||||
template_id="",
|
||||
user_id="user_1",
|
||||
temp_path=Path("/tmp"),
|
||||
output_name="test_output.mp4",
|
||||
voice_ids=[],
|
||||
)
|
||||
|
||||
# 验证 voice_id 没有被注入
|
||||
assert "voice_id" not in mock_plan.config
|
||||
|
||||
|
||||
class TestGenerateVideoPassesVoiceIds:
|
||||
"""验证 generate_video 调用 _render_video 时传递 voice_ids"""
|
||||
|
||||
def test_generate_video_passes_voice_ids(self):
|
||||
"""generate_video 中 _render_video 调用包含 voice_ids 参数"""
|
||||
with open("apps/worker/worker_app/tasks/generation.py", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
assert 'voice_ids=task_info.get("voice_ids", [])' in content
|
||||
@@ -0,0 +1,387 @@
|
||||
"""确认生成 API 单元测试.
|
||||
|
||||
覆盖 POST /tasks/{task_id}/confirm 端点:
|
||||
- 正常确认流程
|
||||
- 预览任务不存在 → 404
|
||||
- 权限不足 → 403
|
||||
- is_preview=False 及分辨率正确
|
||||
- cover_url 和 custom_title 正确传递
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
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 list_by_project(self, project_id: str) -> list[Any]:
|
||||
return [t for t in self._store.values() if t.project_id == project_id]
|
||||
|
||||
def list_by_user(self, user_id: str) -> list[Any]:
|
||||
return [t for t in self._store.values() if t.created_by_user_id == user_id]
|
||||
|
||||
def count_by_user(self, user_id: str) -> int:
|
||||
return len([t for t in self._store.values() if t.created_by_user_id == user_id])
|
||||
|
||||
def count_pending_by_user(self, user_id: str) -> int:
|
||||
return len(
|
||||
[
|
||||
t
|
||||
for t in self._store.values()
|
||||
if t.created_by_user_id == user_id and t.status == GenerationTaskStatus.PENDING
|
||||
]
|
||||
)
|
||||
|
||||
def count_pending_total(self) -> int:
|
||||
return len([t for t in self._store.values() if t.status == GenerationTaskStatus.PENDING])
|
||||
|
||||
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[Any]:
|
||||
items = [t for t in self._store.values() if t.created_by_user_id == user_id]
|
||||
items.sort(key=lambda t: t.created_at, reverse=True)
|
||||
return items[:limit]
|
||||
|
||||
def list_by_source_edit_plan(self, plan_id: str) -> list[Any]:
|
||||
return [t for t in self._store.values() if (t.source_edit_plan_id or "") == plan_id]
|
||||
|
||||
|
||||
# ── Stub Project Repository ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeProject:
|
||||
id: str = "project-001"
|
||||
owner_user_id: str = "user-001"
|
||||
shared_users: list[str] = field(default_factory=list)
|
||||
name: str = "Test Project"
|
||||
|
||||
def can_access(self, user_id: str) -> bool:
|
||||
return user_id == self.owner_user_id or user_id in self.shared_users
|
||||
|
||||
|
||||
class StubProjectRepository:
|
||||
def __init__(self) -> None:
|
||||
self._projects: dict[str, FakeProject] = {}
|
||||
|
||||
def add(self, project: FakeProject) -> None:
|
||||
self._projects[project.id] = project
|
||||
|
||||
def find_by_id(self, project_id: str) -> Optional[FakeProject]:
|
||||
return self._projects.get(project_id)
|
||||
|
||||
|
||||
# ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gen_task_repo() -> StubGenerationTaskRepository:
|
||||
return StubGenerationTaskRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_repo() -> StubProjectRepository:
|
||||
repo = StubProjectRepository()
|
||||
repo.add(FakeProject())
|
||||
return repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(
|
||||
gen_task_repo: StubGenerationTaskRepository,
|
||||
project_repo: StubProjectRepository,
|
||||
) -> FastAPI:
|
||||
"""构建测试 FastAPI 应用,注入 Stub Repository"""
|
||||
from app.api.routes.generation_tasks import router
|
||||
from app.auth import get_current_user
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_generated_video_repository,
|
||||
get_generation_task_repository,
|
||||
get_project_repository,
|
||||
)
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router, prefix="/api/v1")
|
||||
|
||||
def override_get_current_user():
|
||||
return FakeAuthenticatedUser()
|
||||
|
||||
def override_get_generation_task_repository():
|
||||
return gen_task_repo
|
||||
|
||||
def override_get_project_repository():
|
||||
return project_repo
|
||||
|
||||
test_app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
test_app.dependency_overrides[get_generation_task_repository] = override_get_generation_task_repository
|
||||
test_app.dependency_overrides[get_project_repository] = override_get_project_repository
|
||||
# Stubs for repositories not used by confirm endpoint but required by router
|
||||
test_app.dependency_overrides[get_asset_library_repository] = lambda: MagicMock()
|
||||
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_preview_task(**kwargs: Any) -> GenerationTask:
|
||||
"""创建预览任务"""
|
||||
defaults = dict(
|
||||
id="preview-task-001",
|
||||
project_id="project-001",
|
||||
asset_library_id="library-001",
|
||||
strategy_id="one_take",
|
||||
voice_library_id="",
|
||||
template_id="",
|
||||
asset_ids=["asset-1"],
|
||||
title_ids=[],
|
||||
voice_ids=[],
|
||||
status=GenerationTaskStatus.COMPLETED,
|
||||
progress=100.0,
|
||||
result_count=1,
|
||||
error_message="",
|
||||
created_by_user_id="user-001",
|
||||
source_edit_plan_id="",
|
||||
asset_select_mode="all",
|
||||
is_preview=True,
|
||||
source_task_id="",
|
||||
output_width=1280,
|
||||
output_height=720,
|
||||
cover_url="",
|
||||
custom_title="",
|
||||
video_title="",
|
||||
resolution="",
|
||||
bgm_config={},
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return GenerationTask(**defaults)
|
||||
|
||||
|
||||
# ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConfirmGeneration:
|
||||
def test_confirm_success(
|
||||
self,
|
||||
client: TestClient,
|
||||
gen_task_repo: StubGenerationTaskRepository,
|
||||
) -> None:
|
||||
"""正常确认流程:预览任务存在、权限正确 → 创建正式任务"""
|
||||
preview = _make_preview_task()
|
||||
gen_task_repo.create(preview)
|
||||
|
||||
with patch("app.api.routes.generation_tasks.safe_enqueue_generation_task", return_value=True) as mock_enqueue:
|
||||
resp = client.post(
|
||||
f"/api/v1/tasks/{preview.id}/confirm",
|
||||
json={
|
||||
"output_width": 1080,
|
||||
"output_height": 1920,
|
||||
"cover_url": "https://example.com/cover.jpg",
|
||||
"custom_title": "我的视频",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
item = data["items"][0]
|
||||
assert item["is_preview"] is False
|
||||
assert item["source_task_id"] == preview.id
|
||||
assert item["output_width"] == 1080
|
||||
assert item["output_height"] == 1920
|
||||
assert item["cover_url"] == "https://example.com/cover.jpg"
|
||||
assert item["custom_title"] == "我的视频"
|
||||
# 复制了预览任务的配置
|
||||
assert item["project_id"] == "project-001"
|
||||
assert item["asset_library_id"] == "library-001"
|
||||
assert item["strategy_id"] == "one_take"
|
||||
assert item["asset_ids"] == ["asset-1"]
|
||||
|
||||
# 验证入队函数被调用
|
||||
mock_enqueue.assert_called_once()
|
||||
|
||||
def test_confirm_not_found(self, client: TestClient) -> None:
|
||||
"""预览任务不存在 → 404"""
|
||||
resp = client.post(
|
||||
"/api/v1/tasks/nonexistent-task/confirm",
|
||||
json={"output_width": 1080, "output_height": 1920},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.json()["detail"]
|
||||
|
||||
def test_confirm_access_denied(
|
||||
self,
|
||||
client: TestClient,
|
||||
gen_task_repo: StubGenerationTaskRepository,
|
||||
) -> None:
|
||||
"""权限不足 → 403"""
|
||||
preview = _make_preview_task(created_by_user_id="other-user-999")
|
||||
gen_task_repo.create(preview)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/tasks/{preview.id}/confirm",
|
||||
json={"output_width": 1080, "output_height": 1920},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert "denied" in resp.json()["detail"].lower() or "Access" in resp.json()["detail"]
|
||||
|
||||
def test_confirm_preserves_config(
|
||||
self,
|
||||
client: TestClient,
|
||||
gen_task_repo: StubGenerationTaskRepository,
|
||||
) -> None:
|
||||
"""确认后的任务 is_preview=False,分辨率已更新,其余配置从预览任务复制"""
|
||||
preview = _make_preview_task(
|
||||
voice_library_id="voice-001",
|
||||
template_id="tmpl-001",
|
||||
title_ids=["title-1", "title-2"],
|
||||
voice_ids=["voice-a"],
|
||||
)
|
||||
gen_task_repo.create(preview)
|
||||
|
||||
with patch("app.api.routes.generation_tasks.safe_enqueue_generation_task", return_value=True):
|
||||
resp = client.post(
|
||||
f"/api/v1/tasks/{preview.id}/confirm",
|
||||
json={"output_width": 1920, "output_height": 1080},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
item = resp.json()["items"][0]
|
||||
assert item["is_preview"] is False
|
||||
assert item["source_task_id"] == preview.id
|
||||
assert item["output_width"] == 1920
|
||||
assert item["output_height"] == 1080
|
||||
# 默认封面和标题
|
||||
assert item["cover_url"] == ""
|
||||
assert item["custom_title"] == ""
|
||||
# 复制的配置
|
||||
assert item["voice_library_id"] == "voice-001"
|
||||
assert item["template_id"] == "tmpl-001"
|
||||
assert item["title_ids"] == ["title-1", "title-2"]
|
||||
assert item["voice_ids"] == ["voice-a"]
|
||||
|
||||
def test_confirm_cover_and_title(
|
||||
self,
|
||||
client: TestClient,
|
||||
gen_task_repo: StubGenerationTaskRepository,
|
||||
) -> None:
|
||||
"""cover_url 和 custom_title 正确传递"""
|
||||
preview = _make_preview_task()
|
||||
gen_task_repo.create(preview)
|
||||
|
||||
with patch("app.api.routes.generation_tasks.safe_enqueue_generation_task", return_value=True):
|
||||
resp = client.post(
|
||||
f"/api/v1/tasks/{preview.id}/confirm",
|
||||
json={
|
||||
"output_width": 1080,
|
||||
"output_height": 1920,
|
||||
"cover_url": "https://cdn.example.com/my-cover.png",
|
||||
"custom_title": "测试视频标题",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
item = resp.json()["items"][0]
|
||||
assert item["cover_url"] == "https://cdn.example.com/my-cover.png"
|
||||
assert item["custom_title"] == "测试视频标题"
|
||||
|
||||
def test_confirm_default_resolution(
|
||||
self,
|
||||
client: TestClient,
|
||||
gen_task_repo: StubGenerationTaskRepository,
|
||||
) -> None:
|
||||
"""不传分辨率时使用 ConfirmGenerationRequest 默认值 1080x1920"""
|
||||
preview = _make_preview_task()
|
||||
gen_task_repo.create(preview)
|
||||
|
||||
with patch("app.api.routes.generation_tasks.safe_enqueue_generation_task", return_value=True):
|
||||
resp = client.post(
|
||||
f"/api/v1/tasks/{preview.id}/confirm",
|
||||
json={},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
item = resp.json()["items"][0]
|
||||
assert item["output_width"] == 1080
|
||||
assert item["output_height"] == 1920
|
||||
|
||||
def test_confirm_creates_new_task_in_repo(
|
||||
self,
|
||||
client: TestClient,
|
||||
gen_task_repo: StubGenerationTaskRepository,
|
||||
) -> None:
|
||||
"""确认生成的任务确实被存入 repository"""
|
||||
preview = _make_preview_task()
|
||||
gen_task_repo.create(preview)
|
||||
|
||||
initial_count = len(gen_task_repo._store)
|
||||
|
||||
with patch("app.api.routes.generation_tasks.safe_enqueue_generation_task", return_value=True):
|
||||
resp = client.post(
|
||||
f"/api/v1/tasks/{preview.id}/confirm",
|
||||
json={},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
new_task_id = resp.json()["items"][0]["id"]
|
||||
assert new_task_id != preview.id
|
||||
assert len(gen_task_repo._store) == initial_count + 1
|
||||
|
||||
new_task = gen_task_repo.get(new_task_id)
|
||||
assert new_task is not None
|
||||
assert new_task.is_preview is False
|
||||
assert new_task.source_task_id == preview.id
|
||||
Regular → Executable
+3
-3
@@ -985,7 +985,7 @@ class TestGetAsrService:
|
||||
def test_returns_service_when_available(self):
|
||||
"""ASR 服务可用时返回实例。"""
|
||||
mock_service = MagicMock()
|
||||
with patch("services.asr_service_factory.get_asr_service") as mock_get:
|
||||
with patch("apps.worker.services.asr_service_factory.get_asr_service") as mock_get:
|
||||
mock_get.return_value = mock_service
|
||||
result = RenderAdapter._get_asr_service()
|
||||
|
||||
@@ -993,7 +993,7 @@ class TestGetAsrService:
|
||||
|
||||
def test_returns_none_when_import_fails(self):
|
||||
"""ASR 服务导入失败时返回 None(不阻断主流程)。"""
|
||||
with patch("services.asr_service_factory.get_asr_service") as mock_get:
|
||||
with patch("apps.worker.services.asr_service_factory.get_asr_service") as mock_get:
|
||||
mock_get.side_effect = ImportError("asr module not found")
|
||||
result = RenderAdapter._get_asr_service()
|
||||
|
||||
@@ -1001,7 +1001,7 @@ class TestGetAsrService:
|
||||
|
||||
def test_returns_none_when_init_fails(self):
|
||||
"""ASR 服务初始化失败时返回 None(不阻断主流程)。"""
|
||||
with patch("services.asr_service_factory.get_asr_service") as mock_get:
|
||||
with patch("apps.worker.services.asr_service_factory.get_asr_service") as mock_get:
|
||||
mock_get.side_effect = RuntimeError("ASR init failed")
|
||||
result = RenderAdapter._get_asr_service()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user