Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16af9ce316 | |||
| 4d27f1ed2c | |||
| d9c5281f46 | |||
| 6b5ec0a2f1 | |||
| de30703d41 | |||
| 68e41f6173 | |||
| 2c4fabb585 | |||
| c79022bc2c |
@@ -312,7 +312,7 @@ def create_preview_generation_task(
|
||||
project_id="",
|
||||
asset_library_id="",
|
||||
strategy_id=strategy_id,
|
||||
voice_library_id="",
|
||||
voice_library_id=request.voice_library_id,
|
||||
template_id=request.template_id,
|
||||
asset_ids=list(request.asset_ids),
|
||||
title_ids=list(request.title_ids),
|
||||
|
||||
@@ -161,6 +161,9 @@ class CreatePreviewGenerationTaskRequest(BaseModel):
|
||||
asset_ids: list[str] = Field(default_factory=list)
|
||||
title_ids: list[str] = Field(default_factory=list)
|
||||
voice_ids: list[str] = Field(default_factory=list)
|
||||
voice_library_id: str = Field(
|
||||
default="", description="配音素材库ID(用户上传的音频或AI配音),对应配音选择页面选择的配音素材"
|
||||
)
|
||||
video_title: str = Field(default="", description="生成视频的标题/名称,为空则使用默认命名")
|
||||
duration: float = Field(default=0.0, ge=0, description="期望视频时长(秒),0 表示由模板决定")
|
||||
video_ratio: str = Field(default="", description="视频比例,如 16:9 / 9:16,为空使用模板默认")
|
||||
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
import apiClient from "../client"
|
||||
import type { ConfirmGenerationRequest, ConfirmGenerationResponse } from "./types"
|
||||
|
||||
/** 确认生成 — 基于预览任务创建正式生成任务 */
|
||||
export const confirmGeneration = async (
|
||||
taskId: string,
|
||||
params: ConfirmGenerationRequest,
|
||||
): Promise<ConfirmGenerationResponse> => {
|
||||
const response = await apiClient.post<ConfirmGenerationResponse>(
|
||||
`/tasks/${taskId}/confirm`,
|
||||
params,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
Regular → Executable
+4
@@ -3,6 +3,10 @@ export type {
|
||||
CreatePreviewRequest,
|
||||
CreatePreviewResponse,
|
||||
PreviewTaskResponse,
|
||||
ConfirmGenerationRequest,
|
||||
ConfirmGenerationResponse,
|
||||
ConfirmGenerationTaskItem,
|
||||
} from "./types"
|
||||
|
||||
export { createPreview, getPreviewStatus } from "./preview"
|
||||
export { confirmGeneration } from "./confirm"
|
||||
|
||||
Regular → Executable
+46
@@ -7,6 +7,8 @@ export interface CreatePreviewRequest {
|
||||
asset_ids: string[]
|
||||
title_ids?: string[]
|
||||
voice_ids?: string[]
|
||||
/** 配音素材库ID(用户上传的音频或AI配音),对应配音选择页面选择的配音素材 */
|
||||
voice_library_id?: string
|
||||
video_title?: string
|
||||
duration?: number
|
||||
video_ratio?: string
|
||||
@@ -45,3 +47,47 @@ export interface PreviewTaskResponse {
|
||||
finished_at?: string
|
||||
generate_duration?: number
|
||||
}
|
||||
|
||||
/** 确认生成请求体 — 基于预览任务创建正式生成任务 */
|
||||
export interface ConfirmGenerationRequest {
|
||||
/** 输出视频宽度,默认 1080 */
|
||||
output_width?: number
|
||||
/** 输出视频高度,默认 1920 */
|
||||
output_height?: number
|
||||
/** 自定义封面图片 URL */
|
||||
cover_url?: string
|
||||
/** 自定义视频标题 */
|
||||
custom_title?: string
|
||||
}
|
||||
|
||||
/** 确认生成响应 */
|
||||
export interface ConfirmGenerationResponse {
|
||||
items: ConfirmGenerationTaskItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 确认生成返回的任务项 */
|
||||
export interface ConfirmGenerationTaskItem {
|
||||
id: string
|
||||
project_id: string
|
||||
asset_library_id: string
|
||||
strategy_id: string
|
||||
voice_library_id: string
|
||||
template_id: string
|
||||
asset_ids: string[]
|
||||
title_ids: string[]
|
||||
voice_ids: string[]
|
||||
source_edit_plan_id: string
|
||||
asset_select_mode: string
|
||||
batch_id: string
|
||||
is_preview: boolean
|
||||
source_task_id: string
|
||||
output_width: number
|
||||
output_height: number
|
||||
cover_url: string
|
||||
custom_title: string
|
||||
status: string
|
||||
progress: number
|
||||
result_count: number
|
||||
error_message: string
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ const GeneratePage: React.FC = () => {
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds: previewVoiceIds,
|
||||
voiceLibraryId: selectedVoice || undefined,
|
||||
previewCount,
|
||||
})
|
||||
|
||||
|
||||
@@ -2383,7 +2383,6 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
/* ── 加载中状态 ── */
|
||||
.xx-preview-loading {
|
||||
text-align: center;
|
||||
|
||||
Regular → Executable
+2
@@ -19,6 +19,8 @@ export interface UseGenerateVideoProps {
|
||||
autoSubtitles: boolean
|
||||
bgm: boolean
|
||||
generateCount: number
|
||||
/** 预览任务的 task_id(用于新确认生成 API) */
|
||||
previewTaskId?: string
|
||||
}
|
||||
|
||||
/** 生成阶段 */
|
||||
|
||||
Regular → Executable
+20
@@ -6,6 +6,7 @@ import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import { generateEditPlan, updateEditPlan, getEditPlan } from "@/api/template-editor"
|
||||
import { confirmGeneration } from "@/api/generation"
|
||||
import type { UseGenerateVideoProps } from "./generate-video/types"
|
||||
import { getGenerationPhase } from "./generate-video/phase"
|
||||
import { useGenerationPolling } from "./generate-video/useGenerationPolling"
|
||||
@@ -55,6 +56,25 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
clearTimer()
|
||||
|
||||
try {
|
||||
// 新流程:使用确认生成 API(基于预览任务)
|
||||
if (props.previewTaskId) {
|
||||
// 解析分辨率
|
||||
const [widthStr, heightStr] = (props.videoRatio || "1080x1920").split("x")
|
||||
const outputWidth = parseInt(widthStr, 10) || 1080
|
||||
const outputHeight = parseInt(heightStr, 10) || 1920
|
||||
|
||||
await confirmGeneration(props.previewTaskId, {
|
||||
output_width: outputWidth,
|
||||
output_height: outputHeight,
|
||||
cover_url: props.coverSettings.upload_url || "",
|
||||
custom_title: props.titleSettings.title || "",
|
||||
})
|
||||
|
||||
startPolling()
|
||||
return
|
||||
}
|
||||
|
||||
// 旧流程:使用 EditPlan API(向后兼容)
|
||||
const payload = buildEditPlanPayload(props)
|
||||
|
||||
// 获取或创建草稿
|
||||
|
||||
@@ -35,6 +35,8 @@ interface UseStep4PreviewProps {
|
||||
videoRatio: string
|
||||
/** 配音 voice_ids(传给后端,让预览包含配音音频) */
|
||||
voiceIds?: string[]
|
||||
/** 配音素材库ID(用户选择的上传音频或AI配音素材) */
|
||||
voiceLibraryId?: string
|
||||
/** 要生成的预览数量 */
|
||||
previewCount?: number
|
||||
}
|
||||
@@ -84,6 +86,7 @@ export function useStep4Preview({
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds,
|
||||
voiceLibraryId,
|
||||
previewCount = 1,
|
||||
}: UseStep4PreviewProps) {
|
||||
const templateName = useMemo(
|
||||
@@ -332,6 +335,7 @@ export function useStep4Preview({
|
||||
duration: duration || undefined,
|
||||
video_ratio: videoRatio,
|
||||
voice_ids: voiceIds && voiceIds.length > 0 ? voiceIds : undefined,
|
||||
voice_library_id: voiceLibraryId || undefined,
|
||||
})
|
||||
|
||||
if (startTimeRef.current === 0) return
|
||||
@@ -355,6 +359,7 @@ export function useStep4Preview({
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds,
|
||||
voiceLibraryId,
|
||||
previewCount,
|
||||
clearPollTimer,
|
||||
pollPreviewStatus,
|
||||
|
||||
@@ -1058,6 +1058,7 @@ def _download_all_assets(
|
||||
task_asset_ids: list[str],
|
||||
voice_library_id: str,
|
||||
task_id: str,
|
||||
voice_ids: list[str] | None = None,
|
||||
) -> tuple[list[Path], str | None]:
|
||||
"""下载视频素材和配音素材。
|
||||
|
||||
@@ -1066,6 +1067,9 @@ def _download_all_assets(
|
||||
|
||||
Note: gen_task 不传入下载函数(session 已关闭),
|
||||
主函数在下载前后已有汇总日志。
|
||||
|
||||
配音下载逻辑:优先使用 voice_library_id(配音素材库资产);
|
||||
若为空则 fallback 到 voice_ids[0](前端选择的音频 asset_id)。
|
||||
"""
|
||||
logger.info("[task_id=%s] [下载素材] 开始下载视频素材", task_id)
|
||||
download_start = time.monotonic()
|
||||
@@ -1085,11 +1089,24 @@ def _download_all_assets(
|
||||
)
|
||||
|
||||
audio_path: str | None = None
|
||||
if voice_library_id:
|
||||
# 配音下载:优先 voice_library_id,fallback 到 voice_ids[0]
|
||||
effective_voice_id = voice_library_id
|
||||
if not effective_voice_id and voice_ids:
|
||||
effective_voice_id = voice_ids[0]
|
||||
logger.info(
|
||||
"[task_id=%s] [下载配音] voice_library_id 为空,fallback 到 voice_ids[0]=%s",
|
||||
task_id,
|
||||
effective_voice_id,
|
||||
)
|
||||
if effective_voice_id:
|
||||
local_audio = temp_path / "voice.mp3"
|
||||
if _download_voice_asset(voice_library_id, local_audio):
|
||||
if _download_voice_asset(effective_voice_id, local_audio):
|
||||
audio_path = str(local_audio)
|
||||
logger.info("[task_id=%s] [下载配音] 配音下载成功", task_id)
|
||||
logger.info(
|
||||
"[task_id=%s] [下载配音] 配音下载成功 (source=%s)",
|
||||
task_id,
|
||||
"voice_library_id" if voice_library_id else "voice_ids",
|
||||
)
|
||||
|
||||
return downloaded_videos, audio_path
|
||||
|
||||
@@ -1422,6 +1439,7 @@ def generate_video(self, task_id: str) -> dict:
|
||||
task_asset_ids=task_asset_ids,
|
||||
voice_library_id=voice_library_id,
|
||||
task_id=task_id,
|
||||
voice_ids=task_info.get("voice_ids", []),
|
||||
)
|
||||
|
||||
if gen_task:
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Tests for voice_ids fallback in _download_all_assets.
|
||||
|
||||
When voice_library_id is empty but voice_ids is non-empty, the Worker
|
||||
should fallback to voice_ids[0] as the audio asset_id.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestDownloadAllAssetsVoiceIdsFallback:
|
||||
"""_download_all_assets 配音下载 fallback 逻辑测试。"""
|
||||
|
||||
@patch("worker_app.tasks.generation._download_voice_asset")
|
||||
@patch("worker_app.tasks.generation._download_library_assets")
|
||||
def test_voice_library_id_takes_priority(self, mock_download_videos, mock_download_voice, tmp_path):
|
||||
"""voice_library_id 存在时优先使用,不 fallback 到 voice_ids。"""
|
||||
from worker_app.tasks.generation import _download_all_assets
|
||||
|
||||
mock_download_videos.return_value = [tmp_path / "v1.mp4"]
|
||||
mock_download_voice.return_value = True
|
||||
|
||||
videos, audio = _download_all_assets(
|
||||
temp_path=tmp_path,
|
||||
asset_library_id="lib-1",
|
||||
project_id="proj-1",
|
||||
task_asset_ids=["a1"],
|
||||
voice_library_id="voice-lib-123",
|
||||
task_id="task-1",
|
||||
voice_ids=["voice-ids-456"],
|
||||
)
|
||||
|
||||
assert audio is not None
|
||||
mock_download_voice.assert_called_once()
|
||||
call_args = mock_download_voice.call_args
|
||||
assert call_args[0][0] == "voice-lib-123" # first positional arg
|
||||
|
||||
@patch("worker_app.tasks.generation._download_voice_asset")
|
||||
@patch("worker_app.tasks.generation._download_library_assets")
|
||||
def test_fallback_to_voice_ids_when_voice_library_id_empty(
|
||||
self, mock_download_videos, mock_download_voice, tmp_path
|
||||
):
|
||||
"""voice_library_id 为空时 fallback 到 voice_ids[0]。"""
|
||||
from worker_app.tasks.generation import _download_all_assets
|
||||
|
||||
mock_download_videos.return_value = [tmp_path / "v1.mp4"]
|
||||
mock_download_voice.return_value = True
|
||||
|
||||
videos, audio = _download_all_assets(
|
||||
temp_path=tmp_path,
|
||||
asset_library_id="lib-1",
|
||||
project_id="proj-1",
|
||||
task_asset_ids=["a1"],
|
||||
voice_library_id="", # 空字符串
|
||||
task_id="task-2",
|
||||
voice_ids=["voice-asset-789"],
|
||||
)
|
||||
|
||||
assert audio is not None
|
||||
mock_download_voice.assert_called_once()
|
||||
call_args = mock_download_voice.call_args
|
||||
assert call_args[0][0] == "voice-asset-789"
|
||||
|
||||
@patch("worker_app.tasks.generation._download_voice_asset")
|
||||
@patch("worker_app.tasks.generation._download_library_assets")
|
||||
def test_no_audio_when_both_empty(self, mock_download_videos, mock_download_voice, tmp_path):
|
||||
"""voice_library_id 和 voice_ids 都为空时,不下载音频。"""
|
||||
from worker_app.tasks.generation import _download_all_assets
|
||||
|
||||
mock_download_videos.return_value = [tmp_path / "v1.mp4"]
|
||||
|
||||
videos, audio = _download_all_assets(
|
||||
temp_path=tmp_path,
|
||||
asset_library_id="lib-1",
|
||||
project_id="proj-1",
|
||||
task_asset_ids=["a1"],
|
||||
voice_library_id="",
|
||||
task_id="task-3",
|
||||
voice_ids=[],
|
||||
)
|
||||
|
||||
assert audio is None
|
||||
mock_download_voice.assert_not_called()
|
||||
|
||||
@patch("worker_app.tasks.generation._download_voice_asset")
|
||||
@patch("worker_app.tasks.generation._download_library_assets")
|
||||
def test_no_audio_when_voice_ids_none(self, mock_download_videos, mock_download_voice, tmp_path):
|
||||
"""voice_ids 为 None 时,不触发 fallback。"""
|
||||
from worker_app.tasks.generation import _download_all_assets
|
||||
|
||||
mock_download_videos.return_value = [tmp_path / "v1.mp4"]
|
||||
|
||||
videos, audio = _download_all_assets(
|
||||
temp_path=tmp_path,
|
||||
asset_library_id="lib-1",
|
||||
project_id="proj-1",
|
||||
task_asset_ids=["a1"],
|
||||
voice_library_id="",
|
||||
task_id="task-4",
|
||||
voice_ids=None,
|
||||
)
|
||||
|
||||
assert audio is None
|
||||
mock_download_voice.assert_not_called()
|
||||
|
||||
@patch("worker_app.tasks.generation._download_voice_asset")
|
||||
@patch("worker_app.tasks.generation._download_library_assets")
|
||||
def test_voice_library_id_empty_string_fallback(self, mock_download_videos, mock_download_voice, tmp_path):
|
||||
"""voice_library_id 为空字符串且 voice_ids 有多个元素时,取第一个。"""
|
||||
from worker_app.tasks.generation import _download_all_assets
|
||||
|
||||
mock_download_videos.return_value = [tmp_path / "v1.mp4"]
|
||||
mock_download_voice.return_value = True
|
||||
|
||||
videos, audio = _download_all_assets(
|
||||
temp_path=tmp_path,
|
||||
asset_library_id="lib-1",
|
||||
project_id="proj-1",
|
||||
task_asset_ids=["a1"],
|
||||
voice_library_id="",
|
||||
task_id="task-5",
|
||||
voice_ids=["first-id", "second-id", "third-id"],
|
||||
)
|
||||
|
||||
assert audio is not None
|
||||
call_args = mock_download_voice.call_args
|
||||
assert call_args[0][0] == "first-id"
|
||||
|
||||
@patch("worker_app.tasks.generation._download_voice_asset")
|
||||
@patch("worker_app.tasks.generation._download_library_assets")
|
||||
def test_backward_compat_no_voice_ids_param(self, mock_download_videos, mock_download_voice, tmp_path):
|
||||
"""不传 voice_ids 参数时,行为与之前一致(向后兼容)。"""
|
||||
from worker_app.tasks.generation import _download_all_assets
|
||||
|
||||
mock_download_videos.return_value = [tmp_path / "v1.mp4"]
|
||||
mock_download_voice.return_value = True
|
||||
|
||||
# 不传 voice_ids
|
||||
videos, audio = _download_all_assets(
|
||||
temp_path=tmp_path,
|
||||
asset_library_id="lib-1",
|
||||
project_id="proj-1",
|
||||
task_asset_ids=["a1"],
|
||||
voice_library_id="voice-lib-999",
|
||||
task_id="task-6",
|
||||
)
|
||||
|
||||
assert audio is not None
|
||||
mock_download_voice.assert_called_once_with("voice-lib-999", tmp_path / "voice.mp3")
|
||||
@@ -0,0 +1,74 @@
|
||||
"""预览生成 voice_library_id 透传修复测试.
|
||||
|
||||
Bug: 预览生成接口硬编码 voice_library_id="",导致用户选择的上传音频
|
||||
在预览渲染时从未下载和混入,预览视频无声。
|
||||
|
||||
Fix: CreatePreviewGenerationTaskRequest 增加 voice_library_id 字段,
|
||||
预览端点透传 request.voice_library_id。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
|
||||
class TestPreviewVoiceLibraryIdSchema:
|
||||
"""CreatePreviewGenerationTaskRequest voice_library_id 字段测试."""
|
||||
|
||||
def test_default_empty_string(self):
|
||||
"""不传 voice_library_id 时默认为空字符串."""
|
||||
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
|
||||
|
||||
req = CreatePreviewGenerationTaskRequest(
|
||||
template_id="tmpl_1",
|
||||
asset_ids=["a1"],
|
||||
)
|
||||
assert req.voice_library_id == ""
|
||||
|
||||
def test_accepts_voice_library_id(self):
|
||||
"""传入 voice_library_id 正常接收."""
|
||||
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
|
||||
|
||||
req = CreatePreviewGenerationTaskRequest(
|
||||
template_id="tmpl_1",
|
||||
asset_ids=["a1"],
|
||||
voice_library_id="asset_abc123",
|
||||
)
|
||||
assert req.voice_library_id == "asset_abc123"
|
||||
|
||||
def test_accepts_empty_voice_library_id(self):
|
||||
"""显式传空字符串也正常."""
|
||||
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
|
||||
|
||||
req = CreatePreviewGenerationTaskRequest(
|
||||
template_id="tmpl_1",
|
||||
asset_ids=["a1"],
|
||||
voice_library_id="",
|
||||
)
|
||||
assert req.voice_library_id == ""
|
||||
|
||||
def test_all_fields_including_voice_library_id(self):
|
||||
"""包含 voice_library_id 的完整请求."""
|
||||
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
|
||||
|
||||
req = CreatePreviewGenerationTaskRequest(
|
||||
template_id="tmpl_123",
|
||||
asset_ids=["a1", "a2"],
|
||||
title_ids=["t1"],
|
||||
voice_ids=["v1"],
|
||||
voice_library_id="voice_asset_456",
|
||||
video_title="测试预览",
|
||||
duration=30.0,
|
||||
video_ratio="9:16",
|
||||
bgm_config={"enabled": True, "volume": 0.5},
|
||||
)
|
||||
assert req.voice_library_id == "voice_asset_456"
|
||||
assert req.asset_ids == ["a1", "a2"]
|
||||
assert req.bgm_config["enabled"] is True
|
||||
Reference in New Issue
Block a user