Compare commits

...

9 Commits

Author SHA1 Message Date
CI Bot c3e5ac2f7f style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 40s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 59s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 59s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m15s
AI Code Review / AI Code Review (pull_request) Successful in 1m38s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m39s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m48s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m31s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m34s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m5s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m6s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m22s
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 47s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m41s
2026-08-09 07:04:18 +00:00
xiaoxia 532947e05c fix: 视频 filter_complex 排除纯音频 clips 修复 FFmpeg exit=234
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 44s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 44s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 59s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m25s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m44s
AI Code Review / AI Code Review (pull_request) Successful in 1m51s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m53s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m54s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m58s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
Bug: _build_filter_complex 遍历所有图层的 clips 生成 [N:v] 视频滤镜链,
但 voice.mp3(用户上传的配音素材)是纯音频文件,没有视频流。FFmpeg 解析
filter_complex 时报错 'Stream specifier :v matches no streams',exit=234。

修复:
- Step 1 (预处理): 收集 clips 时跳过 clip_type='audio' 的 clips
- Step 2 (同层串联): 跳过纯音频图层,只处理视频 clips
- 音频 clips 由 mix_audio() 独立处理,不应出现在视频 filter_complex

新增 6 个单元测试覆盖各种场景(voice_library、TTS、纯音频报错等)。
2026-08-09 15:01:12 +08:00
xiaoxia a75fa1cd93 fix: Worker 配音下载 fallback 到 voice_ids 修复预览无声 (#1320)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 40s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 59s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m41s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m16s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m16s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 5m24s
CI/CD Pipeline / Integration Tests (push) Successful in 1m21s
CI/CD Pipeline / Unit Tests (push) Successful in 8m50s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m12s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m25s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 38s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m13s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m29s
2026-08-09 14:07:13 +08:00
xiaoxia d9c5281f46 feat: 预览生成 API 传入 voice_library_id(配音素材 ID) (#1314)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 50s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 59s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m32s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m35s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 3m52s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m33s
CI/CD Pipeline / Integration Tests (push) Successful in 1m45s
CI/CD Pipeline / Unit Tests (push) Successful in 8m39s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m23s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m8s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 38s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m12s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m27s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-09 12:31:00 +08:00
xiaoxia 6b5ec0a2f1 Merge pull request 'fix: 预览生成接口透传 voice_library_id 修复预览无声问题' (#1313) from fix/preview-voice-library-id into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m10s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m6s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m15s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m14s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m38s
CI/CD Pipeline / Integration Tests (push) Successful in 1m28s
CI/CD Pipeline / Unit Tests (push) Successful in 8m42s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m34s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m18s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 40s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m9s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m27s
2026-08-09 11:32:53 +08:00
CI Bot de30703d41 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 43s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 56s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 55s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m33s
AI Code Review / AI Code Review (pull_request) Successful in 1m34s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m43s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m23s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m53s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m32s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m24s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 3m47s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m54s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m2s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m28s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 7m37s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 48s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 49s
2026-08-09 03:23:53 +00:00
xiaoxia 68e41f6173 fix: 预览生成接口透传 voice_library_id 修复预览无声问题
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 40s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 43s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 55s
AI Code Review / AI Code Review (pull_request) Successful in 1m12s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m23s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m52s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
根因:预览生成接口(POST /preview)硬编码 voice_library_id="",
导致用户选择的上传音频从未传递给 Worker,渲染时没有音频可混入。

修复:
- CreatePreviewGenerationTaskRequest 增加 voice_library_id 字段
- 预览端点将 request.voice_library_id 透传给 CreateGenerationTaskCommand
- 确认生成接口已自动继承源预览任务的 voice_library_id
- 新增 4 个单元测试验证 schema 字段

下游链路(Worker 下载音频 → 渲染引擎混音)无需修改,
只需前端在创建预览任务时传入 voice_library_id 即可。
2026-08-09 11:21:08 +08:00
xiaoxia 2c4fabb585 feat: 前端对接确认生成 API (POST /tasks/{task_id}/confirm) (#1310)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m40s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m50s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m56s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m20s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 3m22s
CI/CD Pipeline / Integration Tests (push) Successful in 1m46s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m28s
CI/CD Pipeline / Unit Tests (push) Successful in 8m25s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m26s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 47s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m14s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m31s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-09 09:11:01 +08:00
xiaoxia c79022bc2c Merge pull request 'fix: 确认生成 API 适配 develop 分支(同步 main PR #1308)' (#1312) from fix/confirm-generation-api-develop into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m7s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m12s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m40s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m50s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 4m46s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m56s
CI/CD Pipeline / Integration Tests (push) Successful in 1m30s
CI/CD Pipeline / Unit Tests (push) Successful in 8m10s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 11m51s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m23s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 36s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m11s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m28s
2026-08-08 22:13:01 +08:00
15 changed files with 619 additions and 8 deletions
@@ -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),
+3
View File
@@ -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,为空使用模板默认")
+14
View File
@@ -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
}
+4
View File
@@ -3,6 +3,10 @@ export type {
CreatePreviewRequest,
CreatePreviewResponse,
PreviewTaskResponse,
ConfirmGenerationRequest,
ConfirmGenerationResponse,
ConfirmGenerationTaskItem,
} from "./types"
export { createPreview, getPreviewStatus } from "./preview"
export { confirmGeneration } from "./confirm"
+46
View File
@@ -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,
})
-1
View File
@@ -2383,7 +2383,6 @@
font-size: 14px;
}
/* ── 加载中状态 ── */
.xx-preview-loading {
text-align: center;
+2
View File
@@ -19,6 +19,8 @@ export interface UseGenerateVideoProps {
autoSubtitles: boolean
bgm: boolean
generateCount: number
/** 预览任务的 task_id(用于新确认生成 API */
previewTaskId?: string
}
/** 生成阶段 */
+20
View File
@@ -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,
@@ -1494,9 +1494,17 @@ class UnifiedRenderService:
raise ValueError("没有可渲染的图层")
# 收集所有 clips(按图层顺序,同层按 order)
# 排除纯音频 clips — 它们由 mix_audio() 独立处理,不应出现在视频 filter_complex 中
# 例如:voice.mp3 没有视频流,如果加入 all_clips 会生成 [N:v] 引用导致 FFmpeg 报错
all_clips: list[ResolvedClip] = []
for layer in layers:
all_clips.extend(layer.clips)
for clip in layer.clips:
if clip.clip_type == "audio":
continue
all_clips.append(clip)
if not all_clips:
raise ValueError("没有可渲染的视频片段(所有片段均为纯音频)")
# 构建输入参数
input_args: list[str] = []
@@ -1580,10 +1588,14 @@ class UnifiedRenderService:
filter_parts.append(filter_str)
preprocessed_labels.append(label)
# Step 2: 同层 clips 用 xfade 串联
# Step 2: 同层 clips 用 xfade 串联(跳过纯音频层,由 mix_audio() 独立处理)
layer_output_labels: dict[str, str] = {}
for layer in layers:
layer_clip_indices = [all_clips.index(c) for c in layer.clips]
# 音频层不参与视频 filter_complex,跳过
video_clips_in_layer = [c for c in layer.clips if c.clip_type != "audio"]
if not video_clips_in_layer:
continue
layer_clip_indices = [all_clips.index(c) for c in video_clips_in_layer]
layer_labels = [preprocessed_labels[i] for i in layer_clip_indices]
# 使用调速后的实际时长,与 Step 1 的调速处理保持一致
layer_durations = [UnifiedRenderService._clip_adjusted_duration(all_clips[i]) for i in layer_clip_indices]
+21 -3
View File
@@ -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_idfallback 到 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,262 @@
"""测试 _build_filter_complex 正确排除纯音频 clips.
Bug: voice.mp3(纯音频文件)被错误地加入视频 filter_complex
导致 FFmpeg 尝试访问 [N:v] 视频流时报错 "Stream specifier ':v' matches no streams".
修复:_build_filter_complex 在收集 clips 时跳过 clip_type="audio" 的 clips
因为音频 clips 由 mix_audio() 独立处理。
"""
from pathlib import Path
from unittest.mock import MagicMock
import pytest
def _make_render_service(tmp_path):
"""创建一个最小化的 UnifiedRenderService 实例."""
from video_processing.unified_render_service import UnifiedRenderService
plan = MagicMock()
plan.id = "test_plan"
plan.config = {}
plan.strategy_id = "test_strategy"
clips = []
asset_path_map = {}
service = UnifiedRenderService(
plan=plan,
clips=clips,
asset_path_map=asset_path_map,
work_dir=tmp_path,
output_width=480,
output_height=854,
output_fps=30,
)
return service
def _make_video_clip(clip_id: str, local_path: Path, duration: float = 5.0):
"""创建一个视频 clip."""
from video_processing.unified_render_service import ResolvedClip
return ResolvedClip(
clip_id=clip_id,
asset_id=f"asset_{clip_id}",
local_path=local_path,
clip_type="video",
order=0,
start_time=0.0,
duration=duration,
config={},
actual_duration=duration,
)
def _make_audio_clip(clip_id: str, local_path: Path, duration: float = 5.0):
"""创建一个纯音频 clip."""
from video_processing.unified_render_service import ResolvedClip
return ResolvedClip(
clip_id=clip_id,
asset_id=f"asset_{clip_id}",
local_path=local_path,
clip_type="audio",
order=0,
start_time=0.0,
duration=duration,
config={"volume": 1.0},
actual_duration=duration,
)
class TestBuildFilterComplexExcludesAudioClips:
"""_build_filter_complex 应该排除 clip_type='audio' 的 clips."""
def test_audio_clip_not_in_filter_complex(self, tmp_path):
"""纯音频 clip 不应出现在 filter_complex 中."""
render_service = _make_render_service(tmp_path)
from video_processing.unified_render_service import RenderLayer
# 准备视频和音频文件
video_path = tmp_path / "video.mp4"
video_path.write_bytes(b"\x00")
audio_path = tmp_path / "voice.mp3"
audio_path.write_bytes(b"\x00")
video_clip = _make_video_clip("clip_0", video_path, duration=5.0)
audio_clip = _make_audio_clip("voice_library_main", audio_path, duration=5.0)
video_layer = RenderLayer(role="main", z_index=1)
video_layer.clips.append(video_clip)
audio_layer = RenderLayer(role="audio", z_index=2)
audio_layer.clips.append(audio_clip)
layers = [video_layer, audio_layer]
# 执行
filter_complex, input_args = render_service._build_filter_complex(layers)
# 验证:filter_complex 只包含视频 clip 的处理([0:v]),不包含音频 clip[1:v]
assert "[0:v]" in filter_complex
assert "[1:v]" not in filter_complex # voice.mp3 不应该有视频滤镜
# 验证:input_args 只包含视频文件,不包含音频文件
assert str(video_path) in " ".join(input_args)
assert str(audio_path) not in " ".join(input_args)
def test_multiple_video_clips_with_audio(self, tmp_path):
"""多个视频 clips + 音频 clip 时,filter_complex 只处理视频."""
render_service = _make_render_service(tmp_path)
from video_processing.unified_render_service import RenderLayer
# 准备文件
video_paths = [tmp_path / f"video_{i}.mp4" for i in range(3)]
for p in video_paths:
p.write_bytes(b"\x00")
audio_path = tmp_path / "voice.mp3"
audio_path.write_bytes(b"\x00")
video_layer = RenderLayer(role="main", z_index=1)
for i, vp in enumerate(video_paths):
clip = _make_video_clip(f"clip_{i}", vp, duration=3.0)
clip.order = i
video_layer.clips.append(clip)
audio_layer = RenderLayer(role="audio", z_index=2)
audio_clip = _make_audio_clip("voice_main", audio_path, duration=9.0)
audio_layer.clips.append(audio_clip)
layers = [video_layer, audio_layer]
# 执行
filter_complex, input_args = render_service._build_filter_complex(layers)
# 验证:只有 3 个视频输入
assert "[0:v]" in filter_complex
assert "[1:v]" in filter_complex
assert "[2:v]" in filter_complex
assert "[3:v]" not in filter_complex # 音频不应该出现
# 验证:input_args 只有 3 个 -i
input_files = [arg for arg in input_args if not arg.startswith("-")]
assert len(input_files) == 3
assert str(audio_path) not in input_files
def test_only_audio_clips_raises_error(self, tmp_path):
"""只有音频 clips 时应该抛出 ValueError."""
render_service = _make_render_service(tmp_path)
from video_processing.unified_render_service import RenderLayer
audio_path = tmp_path / "voice.mp3"
audio_path.write_bytes(b"\x00")
audio_layer = RenderLayer(role="audio", z_index=2)
audio_clip = _make_audio_clip("voice_main", audio_path, duration=5.0)
audio_layer.clips.append(audio_clip)
layers = [audio_layer]
with pytest.raises(ValueError, match="没有可渲染的视频片段"):
render_service._build_filter_complex(layers)
def test_tts_audio_clip_excluded(self, tmp_path):
"""TTS 配音 clipclip_type='audio', config.tts=True)也不应出现在 filter_complex."""
render_service = _make_render_service(tmp_path)
from video_processing.unified_render_service import RenderLayer, ResolvedClip
video_path = tmp_path / "video.mp4"
video_path.write_bytes(b"\x00")
tts_audio_path = tmp_path / "tts_segment.wav"
tts_audio_path.write_bytes(b"\x00")
video_clip = _make_video_clip("clip_0", video_path, duration=10.0)
tts_clip = ResolvedClip(
clip_id="tts_0.000",
asset_id="tts_voiceover",
local_path=tts_audio_path,
clip_type="audio",
order=0,
start_time=0.0,
duration=3.0,
config={"volume": 1.0, "tts": True},
actual_duration=3.0,
)
video_layer = RenderLayer(role="main", z_index=1)
video_layer.clips.append(video_clip)
audio_layer = RenderLayer(role="audio", z_index=2)
audio_layer.clips.append(tts_clip)
layers = [video_layer, audio_layer]
filter_complex, input_args = render_service._build_filter_complex(layers)
# TTS 音频不应出现在 filter_complex
assert "[1:v]" not in filter_complex
input_files = [arg for arg in input_args if not arg.startswith("-")]
assert str(tts_audio_path) not in input_files
def test_video_clip_with_audio_config_not_excluded(self, tmp_path):
"""clip_type='video' 的 clip 不应被排除(即使它有音频流)."""
render_service = _make_render_service(tmp_path)
from video_processing.unified_render_service import RenderLayer
video_path = tmp_path / "video.mp4"
video_path.write_bytes(b"\x00")
video_clip = _make_video_clip("clip_0", video_path, duration=5.0)
video_layer = RenderLayer(role="main", z_index=1)
video_layer.clips.append(video_clip)
layers = [video_layer]
filter_complex, input_args = render_service._build_filter_complex(layers)
# 视频 clip 应该被处理
assert "[0:v]" in filter_complex
input_files = [arg for arg in input_args if not arg.startswith("-")]
assert str(video_path) in input_files
def test_voice_library_clip_with_voice_library_flag(self, tmp_path):
"""voice_library=True 的 clip(来自 _maybe_add_voice_library_layer)应被排除."""
render_service = _make_render_service(tmp_path)
from video_processing.unified_render_service import RenderLayer, ResolvedClip
video_path = tmp_path / "video.mp4"
video_path.write_bytes(b"\x00")
voice_path = tmp_path / "voice.mp3"
voice_path.write_bytes(b"\x00")
video_clip = _make_video_clip("clip_0", video_path, duration=14.0)
# 模拟 _maybe_add_voice_library_layer 创建的 clip
voice_clip = ResolvedClip(
clip_id="voice_library_main",
asset_id="voice_library",
local_path=voice_path,
clip_type="audio",
order=0,
start_time=0.0,
duration=14.0,
config={"volume": 1.0, "voice_library": True},
actual_duration=14.0,
)
video_layer = RenderLayer(role="main", z_index=1)
video_layer.clips.append(video_clip)
audio_layer = RenderLayer(role="audio", z_index=2)
audio_layer.clips.append(voice_clip)
layers = [video_layer, audio_layer]
filter_complex, input_args = render_service._build_filter_complex(layers)
# 只有视频 clip 被处理
assert "[0:v]" in filter_complex
assert "[1:v]" not in filter_complex
# voice.mp3 不在输入中
assert str(voice_path) not in " ".join(input_args)
@@ -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