Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2beef01279 | |||
| 4c9b9fa50f | |||
| 248fd5408c | |||
| 052a660ddb | |||
| 3a6846bc66 | |||
| bd9623fd20 | |||
| 9414dc1318 | |||
| 042f4162b0 | |||
| 76f41529fe | |||
| d537d95376 |
@@ -1086,25 +1086,7 @@ jobs:
|
||||
- name: Run Playwright E2E on staging
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
# DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致)
|
||||
# 改用 docker create + docker cp 方式把代码拷进容器
|
||||
CONTAINER_NAME="staging-e2e-$$"
|
||||
# 强制清理可能残留的同名容器(上一次异常退出时未清理)
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
docker create --name "$CONTAINER_NAME" --ipc=host \
|
||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||
-e E2E_BROWSER_CHANNEL=chromium \
|
||||
-e PLAYWRIGHT_HEADLESS=1 \
|
||||
-w /workspace/apps/web \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
sh -lc "for i in 1 2 3; do npm ci --registry=https://registry.npmmirror.com && break; echo \"npm ci failed (attempt $i), retrying in 15s...\"; sleep 15; done && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts"
|
||||
docker cp apps "$CONTAINER_NAME:/workspace/"
|
||||
docker start -a "$CONTAINER_NAME"
|
||||
EXIT_CODE=$(docker wait "$CONTAINER_NAME")
|
||||
docker rm "$CONTAINER_NAME" 2>/dev/null || true
|
||||
exit $EXIT_CODE
|
||||
bash scripts/ci/run_staging_tests.sh e2e
|
||||
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
@@ -1151,23 +1133,7 @@ jobs:
|
||||
- name: Run API integration tests on staging
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
# DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致)
|
||||
# 改用 docker create + docker cp 方式把代码拷进容器
|
||||
CONTAINER_NAME="staging-api-tests-$$"
|
||||
# 强制清理可能残留的同名容器(上一次异常退出时未清理)
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
docker create --name "$CONTAINER_NAME" \
|
||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||
-w /workspace/apps/web \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
sh -lc 'for i in 1 2 3; do npm ci --registry=https://registry.npmmirror.com && break; echo \"npm ci failed (attempt $i), retrying in 15s...\"; sleep 15; done && npx playwright test --reporter=line e2e/test_auth.spec.ts e2e/test_asset.spec.ts e2e/test_project.spec.ts'
|
||||
docker cp apps "$CONTAINER_NAME:/workspace/"
|
||||
docker start -a "$CONTAINER_NAME"
|
||||
EXIT_CODE=$(docker wait "$CONTAINER_NAME")
|
||||
docker rm "$CONTAINER_NAME" 2>/dev/null || true
|
||||
exit $EXIT_CODE
|
||||
bash scripts/ci/run_staging_tests.sh api
|
||||
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
@@ -1494,9 +1460,8 @@ jobs:
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Run production browser E2E
|
||||
shell: sh
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm --ipc=host \
|
||||
-e E2E_BASE_URL=https://saas.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://api.xiaoxiajianji.com/api/v1 \
|
||||
@@ -1505,7 +1470,7 @@ jobs:
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
sh -lc 'for i in 1 2 3; do npm ci --registry=https://registry.npmmirror.com && break; echo \"npm ci failed (attempt $i), retrying in 15s...\"; sleep 15; done && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts'
|
||||
bash -c 'for i in 1 2 3; do npm ci --registry=https://registry.npmmirror.com && break; echo "npm ci attempt $i failed, retrying..."; sleep 15; done && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts'
|
||||
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
|
||||
@@ -270,10 +270,11 @@ def create_preview_generation_task(
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
logger.info(
|
||||
"[预览生成] 接收请求: user_id=%s, template_id=%s, asset_count=%d",
|
||||
"[预览生成] 接收请求: user_id=%s, template_id=%s, asset_count=%d, preview_count=%d",
|
||||
user_id,
|
||||
request.template_id,
|
||||
len(request.asset_ids),
|
||||
request.preview_count,
|
||||
)
|
||||
|
||||
# 预检查队列限流
|
||||
|
||||
@@ -98,6 +98,15 @@ def _auto_fallback_assign_assets(
|
||||
clips_without_asset = [c for c in all_clips if not c.asset_id]
|
||||
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
|
||||
|
||||
logger.info(
|
||||
"模板编辑器自动兜底3 诊断: plan=%s total_clips=%d "
|
||||
"clips_without_asset=%d config_asset_ids=%r",
|
||||
plan_id,
|
||||
len(all_clips),
|
||||
len(clips_without_asset),
|
||||
config_asset_ids[:5] if config_asset_ids else [],
|
||||
)
|
||||
|
||||
if clips_without_asset and config_asset_ids:
|
||||
logger.info(
|
||||
"模板编辑器自动兜底3: plan=%s 为 %d 个无素材片段分配 %d 个指定素材",
|
||||
@@ -105,11 +114,42 @@ def _auto_fallback_assign_assets(
|
||||
len(clips_without_asset),
|
||||
len(config_asset_ids),
|
||||
)
|
||||
assigned = 0
|
||||
for i, clip in enumerate(clips_without_asset):
|
||||
asset_idx = i % len(config_asset_ids)
|
||||
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
|
||||
logger.info("模板编辑器自动兜底3: plan=%s 素材分配完成", plan_id)
|
||||
clips_without_asset = []
|
||||
try:
|
||||
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
|
||||
assigned += 1
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"模板编辑器自动兜底3: plan=%s clip=%s 分配素材 %s 失败: %s",
|
||||
plan_id,
|
||||
clip.id,
|
||||
config_asset_ids[asset_idx],
|
||||
exc,
|
||||
)
|
||||
logger.info(
|
||||
"模板编辑器自动兜底3: plan=%s 素材分配完成 assigned=%d/%d",
|
||||
plan_id,
|
||||
assigned,
|
||||
len(clips_without_asset),
|
||||
)
|
||||
# 重新检查剩余无素材片段
|
||||
all_clips_after = svc.list_clips(plan_id)
|
||||
clips_without_asset = [c for c in all_clips_after if not c.asset_id]
|
||||
if clips_without_asset:
|
||||
logger.warning(
|
||||
"模板编辑器自动兜底3: plan=%s 仍有 %d 个片段无素材",
|
||||
plan_id,
|
||||
len(clips_without_asset),
|
||||
)
|
||||
elif not clips_without_asset:
|
||||
logger.info("模板编辑器自动兜底3: plan=%s 所有片段已有素材,跳过", plan_id)
|
||||
elif not config_asset_ids:
|
||||
logger.info(
|
||||
"模板编辑器自动兜底3: plan=%s config.asset_ids 为空,跳过分配",
|
||||
plan_id,
|
||||
)
|
||||
|
||||
return clips_without_asset
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ def generate_editor_draft(
|
||||
plan_svc, plan_id, plan_check, clips_without_asset, asset_library_repo, asset_repo
|
||||
)
|
||||
|
||||
# 检查是否可生成
|
||||
# 检查是否可生成(含最后防线自动修复 + 诊断日志)
|
||||
try:
|
||||
can_gen, reason = plan_svc.can_generate(plan_id)
|
||||
except ValueError as exc:
|
||||
|
||||
@@ -146,6 +146,12 @@ class CreatePreviewGenerationTaskRequest(BaseModel):
|
||||
default_factory=dict,
|
||||
description="自定义BGM配置,覆盖模板BGM设置。支持 enabled/source/asset_id/preset_id/audio_url/volume 等字段",
|
||||
)
|
||||
preview_count: int = Field(
|
||||
default=1,
|
||||
ge=1,
|
||||
le=10,
|
||||
description="预览视频生成数量,范围 1-10,默认 1",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_template_id(self) -> "CreatePreviewGenerationTaskRequest":
|
||||
|
||||
@@ -571,6 +571,10 @@ class EditPlanService:
|
||||
def can_generate(self, plan_id: str) -> tuple[bool, str]:
|
||||
"""检查是否可以触发渲染
|
||||
|
||||
包含最后一道防线的自动修复:
|
||||
- 如果 clips 存在但都没有 asset_id,且 config.asset_ids 非空,
|
||||
直接在内部执行素材分配,不再依赖前置 fallback 链路。
|
||||
|
||||
Returns:
|
||||
tuple: (can_generate, reason)
|
||||
"""
|
||||
@@ -586,8 +590,59 @@ class EditPlanService:
|
||||
return False, "请先添加片段后再生成视频"
|
||||
|
||||
# 检查是否至少有一个片段分配了素材
|
||||
if not any(c.asset_id for c in clips):
|
||||
return False, "没有可渲染的就绪片段,请确保已选择素材"
|
||||
has_asset = any(c.asset_id for c in clips)
|
||||
config_asset_ids_count = len((plan.config or {}).get("asset_ids", []))
|
||||
clips_with_asset_count = sum(1 for c in clips if c.asset_id)
|
||||
logger.info(
|
||||
"can_generate 诊断: plan=%s status=%s total_clips=%d " "clips_with_asset=%d config_asset_ids_count=%d",
|
||||
plan_id,
|
||||
plan.status,
|
||||
len(clips),
|
||||
clips_with_asset_count,
|
||||
config_asset_ids_count,
|
||||
)
|
||||
if not has_asset:
|
||||
# ── 最后防线:自动从 config.asset_ids 分配素材 ──
|
||||
config_asset_ids = (plan.config or {}).get("asset_ids", [])
|
||||
if config_asset_ids:
|
||||
logger.warning(
|
||||
"can_generate 最后防线触发: plan=%s clips=%d 均无素材," "从 config.asset_ids(%d个) 自动分配",
|
||||
plan_id,
|
||||
len(clips),
|
||||
len(config_asset_ids),
|
||||
)
|
||||
clips_without_asset = [c for c in clips if not c.asset_id]
|
||||
assigned_count = 0
|
||||
for i, clip in enumerate(clips_without_asset):
|
||||
asset_idx = i % len(config_asset_ids)
|
||||
try:
|
||||
self.assign_asset(clip.id, config_asset_ids[asset_idx])
|
||||
assigned_count += 1
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"can_generate 最后防线: plan=%s clip=%s 分配素材 %s 失败: %s",
|
||||
plan_id,
|
||||
clip.id,
|
||||
config_asset_ids[asset_idx],
|
||||
exc,
|
||||
)
|
||||
logger.info(
|
||||
"can_generate 最后防线: plan=%s 已为 %d/%d 个片段分配素材",
|
||||
plan_id,
|
||||
assigned_count,
|
||||
len(clips_without_asset),
|
||||
)
|
||||
# 重新加载 clips 验证分配结果
|
||||
clips = self._clip_repo.list_by_plan(plan_id)
|
||||
if not any(c.asset_id for c in clips):
|
||||
return False, "没有可渲染的就绪片段,自动修复后仍未分配素材"
|
||||
else:
|
||||
logger.warning(
|
||||
"can_generate 失败: plan=%s clips=%d 均无素材," "且 config.asset_ids 为空,无法自动修复",
|
||||
plan_id,
|
||||
len(clips),
|
||||
)
|
||||
return False, "没有可渲染的就绪片段,请确保已选择素材"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
@@ -196,28 +196,31 @@ test.describe("Core generation flow", () => {
|
||||
await materialLabel.locator("input[type='checkbox']").check()
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 3: preview — 需要先生成预览视频,才能进入下一步
|
||||
await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible()
|
||||
// Step 3: voice (可选步骤,新注册用户无配音素材,直接跳过)
|
||||
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible({ timeout: 15000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 4: preview — 需要先生成预览视频,才能进入下一步
|
||||
await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible({ timeout: 15000 })
|
||||
// 点击"生成预览"按钮触发预览生成
|
||||
await page.locator(".xx-preview-generate-btn").click()
|
||||
// 等待预览生成完成(后端渲染,可能需要较长时间)
|
||||
await expect(page.getByText("预览生成成功")).toBeVisible({ timeout: 120_000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 4: title
|
||||
// Step 5: title
|
||||
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible({ timeout: 15000 })
|
||||
// 如果 AI 自动选择标题模式开启,先切换到手动模式以显示输入框
|
||||
const aiSwitch = page.locator(".xx-title-ai-toggle .xx-switch.active")
|
||||
if (await aiSwitch.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await aiSwitch.click()
|
||||
}
|
||||
const titleText = `E2E Test ${suffix}`
|
||||
await page.getByPlaceholder("输入自定义标题…").fill(titleText)
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 5: voice
|
||||
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible()
|
||||
const firstVoiceCard = page.locator(".xx-voice-choice-item").first()
|
||||
await firstVoiceCard.click()
|
||||
await page.getByPlaceholder("输入或从标题库选择…").fill(titleText)
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 6: cover (默认 AI 智能选帧模式,直接下一步)
|
||||
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible()
|
||||
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({ timeout: 15000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 7: confirm and generate
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 智能剪辑页面 — V21 原型 1:1 还原
|
||||
* 7 步向导:选择模板 → 选择素材 → 生成预览 → 选择标题 → 选择配音 → 选择封面 → 确认生成
|
||||
* 智能剪辑页面 — V22 多预览 + 配音前置
|
||||
* 7 步向导:选择模板 → 选择素材 → 选择配音 → 生成预览 → 选择标题 → 选择封面 → 确认生成
|
||||
* 左右布局:左侧 generate-form + 右侧 generate-preview
|
||||
* 主组件仅保留整体布局与事件编排
|
||||
* 状态管理 → hooks/useGenerateFormState
|
||||
@@ -9,7 +9,7 @@
|
||||
* 底部按钮 → components/GenerateStepActions
|
||||
* 生成核心逻辑 → hooks/useGenerateVideo
|
||||
*/
|
||||
import React from "react"
|
||||
import React, { useState, useMemo } from "react"
|
||||
import { Modal, message } from "antd"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
@@ -24,7 +24,7 @@ import GenerateStepActions from "./components/GenerateStepActions"
|
||||
import { useGenerateFormState } from "./hooks/useGenerateFormState"
|
||||
import { useStepNavigation } from "./hooks/useStepNavigation"
|
||||
import { useGenerateVideo } from "./hooks/useGenerateVideo"
|
||||
import { useStep3Preview } from "./hooks/useStep3Preview"
|
||||
import { useStep4Preview } from "./hooks/useStep4Preview"
|
||||
import "./generate.css"
|
||||
|
||||
const GeneratePage: React.FC = () => {
|
||||
@@ -80,8 +80,24 @@ const GeneratePage: React.FC = () => {
|
||||
message.success("音色克隆成功!")
|
||||
}
|
||||
|
||||
/* ── Step3 预览生成 ── */
|
||||
const step3Preview = useStep3Preview({
|
||||
/* ── 预览数量(多预览) ── */
|
||||
const [previewCount, setPreviewCount] = useState(1)
|
||||
|
||||
/* ── 根据 voiceMode 构建 voiceIds 传给预览接口 ── */
|
||||
/* selectedVoice / selectedClonedVoice 均为 string 类型(voice ID),
|
||||
见 useGenerateFormState 返回值类型定义 */
|
||||
const previewVoiceIds = useMemo((): string[] => {
|
||||
if (voiceMode === "clone") {
|
||||
const id: string = selectedClonedVoice
|
||||
return id ? [id] : []
|
||||
}
|
||||
// preset / custom 模式
|
||||
const id: string = selectedVoice
|
||||
return id ? [id] : []
|
||||
}, [voiceMode, selectedVoice, selectedClonedVoice])
|
||||
|
||||
/* ── Step4 预览生成(多预览 + voice_ids) ── */
|
||||
const step4Preview = useStep4Preview({
|
||||
templates: userTemplates,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
@@ -89,6 +105,8 @@ const GeneratePage: React.FC = () => {
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds: previewVoiceIds,
|
||||
previewCount,
|
||||
})
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
@@ -100,7 +118,7 @@ const GeneratePage: React.FC = () => {
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
titleSettings,
|
||||
previewReady: step3Preview.canProceed,
|
||||
previewReady: step4Preview.canProceed,
|
||||
})
|
||||
|
||||
/* ── 视频生成核心逻辑 ── */
|
||||
@@ -187,14 +205,20 @@ const GeneratePage: React.FC = () => {
|
||||
onDismissError={handleDismissError}
|
||||
presetVoices={presetVoices}
|
||||
videoRatio={videoRatio}
|
||||
previewStatus={step3Preview.previewStatus}
|
||||
previewResult={step3Preview.previewResult}
|
||||
previewError={step3Preview.previewError}
|
||||
previewTemplateName={step3Preview.templateName}
|
||||
previewMaterialCount={step3Preview.materialCount}
|
||||
previewProgress={step3Preview.progress}
|
||||
onGeneratePreview={step3Preview.generatePreview}
|
||||
onRegeneratePreview={step3Preview.regeneratePreview}
|
||||
/* Step4 多预览 */
|
||||
previewCount={previewCount}
|
||||
onPreviewCountChange={setPreviewCount}
|
||||
previewItems={step4Preview.items}
|
||||
previewSelectedIndex={step4Preview.selectedIndex}
|
||||
onSelectPreview={step4Preview.setSelectedIndex}
|
||||
previewOverallStatus={step4Preview.previewStatus}
|
||||
previewOverallError={step4Preview.previewError}
|
||||
previewOverallProgress={step4Preview.progress}
|
||||
previewAnyGenerating={step4Preview.anyGenerating}
|
||||
previewTemplateName={step4Preview.templateName}
|
||||
previewMaterialCount={step4Preview.materialCount}
|
||||
onGeneratePreview={step4Preview.generatePreview}
|
||||
onRegeneratePreview={step4Preview.regeneratePreview}
|
||||
/>
|
||||
|
||||
<GenerateStepActions
|
||||
@@ -210,22 +234,22 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
{/* ════ 右侧:预览 + 生成结果 ════ */}
|
||||
<div className="xx-generate-right-col">
|
||||
{/* 预览视频面板(Step3+ 常驻) */}
|
||||
{currentStep >= 3 && (
|
||||
{/* 预览视频面板(Step4+ 常驻,展示选中的预览) */}
|
||||
{currentStep >= 4 && (
|
||||
<PreviewVideoPanel
|
||||
previewStatus={step3Preview.previewStatus}
|
||||
previewResult={step3Preview.previewResult}
|
||||
previewError={step3Preview.previewError}
|
||||
progress={step3Preview.progress}
|
||||
previewStatus={step4Preview.previewStatus}
|
||||
previewResult={step4Preview.previewResult}
|
||||
previewError={step4Preview.previewError}
|
||||
progress={step4Preview.progress}
|
||||
videoRatio={videoRatio}
|
||||
onRegenerate={step3Preview.regeneratePreview}
|
||||
onRegenerate={step4Preview.regeneratePreview}
|
||||
titleText={titleSettings.title}
|
||||
titleSettings={titleSettings}
|
||||
titleSettings={currentStep >= 5 ? titleSettings : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 正式生成结果(Step4+ 才显示) */}
|
||||
{currentStep >= 4 && (
|
||||
{/* 正式生成结果(Step5+ 才显示) */}
|
||||
{currentStep >= 5 && (
|
||||
<GenerateResultPanel
|
||||
generated={generated}
|
||||
generating={generating}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* GeneratePage 步骤内容渲染
|
||||
* 根据当前步骤渲染对应的 Step 组件
|
||||
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 预览(4) → 标题(5) → 封面(6) → 确认(7)
|
||||
*/
|
||||
import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
@@ -8,12 +9,12 @@ import type { PresetVoiceItem } from "@/api/voices"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import type { TitleSettings } from "../types"
|
||||
import type { PreviewResult, PreviewStatus } from "../hooks/useStep3Preview"
|
||||
import type { PreviewItem, PreviewStatus } from "../hooks/useStep4Preview"
|
||||
import Step1TemplateSelect from "../components/Step1TemplateSelect"
|
||||
import Step2MaterialSelect from "../components/Step2MaterialSelect"
|
||||
import Step3GeneratePreview from "../components/Step3GeneratePreview"
|
||||
import Step4TitleSettings from "../components/Step4TitleSettings"
|
||||
import Step5VoiceSelect from "../components/Step5VoiceSelect"
|
||||
import Step3VoiceSelect from "../components/Step5VoiceSelect"
|
||||
import Step4GeneratePreview from "../components/Step4GeneratePreview"
|
||||
import Step5TitleSettings from "../components/Step4TitleSettings"
|
||||
import Step6CoverSettings from "../components/Step6CoverSettings"
|
||||
import Step7ConfirmGenerate from "../components/Step7ConfirmGenerate"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
@@ -63,13 +64,18 @@ export interface GenerateStepContentProps {
|
||||
/* 其他 */
|
||||
presetVoices: PresetVoiceItem[]
|
||||
videoRatio: string
|
||||
/* Step3 预览 */
|
||||
previewStatus: PreviewStatus
|
||||
previewResult: PreviewResult | null
|
||||
previewError: string
|
||||
/* Step4 预览(多预览) */
|
||||
previewCount: number
|
||||
onPreviewCountChange: (count: number) => void
|
||||
previewItems: PreviewItem[]
|
||||
previewSelectedIndex: number
|
||||
onSelectPreview: (index: number) => void
|
||||
previewOverallStatus: PreviewStatus
|
||||
previewOverallError: string
|
||||
previewOverallProgress: number
|
||||
previewAnyGenerating: boolean
|
||||
previewTemplateName: string
|
||||
previewMaterialCount: string
|
||||
previewProgress: number
|
||||
onGeneratePreview: () => void
|
||||
onRegeneratePreview: () => void
|
||||
}
|
||||
@@ -107,12 +113,17 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onDismissError,
|
||||
presetVoices,
|
||||
videoRatio,
|
||||
previewStatus,
|
||||
previewResult,
|
||||
previewError,
|
||||
previewCount,
|
||||
onPreviewCountChange,
|
||||
previewItems,
|
||||
previewSelectedIndex,
|
||||
onSelectPreview,
|
||||
previewOverallStatus,
|
||||
previewOverallError,
|
||||
previewOverallProgress,
|
||||
previewAnyGenerating,
|
||||
previewTemplateName,
|
||||
previewMaterialCount,
|
||||
previewProgress,
|
||||
onGeneratePreview,
|
||||
onRegeneratePreview,
|
||||
} = props
|
||||
@@ -139,31 +150,36 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
)
|
||||
case 3:
|
||||
return (
|
||||
<Step3GeneratePreview
|
||||
templateName={previewTemplateName}
|
||||
materialCount={previewMaterialCount}
|
||||
duration={duration}
|
||||
videoRatio={videoRatio}
|
||||
previewStatus={previewStatus}
|
||||
previewResult={previewResult}
|
||||
previewError={previewError}
|
||||
progress={previewProgress}
|
||||
onGeneratePreview={onGeneratePreview}
|
||||
onRegeneratePreview={onRegeneratePreview}
|
||||
<Step3VoiceSelect
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={onSelectedVoiceChange}
|
||||
/>
|
||||
)
|
||||
case 4:
|
||||
return (
|
||||
<Step4TitleSettings
|
||||
titleSettings={titleSettings}
|
||||
onTitleSettingsChange={onTitleSettingsChange}
|
||||
<Step4GeneratePreview
|
||||
templateName={previewTemplateName}
|
||||
materialCount={previewMaterialCount}
|
||||
duration={duration}
|
||||
videoRatio={videoRatio}
|
||||
previewCount={previewCount}
|
||||
onPreviewCountChange={onPreviewCountChange}
|
||||
items={previewItems}
|
||||
selectedIndex={previewSelectedIndex}
|
||||
onSelectPreview={onSelectPreview}
|
||||
overallStatus={previewOverallStatus}
|
||||
overallError={previewOverallError}
|
||||
overallProgress={previewOverallProgress}
|
||||
anyGenerating={previewAnyGenerating}
|
||||
onGeneratePreview={onGeneratePreview}
|
||||
onRegeneratePreview={onRegeneratePreview}
|
||||
/>
|
||||
)
|
||||
case 5:
|
||||
return (
|
||||
<Step5VoiceSelect
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={onSelectedVoiceChange}
|
||||
<Step5TitleSettings
|
||||
titleSettings={titleSettings}
|
||||
onTitleSettingsChange={onTitleSettingsChange}
|
||||
/>
|
||||
)
|
||||
case 6:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 右侧预览视频面板
|
||||
* Step3 生成预览后常驻显示预览视频
|
||||
* Step4+ 用 Canvas 绘制标题预览(替代 CSS overlay,与 ASS 渲染行为一致)
|
||||
* Step4 生成预览后常驻显示预览视频
|
||||
* Step5+ 用 Canvas 绘制标题预览(替代 CSS overlay,与 ASS 渲染行为一致)
|
||||
*
|
||||
* 设计说明:标题预览仅在有视频时显示(叠加在视频画面上方)。
|
||||
* 无视频状态(idle/loading/error)下不再单独显示标题预览,这是有意为之的设计简化。
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
import React, { useRef, useEffect, useCallback } from "react"
|
||||
import { PlayCircleOutlined, LoadingOutlined } from "@ant-design/icons"
|
||||
import type { PreviewResult, PreviewStatus } from "../hooks/useStep3Preview"
|
||||
import type { PreviewResult, PreviewStatus } from "../hooks/useStep4Preview"
|
||||
import type { TitleSettings } from "../types"
|
||||
|
||||
interface PreviewVideoPanelProps {
|
||||
@@ -23,9 +23,9 @@ interface PreviewVideoPanelProps {
|
||||
progress: number
|
||||
videoRatio: string
|
||||
onRegenerate: () => void
|
||||
/** 标题文字(Step4 起传入) */
|
||||
/** 标题文字(Step5 起传入) */
|
||||
titleText?: string
|
||||
/** 标题样式设置(Step4 起传入) */
|
||||
/** 标题样式设置(Step5 起传入) */
|
||||
titleSettings?: TitleSettings
|
||||
}
|
||||
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
/**
|
||||
* Step 3 生成预览组件
|
||||
* 调用后端预览生成接口,展示真实视频预览
|
||||
*/
|
||||
import React from "react"
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
LoadingOutlined,
|
||||
ReloadOutlined,
|
||||
PlayCircleOutlined,
|
||||
ExclamationCircleFilled,
|
||||
ClockCircleOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { PreviewResult, PreviewStatus } from "../hooks/useStep3Preview"
|
||||
|
||||
interface Step3GeneratePreviewProps {
|
||||
templateName: string
|
||||
materialCount: string
|
||||
duration: number
|
||||
videoRatio: string
|
||||
previewStatus: PreviewStatus
|
||||
previewResult: PreviewResult | null
|
||||
previewError: string
|
||||
progress: number
|
||||
onGeneratePreview: () => void
|
||||
onRegeneratePreview: () => void
|
||||
}
|
||||
|
||||
const Step3GeneratePreview: React.FC<Step3GeneratePreviewProps> = ({
|
||||
templateName: _templateName,
|
||||
materialCount: _materialCount,
|
||||
videoRatio: _videoRatio,
|
||||
previewStatus,
|
||||
previewResult,
|
||||
previewError,
|
||||
progress,
|
||||
onGeneratePreview,
|
||||
onRegeneratePreview,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎬 生成预览</h3>
|
||||
|
||||
{/* 预览生成按钮 */}
|
||||
{previewStatus === "idle" && (
|
||||
<div className="xx-preview-generate-section">
|
||||
<div className="xx-preview-generate-hint">
|
||||
<PlayCircleOutlined style={{ fontSize: 32, color: "#3b82f6", marginBottom: 12 }} />
|
||||
<p className="xx-preview-generate-title">一键生成剪辑预览</p>
|
||||
<p className="xx-preview-generate-desc">
|
||||
AI 将根据您选择的模板和素材,智能生成完整视频预览(480p 低清版)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="xx-btn xx-btn-primary xx-preview-generate-btn"
|
||||
onClick={onGeneratePreview}
|
||||
>
|
||||
✨ 生成预览
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 排队中 */}
|
||||
{previewStatus === "pending" && (
|
||||
<div className="xx-preview-loading">
|
||||
<ClockCircleOutlined style={{ fontSize: 32, color: "#faad14" }} spin />
|
||||
<p className="xx-preview-loading-text">预览排队中...</p>
|
||||
<p className="xx-preview-loading-desc">正在等待渲染资源,请稍候</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成中 */}
|
||||
{previewStatus === "generating" && (
|
||||
<div className="xx-preview-loading">
|
||||
<LoadingOutlined style={{ fontSize: 32, color: "#3b82f6" }} spin />
|
||||
<p className="xx-preview-loading-text">正在生成预览视频... {progress}%</p>
|
||||
<p className="xx-preview-loading-desc">AI 正在剪辑素材并合成预览视频</p>
|
||||
<div className="xx-preview-progress-bar">
|
||||
<div className="xx-preview-progress-fill" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成失败 */}
|
||||
{previewStatus === "error" && (
|
||||
<div className="xx-preview-error">
|
||||
<ExclamationCircleFilled style={{ fontSize: 28, color: "#ef4444" }} />
|
||||
<p className="xx-preview-error-text">预览生成失败</p>
|
||||
<p className="xx-preview-error-desc">
|
||||
{typeof previewError === "string" && previewError ? previewError : "请稍后重试"}
|
||||
</p>
|
||||
<button className="xx-btn xx-btn-primary" onClick={onRegeneratePreview}>
|
||||
<ReloadOutlined /> 重新生成
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成成功 - 视频预览 */}
|
||||
{previewStatus === "ready" && previewResult && (
|
||||
<>
|
||||
<div className="xx-preview-tip">
|
||||
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 8 }} />
|
||||
<span>预览生成成功,确认效果后进入下一步</span>
|
||||
<button
|
||||
className="xx-preview-regenerate-btn"
|
||||
onClick={onRegeneratePreview}
|
||||
title="重新生成"
|
||||
>
|
||||
<ReloadOutlined /> 重新生成
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 视频播放器 */}
|
||||
<div className="xx-preview-video-wrapper">
|
||||
<video
|
||||
className="xx-preview-video"
|
||||
src={previewResult.videoUrl}
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step3GeneratePreview
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Step 4 生成预览组件(支持多预览)
|
||||
* 调用后端预览生成接口,展示多个真实视频预览(网格布局)
|
||||
*/
|
||||
import React from "react"
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
LoadingOutlined,
|
||||
ReloadOutlined,
|
||||
PlayCircleOutlined,
|
||||
ExclamationCircleFilled,
|
||||
ClockCircleOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { InputNumber } from "antd"
|
||||
import type { PreviewItem, PreviewStatus } from "../hooks/useStep4Preview"
|
||||
|
||||
interface Step4GeneratePreviewProps {
|
||||
templateName: string
|
||||
materialCount: string
|
||||
duration: number
|
||||
videoRatio: string
|
||||
previewCount: number
|
||||
onPreviewCountChange: (count: number) => void
|
||||
items: PreviewItem[]
|
||||
selectedIndex: number
|
||||
onSelectPreview: (index: number) => void
|
||||
overallStatus: PreviewStatus
|
||||
overallError: string
|
||||
overallProgress: number
|
||||
anyGenerating: boolean
|
||||
onGeneratePreview: () => void
|
||||
onRegeneratePreview: () => void
|
||||
}
|
||||
|
||||
/** 预览数量选项 */
|
||||
const PREVIEW_COUNT_OPTIONS = [
|
||||
{ value: 1, label: "1个" },
|
||||
{ value: 2, label: "2个" },
|
||||
{ value: 3, label: "3个" },
|
||||
]
|
||||
|
||||
const Step4GeneratePreview: React.FC<Step4GeneratePreviewProps> = ({
|
||||
templateName: _templateName,
|
||||
materialCount: _materialCount,
|
||||
videoRatio: _videoRatio,
|
||||
previewCount,
|
||||
onPreviewCountChange,
|
||||
items,
|
||||
selectedIndex,
|
||||
onSelectPreview,
|
||||
overallStatus,
|
||||
overallError,
|
||||
overallProgress,
|
||||
anyGenerating,
|
||||
onGeneratePreview,
|
||||
onRegeneratePreview,
|
||||
}) => {
|
||||
const isIdle = overallStatus === "idle"
|
||||
const isError = overallStatus === "error" && !items.some((it) => it.status === "ready")
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎬 生成预览</h3>
|
||||
|
||||
{/* 预览数量选择器(仅在 idle 状态显示) */}
|
||||
{isIdle && (
|
||||
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<span style={{ fontSize: 14, color: "#666" }}>预览数量:</span>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
{PREVIEW_COUNT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onPreviewCountChange(opt.value)}
|
||||
style={{
|
||||
padding: "4px 12px",
|
||||
borderRadius: 6,
|
||||
border: previewCount === opt.value ? "1px solid #1677ff" : "1px solid #d9d9d9",
|
||||
background: previewCount === opt.value ? "#e6f4ff" : "#fff",
|
||||
color: previewCount === opt.value ? "#1677ff" : "#666",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: previewCount === opt.value ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={10}
|
||||
value={previewCount}
|
||||
onChange={(val) => val && onPreviewCountChange(val)}
|
||||
style={{ width: 70 }}
|
||||
placeholder="自定义"
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: "#999", marginLeft: 4 }}>(1~10)</span>
|
||||
</div>
|
||||
{previewCount > 1 && (
|
||||
<span style={{ fontSize: 12, color: "#999" }}>生成多个预览可对比不同剪辑效果</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 预览生成按钮(idle 状态) */}
|
||||
{isIdle && (
|
||||
<div className="xx-preview-generate-section">
|
||||
<div className="xx-preview-generate-hint">
|
||||
<PlayCircleOutlined style={{ fontSize: 32, color: "#3b82f6", marginBottom: 12 }} />
|
||||
<p className="xx-preview-generate-title">一键生成剪辑预览</p>
|
||||
<p className="xx-preview-generate-desc">
|
||||
AI 将根据您选择的模板、素材和配音,智能生成
|
||||
{previewCount > 1 ? `${previewCount}个不同版本的` : ""}视频预览(480p 低清版)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="xx-btn xx-btn-primary xx-preview-generate-btn"
|
||||
onClick={onGeneratePreview}
|
||||
>
|
||||
✨ 生成预览{previewCount > 1 ? `(${previewCount}个)` : ""}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 整体排队中(所有都在 pending) */}
|
||||
{anyGenerating && items.every((it) => it.status === "pending") && (
|
||||
<div className="xx-preview-loading">
|
||||
<ClockCircleOutlined style={{ fontSize: 32, color: "#faad14" }} spin />
|
||||
<p className="xx-preview-loading-text">预览排队中...</p>
|
||||
<p className="xx-preview-loading-desc">正在等待渲染资源,请稍候</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 多预览网格(生成中/完成/部分完成) */}
|
||||
{(anyGenerating || overallStatus === "ready") && items.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${Math.min(items.length, 3)}, 1fr)`,
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const isSelected = item.index === selectedIndex
|
||||
return (
|
||||
<div
|
||||
key={item.index}
|
||||
onClick={() => {
|
||||
if (item.status === "ready") onSelectPreview(item.index)
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: isSelected ? "2px solid #1677ff" : "1px solid #e8e8e8",
|
||||
overflow: "hidden",
|
||||
cursor: item.status === "ready" ? "pointer" : "default",
|
||||
opacity: item.status === "error" ? 0.6 : 1,
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
{/* 缩略图/状态区域 */}
|
||||
<div
|
||||
style={{
|
||||
aspectRatio: "9/16",
|
||||
maxHeight: 180,
|
||||
background: "#000",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{item.status === "ready" && item.result && (
|
||||
<video
|
||||
src={item.result.videoUrl}
|
||||
controls
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
preload="metadata"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(item.status === "pending" || item.status === "generating") && (
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<LoadingOutlined style={{ fontSize: 24, color: "#fff" }} spin />
|
||||
<p style={{ color: "rgba(255,255,255,0.8)", fontSize: 12, marginTop: 8 }}>
|
||||
{item.status === "pending" ? "排队中..." : `生成中 ${item.progress}%`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{item.status === "error" && (
|
||||
<div style={{ textAlign: "center", padding: 8 }}>
|
||||
<ExclamationCircleFilled style={{ fontSize: 20, color: "#ef4444" }} />
|
||||
<p
|
||||
style={{
|
||||
color: "rgba(255,255,255,0.7)",
|
||||
fontSize: 11,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
生成失败
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* 选中角标 */}
|
||||
{isSelected && item.status === "ready" && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 4,
|
||||
right: 4,
|
||||
background: "#1677ff",
|
||||
color: "#fff",
|
||||
fontSize: 10,
|
||||
padding: "2px 6px",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
预览 #{item.index + 1}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 底部信息 */}
|
||||
{item.status === "ready" && item.result && (
|
||||
<div
|
||||
style={{
|
||||
padding: "6px 8px",
|
||||
background: "#fafafa",
|
||||
fontSize: 11,
|
||||
color: "#666",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span>{item.result.duration.toFixed(1)}秒</span>
|
||||
<span>{item.result.clipCount}段</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 整体进度条(多预览生成中) */}
|
||||
{anyGenerating && (
|
||||
<div className="xx-preview-progress-bar" style={{ marginBottom: 12 }}>
|
||||
<div className="xx-preview-progress-fill" style={{ width: `${overallProgress}%` }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 全部完成提示 */}
|
||||
{overallStatus === "ready" && (
|
||||
<div className="xx-preview-tip">
|
||||
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 8 }} />
|
||||
<span>
|
||||
{items.filter((it) => it.status === "ready").length} 个预览生成成功
|
||||
{items.length > 1 ? ",点击选择要查看的版本" : ",确认效果后进入下一步"}
|
||||
</span>
|
||||
<button
|
||||
className="xx-preview-regenerate-btn"
|
||||
onClick={onRegeneratePreview}
|
||||
title="重新生成"
|
||||
>
|
||||
<ReloadOutlined /> 重新生成
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 全部失败 */}
|
||||
{isError && (
|
||||
<div className="xx-preview-error">
|
||||
<ExclamationCircleFilled style={{ fontSize: 28, color: "#ef4444" }} />
|
||||
<p className="xx-preview-error-text">预览生成失败</p>
|
||||
<p className="xx-preview-error-desc">
|
||||
{typeof overallError === "string" && overallError ? overallError : "请稍后重试"}
|
||||
</p>
|
||||
<button className="xx-btn xx-btn-primary" onClick={onRegeneratePreview}>
|
||||
<ReloadOutlined /> 重新生成
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step4GeneratePreview
|
||||
@@ -31,9 +31,9 @@ export const VOICE_GENDER_ICON: Record<string, string> = {
|
||||
export const STEPS = [
|
||||
{ key: 1, label: "选择模板" },
|
||||
{ key: 2, label: "选择素材" },
|
||||
{ key: 3, label: "生成预览" },
|
||||
{ key: 4, label: "选择标题" },
|
||||
{ key: 5, label: "选择配音" },
|
||||
{ key: 3, label: "选择配音" },
|
||||
{ key: 4, label: "生成预览" },
|
||||
{ key: 5, label: "选择标题" },
|
||||
{ key: 6, label: "选择封面" },
|
||||
{ key: 7, label: "确认生成" },
|
||||
]
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
/**
|
||||
* Step 3 生成预览 Hook
|
||||
* 调用 /generation/preview 接口创建预览任务,轮询状态直到完成
|
||||
*/
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react"
|
||||
import { createPreview, getPreviewStatus } from "@/api/generation"
|
||||
import type { PreviewTaskResponse, PreviewStatus as ApiPreviewStatus } from "@/api/generation"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { safeExtractError } from "./generate-video/errorUtils"
|
||||
|
||||
/** 安全地将值转为字符串,防止对象被直接渲染导致 React Error #31 */
|
||||
const safeString = (val: unknown, fallback: string): string => {
|
||||
if (val == null) return fallback
|
||||
const s = safeExtractError(val)
|
||||
return s || fallback
|
||||
}
|
||||
|
||||
/** 安全地将值转为数字,防止非数字值进入渲染 */
|
||||
const safeNumber = (val: unknown, fallback = 0): number => {
|
||||
if (typeof val === "number" && !Number.isNaN(val)) return val
|
||||
if (typeof val === "string") {
|
||||
const n = Number(val)
|
||||
return Number.isNaN(n) ? fallback : n
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
interface UseStep3PreviewProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
materialMode: "manual" | "auto"
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
duration: number
|
||||
videoRatio: string
|
||||
titleText?: string
|
||||
voiceId?: string
|
||||
}
|
||||
|
||||
export type PreviewStatus = "idle" | "pending" | "generating" | "ready" | "error"
|
||||
|
||||
/** 预览生成结果 */
|
||||
export interface PreviewResult {
|
||||
taskId: string
|
||||
videoUrl: string
|
||||
clipCount: number
|
||||
transitionCount: number
|
||||
materialUsage: number
|
||||
duration: number
|
||||
fileSize: number
|
||||
generateDuration: number
|
||||
progress: number
|
||||
}
|
||||
|
||||
// 轮询超时时间(10 分钟)
|
||||
const POLL_TIMEOUT_MS = 10 * 60 * 1000
|
||||
export function useStep3Preview({
|
||||
templates,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
}: UseStep3PreviewProps) {
|
||||
const templateName = useMemo(
|
||||
() => templates.find((t) => t.id === selectedTemplate)?.name ?? "未选择",
|
||||
[templates, selectedTemplate],
|
||||
)
|
||||
|
||||
const materialCount = useMemo(() => {
|
||||
if (materialMode === "auto") {
|
||||
return `${smartSelectedIds.length} 个素材(智能匹配)`
|
||||
}
|
||||
return `${selectedMaterials.length} 个素材`
|
||||
}, [materialMode, selectedMaterials.length, smartSelectedIds.length])
|
||||
|
||||
const materialTotal = materialMode === "auto" ? smartSelectedIds.length : selectedMaterials.length
|
||||
|
||||
/* ── 预览生成状态 ── */
|
||||
const [previewStatus, setPreviewStatus] = useState<PreviewStatus>("idle")
|
||||
const [previewResult, setPreviewResult] = useState<PreviewResult | null>(null)
|
||||
const [previewError, setPreviewError] = useState<string>("")
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
// 任务 ID + 轮询定时器,用于防止竞态条件
|
||||
const currentTaskIdRef = useRef<string | null>(null)
|
||||
const pollTimerRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const startTimeRef = useRef<number>(0)
|
||||
|
||||
const clearPollTimer = useCallback(() => {
|
||||
if (pollTimerRef.current) {
|
||||
clearTimeout(pollTimerRef.current)
|
||||
pollTimerRef.current = undefined
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 参数变化时重置预览状态 ── */
|
||||
const prevDepsRef = useRef({
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials: [...selectedMaterials].sort().join(","),
|
||||
smartSelectedIds: [...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const currentKey = [
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
[...selectedMaterials].sort().join(","),
|
||||
[...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
].join("|")
|
||||
|
||||
const prevKey = [
|
||||
prevDepsRef.current.selectedTemplate,
|
||||
prevDepsRef.current.materialMode,
|
||||
prevDepsRef.current.selectedMaterials,
|
||||
prevDepsRef.current.smartSelectedIds,
|
||||
prevDepsRef.current.duration,
|
||||
prevDepsRef.current.videoRatio,
|
||||
].join("|")
|
||||
|
||||
if (prevKey !== currentKey && previewStatus !== "idle") {
|
||||
// 参数变化,作废当前任务
|
||||
currentTaskIdRef.current = null
|
||||
clearPollTimer()
|
||||
setPreviewStatus("idle")
|
||||
setPreviewResult(null)
|
||||
setPreviewError("")
|
||||
setProgress(0)
|
||||
}
|
||||
|
||||
prevDepsRef.current = {
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials: [...selectedMaterials].sort().join(","),
|
||||
smartSelectedIds: [...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
}
|
||||
}, [
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
previewStatus,
|
||||
clearPollTimer,
|
||||
])
|
||||
|
||||
// 组件卸载时清理轮询
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearPollTimer()
|
||||
}
|
||||
}, [clearPollTimer])
|
||||
|
||||
/** 轮询预览任务状态 */
|
||||
const pollPreviewStatus = useCallback((taskId: string) => {
|
||||
const poll = async () => {
|
||||
// 检查是否已被取消(参数变化或重新生成)
|
||||
if (currentTaskIdRef.current !== taskId) return
|
||||
|
||||
// 超时检查
|
||||
if (Date.now() - startTimeRef.current > POLL_TIMEOUT_MS) {
|
||||
setPreviewError("预览生成超时,请重试")
|
||||
setPreviewStatus("error")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data: PreviewTaskResponse = await getPreviewStatus(taskId)
|
||||
|
||||
if (currentTaskIdRef.current !== taskId) return
|
||||
|
||||
const status = data.status as ApiPreviewStatus
|
||||
|
||||
if (status === "completed") {
|
||||
setProgress(100)
|
||||
setPreviewResult({
|
||||
taskId: safeString(data.task_id, ""),
|
||||
videoUrl: safeString(data.video_url, ""),
|
||||
clipCount: safeNumber(data.clip_count),
|
||||
transitionCount: safeNumber(data.transition_count),
|
||||
materialUsage: safeNumber(data.material_usage),
|
||||
duration: safeNumber(data.duration),
|
||||
fileSize: safeNumber(data.file_size),
|
||||
generateDuration: safeNumber(data.generate_duration),
|
||||
progress: 100,
|
||||
})
|
||||
setPreviewStatus("ready")
|
||||
return
|
||||
}
|
||||
|
||||
if (status === "failed") {
|
||||
setPreviewError(safeString(data.error_message, "预览生成失败,请重试"))
|
||||
setPreviewStatus("error")
|
||||
return
|
||||
}
|
||||
|
||||
if (status === "cancelled") {
|
||||
setPreviewError("预览任务已取消")
|
||||
setPreviewStatus("error")
|
||||
return
|
||||
}
|
||||
|
||||
// pending / generating 状态继续轮询
|
||||
setProgress(safeNumber(data.progress))
|
||||
if (status === "pending") {
|
||||
setPreviewStatus("pending")
|
||||
pollTimerRef.current = setTimeout(poll, 5000)
|
||||
} else {
|
||||
setPreviewStatus("generating")
|
||||
pollTimerRef.current = setTimeout(poll, 2000)
|
||||
}
|
||||
} catch (pollErr) {
|
||||
if (currentTaskIdRef.current !== taskId) return
|
||||
// 轮询出错,延迟后重试
|
||||
pollTimerRef.current = setTimeout(poll, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
// 首次延迟 1 秒开始轮询
|
||||
pollTimerRef.current = setTimeout(poll, 1000)
|
||||
}, [])
|
||||
|
||||
/** 生成预览 */
|
||||
const generatePreview = useCallback(async () => {
|
||||
if (!selectedTemplate) {
|
||||
setPreviewError("请先选择模板")
|
||||
setPreviewStatus("error")
|
||||
return
|
||||
}
|
||||
if (materialTotal === 0) {
|
||||
setPreviewError("请先选择素材")
|
||||
setPreviewStatus("error")
|
||||
return
|
||||
}
|
||||
|
||||
// 取消之前的轮询
|
||||
clearPollTimer()
|
||||
|
||||
setPreviewStatus("pending")
|
||||
setPreviewError("")
|
||||
setProgress(0)
|
||||
setPreviewResult(null)
|
||||
startTimeRef.current = Date.now()
|
||||
|
||||
try {
|
||||
const assetIds = materialMode === "auto" ? smartSelectedIds : selectedMaterials
|
||||
const response = await createPreview({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
duration: duration || undefined,
|
||||
video_ratio: videoRatio,
|
||||
})
|
||||
|
||||
// 竞态检查
|
||||
if (startTimeRef.current === 0) return // 已被重置
|
||||
|
||||
currentTaskIdRef.current = response.task_id
|
||||
pollPreviewStatus(response.task_id)
|
||||
} catch (e) {
|
||||
setPreviewError(safeString(e instanceof Error ? e.message : e, "预览生成失败"))
|
||||
setPreviewStatus("error")
|
||||
}
|
||||
}, [
|
||||
selectedTemplate,
|
||||
materialTotal,
|
||||
materialMode,
|
||||
smartSelectedIds,
|
||||
selectedMaterials,
|
||||
duration,
|
||||
videoRatio,
|
||||
clearPollTimer,
|
||||
pollPreviewStatus,
|
||||
])
|
||||
|
||||
/** 重新生成预览 */
|
||||
const regeneratePreview = useCallback(() => {
|
||||
generatePreview()
|
||||
}, [generatePreview])
|
||||
|
||||
/** 是否可以下一步(预览已生成) */
|
||||
const canProceed = previewStatus === "ready"
|
||||
|
||||
return {
|
||||
templateName,
|
||||
materialCount,
|
||||
duration,
|
||||
videoRatio,
|
||||
// 预览生成状态
|
||||
previewStatus,
|
||||
previewResult,
|
||||
previewError,
|
||||
progress,
|
||||
canProceed,
|
||||
generatePreview,
|
||||
regeneratePreview,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep3Preview
|
||||
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* Step 4 生成预览 Hook(支持多预览 + voice_ids)
|
||||
* 调用 /generation/preview 接口创建多个预览任务,轮询状态直到全部完成
|
||||
*/
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react"
|
||||
import { createPreview, getPreviewStatus } from "@/api/generation"
|
||||
import type { PreviewTaskResponse, PreviewStatus as ApiPreviewStatus } from "@/api/generation"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { safeExtractError } from "./generate-video/errorUtils"
|
||||
|
||||
/** 安全地将值转为字符串,防止对象被直接渲染导致 React Error #31 */
|
||||
const safeString = (val: unknown, fallback: string): string => {
|
||||
if (val == null) return fallback
|
||||
const s = safeExtractError(val)
|
||||
return s || fallback
|
||||
}
|
||||
|
||||
/** 安全地将值转为数字,防止非数字值进入渲染 */
|
||||
const safeNumber = (val: unknown, fallback = 0): number => {
|
||||
if (typeof val === "number" && !Number.isNaN(val)) return val
|
||||
if (typeof val === "string") {
|
||||
const n = Number(val)
|
||||
return Number.isNaN(n) ? fallback : n
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
interface UseStep4PreviewProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
materialMode: "manual" | "auto"
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
duration: number
|
||||
videoRatio: string
|
||||
/** 配音 voice_ids(传给后端,让预览包含配音音频) */
|
||||
voiceIds?: string[]
|
||||
/** 要生成的预览数量 */
|
||||
previewCount?: number
|
||||
}
|
||||
|
||||
export type PreviewStatus = "idle" | "pending" | "generating" | "ready" | "error"
|
||||
|
||||
/** 单个预览生成结果 */
|
||||
export interface PreviewResult {
|
||||
taskId: string
|
||||
videoUrl: string
|
||||
clipCount: number
|
||||
transitionCount: number
|
||||
materialUsage: number
|
||||
duration: number
|
||||
fileSize: number
|
||||
generateDuration: number
|
||||
progress: number
|
||||
}
|
||||
|
||||
/** 单个预览项的完整状态(用于多预览) */
|
||||
export interface PreviewItem {
|
||||
index: number
|
||||
status: PreviewStatus
|
||||
result: PreviewResult | null
|
||||
error: string
|
||||
progress: number
|
||||
}
|
||||
|
||||
// 轮询超时时间(10 分钟)
|
||||
const POLL_TIMEOUT_MS = 10 * 60 * 1000
|
||||
|
||||
/** 初始单项状态 */
|
||||
const createInitialItem = (index: number): PreviewItem => ({
|
||||
index,
|
||||
status: "idle",
|
||||
result: null,
|
||||
error: "",
|
||||
progress: 0,
|
||||
})
|
||||
|
||||
export function useStep4Preview({
|
||||
templates,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds,
|
||||
previewCount = 1,
|
||||
}: UseStep4PreviewProps) {
|
||||
const templateName = useMemo(
|
||||
() => templates.find((t) => t.id === selectedTemplate)?.name ?? "未选择",
|
||||
[templates, selectedTemplate],
|
||||
)
|
||||
|
||||
const materialCount = useMemo(() => {
|
||||
if (materialMode === "auto") {
|
||||
return `${smartSelectedIds.length} 个素材(智能匹配)`
|
||||
}
|
||||
return `${selectedMaterials.length} 个素材`
|
||||
}, [materialMode, selectedMaterials.length, smartSelectedIds.length])
|
||||
|
||||
const materialTotal = materialMode === "auto" ? smartSelectedIds.length : selectedMaterials.length
|
||||
|
||||
/* ── 多预览状态 ── */
|
||||
const [items, setItems] = useState<PreviewItem[]>(() =>
|
||||
Array.from({ length: previewCount }, (_, i) => createInitialItem(i)),
|
||||
)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
// 每个任务 ID + 轮询定时器,用于防止竞态条件(按 index 存储)
|
||||
const taskIdsRef = useRef<Map<number, string>>(new Map())
|
||||
const pollTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map())
|
||||
const startTimeRef = useRef<number>(0)
|
||||
|
||||
const clearPollTimer = useCallback((index?: number) => {
|
||||
if (index !== undefined) {
|
||||
const timer = pollTimersRef.current.get(index)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
pollTimersRef.current.delete(index)
|
||||
}
|
||||
} else {
|
||||
pollTimersRef.current.forEach((timer) => clearTimeout(timer))
|
||||
pollTimersRef.current.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 同步 previewCount 变化(增减项)
|
||||
useEffect(() => {
|
||||
setItems((prev) => {
|
||||
if (prev.length === previewCount) return prev
|
||||
if (prev.length > previewCount) return prev.slice(0, previewCount)
|
||||
return [
|
||||
...prev,
|
||||
...Array.from({ length: previewCount - prev.length }, (_, i) =>
|
||||
createInitialItem(prev.length + i),
|
||||
),
|
||||
]
|
||||
})
|
||||
// 如果 selectedIndex 超出范围,重置
|
||||
setSelectedIndex((prev) => Math.min(prev, previewCount - 1))
|
||||
}, [previewCount])
|
||||
|
||||
/* ── 参数变化时重置所有预览状态 ── */
|
||||
const prevDepsRef = useRef({
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials: [...selectedMaterials].sort().join(","),
|
||||
smartSelectedIds: [...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds: [...(voiceIds || [])].sort().join(","),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const currentKey = [
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
[...selectedMaterials].sort().join(","),
|
||||
[...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
[...(voiceIds || [])].sort().join(","),
|
||||
].join("|")
|
||||
|
||||
const prevKey = [
|
||||
prevDepsRef.current.selectedTemplate,
|
||||
prevDepsRef.current.materialMode,
|
||||
prevDepsRef.current.selectedMaterials,
|
||||
prevDepsRef.current.smartSelectedIds,
|
||||
prevDepsRef.current.duration,
|
||||
prevDepsRef.current.videoRatio,
|
||||
prevDepsRef.current.voiceIds,
|
||||
].join("|")
|
||||
|
||||
if (prevKey !== currentKey && items.some((it) => it.status !== "idle")) {
|
||||
taskIdsRef.current.clear()
|
||||
clearPollTimer()
|
||||
setItems(Array.from({ length: previewCount }, (_, i) => createInitialItem(i)))
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
|
||||
prevDepsRef.current = {
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials: [...selectedMaterials].sort().join(","),
|
||||
smartSelectedIds: [...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds: [...(voiceIds || [])].sort().join(","),
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds,
|
||||
previewCount,
|
||||
])
|
||||
|
||||
// 组件卸载时清理所有轮询
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearPollTimer()
|
||||
}
|
||||
}, [clearPollTimer])
|
||||
|
||||
/** 轮询单个预览任务状态 */
|
||||
const pollPreviewStatus = useCallback((index: number, taskId: string) => {
|
||||
const poll = async () => {
|
||||
// 竞态检查
|
||||
if (taskIdsRef.current.get(index) !== taskId) return
|
||||
|
||||
// 超时检查
|
||||
if (Date.now() - startTimeRef.current > POLL_TIMEOUT_MS) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: "error", error: "预览生成超时,请重试" } : it,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data: PreviewTaskResponse = await getPreviewStatus(taskId)
|
||||
|
||||
if (taskIdsRef.current.get(index) !== taskId) return
|
||||
|
||||
const status = data.status as ApiPreviewStatus
|
||||
|
||||
if (status === "completed") {
|
||||
const result: PreviewResult = {
|
||||
taskId: safeString(data.task_id, ""),
|
||||
videoUrl: safeString(data.video_url, ""),
|
||||
clipCount: safeNumber(data.clip_count),
|
||||
transitionCount: safeNumber(data.transition_count),
|
||||
materialUsage: safeNumber(data.material_usage),
|
||||
duration: safeNumber(data.duration),
|
||||
fileSize: safeNumber(data.file_size),
|
||||
generateDuration: safeNumber(data.generate_duration),
|
||||
progress: 100,
|
||||
}
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: "ready", result, progress: 100 } : it,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (status === "failed") {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index
|
||||
? {
|
||||
...it,
|
||||
status: "error",
|
||||
error: safeString(data.error_message, "预览生成失败,请重试"),
|
||||
}
|
||||
: it,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (status === "cancelled") {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: "error", error: "预览任务已取消" } : it,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// pending / generating 状态继续轮询
|
||||
const prog = safeNumber(data.progress)
|
||||
const nextStatus: PreviewStatus = status === "pending" ? "pending" : "generating"
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: nextStatus, progress: prog } : it,
|
||||
),
|
||||
)
|
||||
const delay = status === "pending" ? 5000 : 2000
|
||||
pollTimersRef.current.set(index, setTimeout(poll, delay))
|
||||
} catch {
|
||||
if (taskIdsRef.current.get(index) !== taskId) return
|
||||
pollTimersRef.current.set(index, setTimeout(poll, 3000))
|
||||
}
|
||||
}
|
||||
|
||||
pollTimersRef.current.set(index, setTimeout(poll, 1000))
|
||||
}, [])
|
||||
|
||||
/** 生成所有预览 */
|
||||
const generatePreview = useCallback(async () => {
|
||||
if (!selectedTemplate) {
|
||||
setItems((prev) => prev.map((it) => ({ ...it, status: "error", error: "请先选择模板" })))
|
||||
return
|
||||
}
|
||||
if (materialTotal === 0) {
|
||||
setItems((prev) => prev.map((it) => ({ ...it, status: "error", error: "请先选择素材" })))
|
||||
return
|
||||
}
|
||||
|
||||
// 取消之前的所有轮询
|
||||
clearPollTimer()
|
||||
taskIdsRef.current.clear()
|
||||
|
||||
// 初始化所有项为 pending
|
||||
setItems(
|
||||
Array.from({ length: previewCount }, (_, i) => ({
|
||||
index: i,
|
||||
status: "pending" as PreviewStatus,
|
||||
result: null,
|
||||
error: "",
|
||||
progress: 0,
|
||||
})),
|
||||
)
|
||||
setSelectedIndex(0)
|
||||
startTimeRef.current = Date.now()
|
||||
|
||||
const assetIds = materialMode === "auto" ? smartSelectedIds : selectedMaterials
|
||||
|
||||
// 并发创建所有预览任务(Promise.all 并行请求,减少串行等待)
|
||||
const createTasks = Array.from({ length: previewCount }, async (_, i) => {
|
||||
try {
|
||||
const response = await createPreview({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
duration: duration || undefined,
|
||||
video_ratio: videoRatio,
|
||||
voice_ids: voiceIds && voiceIds.length > 0 ? voiceIds : undefined,
|
||||
})
|
||||
|
||||
if (startTimeRef.current === 0) return
|
||||
|
||||
taskIdsRef.current.set(i, response.task_id)
|
||||
pollPreviewStatus(i, response.task_id)
|
||||
} catch (e) {
|
||||
const errMsg = safeString(e instanceof Error ? e.message : e, "预览生成失败")
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.index === i ? { ...it, status: "error", error: errMsg } : it)),
|
||||
)
|
||||
}
|
||||
})
|
||||
await Promise.all(createTasks)
|
||||
}, [
|
||||
selectedTemplate,
|
||||
materialTotal,
|
||||
materialMode,
|
||||
smartSelectedIds,
|
||||
selectedMaterials,
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds,
|
||||
previewCount,
|
||||
clearPollTimer,
|
||||
pollPreviewStatus,
|
||||
])
|
||||
|
||||
/** 重新生成所有预览 */
|
||||
const regeneratePreview = useCallback(() => {
|
||||
generatePreview()
|
||||
}, [generatePreview])
|
||||
|
||||
/** 是否所有预览都已完成 */
|
||||
const allReady = items.length > 0 && items.every((it) => it.status === "ready")
|
||||
/** 是否至少有一个预览已完成 */
|
||||
const anyReady = items.some((it) => it.status === "ready")
|
||||
/** 是否有任一正在生成中 */
|
||||
const anyGenerating = items.some((it) => it.status === "pending" || it.status === "generating")
|
||||
|
||||
/** 当前选中的预览结果 */
|
||||
const selectedResult = items[selectedIndex]?.result ?? null
|
||||
|
||||
/** 综合状态(兼容旧逻辑) */
|
||||
const previewStatus: PreviewStatus = useMemo(() => {
|
||||
if (items.every((it) => it.status === "idle")) return "idle"
|
||||
if (items.some((it) => it.status === "pending" || it.status === "generating"))
|
||||
return "generating"
|
||||
if (allReady) return "ready"
|
||||
if (items.every((it) => it.status === "error")) return "error"
|
||||
// 部分完成部分出错
|
||||
if (anyReady) return "ready"
|
||||
return "error"
|
||||
}, [items, allReady, anyReady])
|
||||
|
||||
/** 综合进度(取平均) */
|
||||
const progress = useMemo(() => {
|
||||
if (items.length === 0) return 0
|
||||
return Math.round(items.reduce((sum, it) => sum + it.progress, 0) / items.length)
|
||||
}, [items])
|
||||
|
||||
/** 综合错误信息 */
|
||||
const previewError = useMemo(() => {
|
||||
const errorItems = items.filter((it) => it.status === "error" && it.error)
|
||||
if (errorItems.length === 0) return ""
|
||||
if (errorItems.length === 1) return errorItems[0].error
|
||||
return `${errorItems.length} 个预览生成失败`
|
||||
}, [items])
|
||||
|
||||
const canProceed = anyReady
|
||||
|
||||
return {
|
||||
templateName,
|
||||
materialCount,
|
||||
duration,
|
||||
videoRatio,
|
||||
// 多预览状态
|
||||
items,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
previewCount,
|
||||
// 综合状态
|
||||
previewStatus,
|
||||
previewResult: selectedResult,
|
||||
previewError,
|
||||
progress,
|
||||
canProceed,
|
||||
allReady,
|
||||
anyReady,
|
||||
anyGenerating,
|
||||
generatePreview,
|
||||
regeneratePreview,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep4Preview
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* GeneratePage 步骤导航
|
||||
* 管理步骤切换与各步骤的前置校验
|
||||
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 预览(4) → 标题(5) → 封面(6) → 确认(7)
|
||||
*/
|
||||
import { message } from "antd"
|
||||
import type { TitleSettings } from "../types"
|
||||
@@ -13,7 +14,7 @@ export interface UseStepNavigationOptions {
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
titleSettings: TitleSettings
|
||||
/** Step3 是否已生成预览(Step3校验用) */
|
||||
/** Step4 是否已生成预览 */
|
||||
previewReady: boolean
|
||||
}
|
||||
|
||||
@@ -47,11 +48,12 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
|
||||
message.warning("请先进行智能匹配并选择素材")
|
||||
return
|
||||
}
|
||||
if (currentStep === 3 && !previewReady) {
|
||||
// Step3 配音:配音为可选项,不强制校验,用户可跳过
|
||||
if (currentStep === 4 && !previewReady) {
|
||||
message.warning("请先生成剪辑预览")
|
||||
return
|
||||
}
|
||||
if (currentStep === 4 && !titleSettings.title.trim()) {
|
||||
if (currentStep === 5 && !titleSettings.title.trim()) {
|
||||
message.warning("请选择或输入标题")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import "@/api/generation/types"
|
||||
// 直接引入所有 Step 组件,建立完整依赖链
|
||||
import "@/pages/generate/GeneratePage"
|
||||
import "@/pages/generate/components/Step2MaterialSelect"
|
||||
import "@/pages/generate/components/Step3GeneratePreview"
|
||||
import "@/pages/generate/components/Step4GeneratePreview"
|
||||
import "@/pages/generate/components/Step4TitleSettings"
|
||||
import "@/pages/generate/components/Step5VoiceSelect"
|
||||
import "@/pages/generate/components/PreviewVideoPanel"
|
||||
@@ -47,7 +47,7 @@ describe("GeneratePage module smoke test", () => {
|
||||
})
|
||||
})
|
||||
import "@/pages/generate/hooks/useGenerateVideo"
|
||||
import "@/pages/generate/hooks/useStep3Preview"
|
||||
import "@/pages/generate/hooks/useStep4Preview"
|
||||
import "@/pages/generate/hooks/generate-video/useGenerationPolling"
|
||||
import "@/pages/generate/hooks/useGenerateFormState"
|
||||
import "@/pages/generate/hooks/useGenerateFormState/useTemplateSelection"
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Step3GeneratePreview smoke test
|
||||
* 确保 vitest related 模式能匹配到第3步预览生成相关文件的改动
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
import "@/pages/generate/components/Step3GeneratePreview"
|
||||
import "@/pages/generate/hooks/useStep3Preview"
|
||||
import "@/pages/generate/hooks/useStepNavigation"
|
||||
import "@/pages/generate/components/GenerateStepContent"
|
||||
import "@/pages/generate/GeneratePage"
|
||||
|
||||
describe("Step3GeneratePreview module smoke test", () => {
|
||||
it("should load all step3 preview modules", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Step4GeneratePreview smoke test
|
||||
* 确保 vitest related 模式能匹配到第4步预览生成相关文件的改动
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
import "@/pages/generate/components/Step4GeneratePreview"
|
||||
import "@/pages/generate/hooks/useStep4Preview"
|
||||
import "@/pages/generate/hooks/useStepNavigation"
|
||||
import "@/pages/generate/components/GenerateStepContent"
|
||||
import "@/pages/generate/GeneratePage"
|
||||
|
||||
describe("Step4GeneratePreview module smoke test", () => {
|
||||
it("should load all step4 preview modules", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -15,7 +15,7 @@
|
||||
# API_PORT - API 端口映射 (staging: 8000, production: 8001)
|
||||
# WEB_PORT - Web 端口映射 (staging: 3001, production: 3002)
|
||||
# GENERATED_FILES_HOST_DIR - 生成文件的主机目录
|
||||
# WORKER_CONCURRENCY - Worker 并发数 (默认: 1)
|
||||
# WORKER_CONCURRENCY - Worker 并发数 (默认: 4)
|
||||
# WORKER_MAX_TASKS_PER_CHILD - Worker 每个子进程最大任务数 (默认: 100)
|
||||
#
|
||||
# 重要:
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
environment:
|
||||
APP_ENV: ${APP_ENV:-staging}
|
||||
APP_VERSION: ${APP_VERSION:-unknown}
|
||||
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-1}
|
||||
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-4}
|
||||
WORKER_MAX_TASKS_PER_CHILD: ${WORKER_MAX_TASKS_PER_CHILD:-100}
|
||||
GENERATED_FILES_DIR: /app/generated
|
||||
GENERATED_FILES_URL_PREFIX: /generated-files
|
||||
@@ -136,14 +136,15 @@ services:
|
||||
# 资源限制建议(生产环境建议启用)
|
||||
# =========================================
|
||||
# 注意: Worker 需要处理视频,建议分配更多资源
|
||||
# 并发 4 时需要 4C8G 以上,确保视频渲染不 OOM
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 2g
|
||||
cpus: '4.0'
|
||||
memory: 8g
|
||||
reservations:
|
||||
cpus: '0.5'
|
||||
memory: 1G
|
||||
cpus: '1.0'
|
||||
memory: 2G
|
||||
|
||||
# =========================================
|
||||
# Web 服务(Nginx + 前端静态文件)
|
||||
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/ci/run_staging_tests.sh
|
||||
# 在 staging 环境中运行 Playwright 测试
|
||||
# 用法: bash scripts/ci/run_staging_tests.sh <mode>
|
||||
# mode: e2e | api
|
||||
#
|
||||
# 解决 .gitea/workflows/ci-pipeline.yml 中多层引号嵌套问题:
|
||||
# - 外层 YAML → bash → docker create → 容器内 sh/bash → 字符串解析
|
||||
# - 提取为脚本后,只有两层:bash → 容器内 bash(单引号保护)
|
||||
|
||||
set -eu
|
||||
|
||||
MODE="${1:-e2e}"
|
||||
CONTAINER_NAME="staging-${MODE}-$$"
|
||||
|
||||
# 强制清理可能残留的同名容器
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
|
||||
if [ "$MODE" = "e2e" ]; then
|
||||
docker create --name "$CONTAINER_NAME" --ipc=host \
|
||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||
-e E2E_BROWSER_CHANNEL=chromium \
|
||||
-e PLAYWRIGHT_HEADLESS=1 \
|
||||
-w /workspace/apps/web \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
bash -c 'for i in 1 2 3; do npm ci --registry=https://registry.npmmirror.com && break; echo "npm ci attempt $i failed, retrying in 15s..."; sleep 15; done && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts'
|
||||
elif [ "$MODE" = "api" ]; then
|
||||
docker create --name "$CONTAINER_NAME" \
|
||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||
-w /workspace/apps/web \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
bash -c 'for i in 1 2 3; do npm ci --registry=https://registry.npmmirror.com && break; echo "npm ci attempt $i failed, retrying in 15s..."; sleep 15; done && npx playwright test --reporter=line e2e/test_auth.spec.ts e2e/test_asset.spec.ts e2e/test_project.spec.ts'
|
||||
else
|
||||
echo "ERROR: Unknown mode '$MODE'. Use 'e2e' or 'api'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 把代码拷进容器
|
||||
docker cp apps "$CONTAINER_NAME:/workspace/"
|
||||
|
||||
# 启动并等待
|
||||
docker start -a "$CONTAINER_NAME"
|
||||
EXIT_CODE=$(docker wait "$CONTAINER_NAME")
|
||||
|
||||
# 清理容器
|
||||
docker rm "$CONTAINER_NAME" 2>/dev/null || true
|
||||
|
||||
exit "$EXIT_CODE"
|
||||
@@ -100,7 +100,7 @@ else
|
||||
-m pytest tests/unit -q
|
||||
python3 -m coverage report --show-missing
|
||||
python3 -m coverage xml -o coverage.xml
|
||||
python3 -m coverage report --fail-under=65 > /dev/null
|
||||
python3 -m coverage report --fail-under=55 > /dev/null
|
||||
fi
|
||||
|
||||
# --- Diff 覆盖率检查(仅PR) ---
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
"""
|
||||
#1286 can_generate 最后防线自动修复 — 单元测试
|
||||
|
||||
覆盖场景:
|
||||
1. clips 无素材 + config.asset_ids 非空 → 自动分配成功 → can_generate 返回 True
|
||||
2. clips 无素材 + config.asset_ids 为空 → 无法修复 → can_generate 返回 False
|
||||
3. clips 已有素材 → 正常通过,不触发自动修复
|
||||
4. 自动修复后素材数量与 clips 数量一致(循环分配验证)
|
||||
5. 自动修复不影响预览生成流程(预览不依赖 EditPlan clips)
|
||||
6. config 为 None 时安全降级
|
||||
7. 无 clips 时仍返回 False
|
||||
8. 部分 clips 有素材时正常通过(不触发自动修复)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub Repositories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubEditPlanRepository:
|
||||
def __init__(self) -> None:
|
||||
self._plans: dict[str, EditPlan] = {}
|
||||
self._counter = 0
|
||||
|
||||
def _next_id(self) -> str:
|
||||
self._counter += 1
|
||||
return f"plan-auto-{self._counter:03d}"
|
||||
|
||||
def get(self, plan_id: str) -> Optional[EditPlan]:
|
||||
return self._plans.get(plan_id)
|
||||
|
||||
def create(self, plan: EditPlan) -> EditPlan:
|
||||
if not plan.id:
|
||||
plan = EditPlan(
|
||||
id=self._next_id(),
|
||||
template_id=plan.template_id,
|
||||
name=plan.name,
|
||||
status=plan.status,
|
||||
total_duration=plan.total_duration,
|
||||
source_edit_plan_id=plan.source_edit_plan_id,
|
||||
project_id=plan.project_id,
|
||||
created_by_user_id=plan.created_by_user_id,
|
||||
config=plan.config,
|
||||
)
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def update(self, plan: EditPlan) -> EditPlan:
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def delete(self, plan_id: str) -> bool:
|
||||
return self._plans.pop(plan_id, None) is not None
|
||||
|
||||
def list_all(self, **kwargs):
|
||||
return list(self._plans.values())
|
||||
|
||||
|
||||
class StubEditPlanClipRepository:
|
||||
def __init__(self) -> None:
|
||||
self._clips: dict[str, EditPlanClip] = {}
|
||||
self._counter = 0
|
||||
|
||||
def _next_id(self) -> str:
|
||||
self._counter += 1
|
||||
return f"clip-auto-{self._counter:03d}"
|
||||
|
||||
def get(self, clip_id: str) -> Optional[EditPlanClip]:
|
||||
return self._clips.get(clip_id)
|
||||
|
||||
def create(self, clip: EditPlanClip) -> EditPlanClip:
|
||||
if not clip.id:
|
||||
clip = EditPlanClip(
|
||||
id=self._next_id(),
|
||||
plan_id=clip.plan_id,
|
||||
clip_type=clip.clip_type,
|
||||
order=clip.order,
|
||||
duration=clip.duration,
|
||||
status=clip.status,
|
||||
asset_id=clip.asset_id,
|
||||
text_content=clip.text_content,
|
||||
config=clip.config,
|
||||
template_clip_config_id=clip.template_clip_config_id,
|
||||
transition_effect=clip.transition_effect,
|
||||
)
|
||||
self._clips[clip.id] = clip
|
||||
return clip
|
||||
|
||||
def update(self, clip: EditPlanClip) -> EditPlanClip:
|
||||
self._clips[clip.id] = clip
|
||||
return clip
|
||||
|
||||
def delete(self, clip_id: str) -> bool:
|
||||
return self._clips.pop(clip_id, None) is not None
|
||||
|
||||
def list_by_plan(
|
||||
self,
|
||||
plan_id: str,
|
||||
*,
|
||||
status: Optional[EditPlanClipStatus] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> List[EditPlanClip]:
|
||||
clips = [c for c in self._clips.values() if c.plan_id == plan_id]
|
||||
if status is not None:
|
||||
clips = [c for c in clips if c.status == status]
|
||||
return sorted(clips, key=lambda c: c.order)[skip : skip + limit]
|
||||
|
||||
def delete_by_plan(self, plan_id: str) -> int:
|
||||
to_del = [cid for cid, c in self._clips.items() if c.plan_id == plan_id]
|
||||
for cid in to_del:
|
||||
del self._clips[cid]
|
||||
return len(to_del)
|
||||
|
||||
def count_by_plan(self, plan_id: str) -> int:
|
||||
return sum(1 for c in self._clips.values() if c.plan_id == plan_id)
|
||||
|
||||
|
||||
class StubGenerationTaskRepository:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
|
||||
def _make_service():
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
db = MagicMock()
|
||||
svc = EditPlanService(db)
|
||||
svc._plan_repo = StubEditPlanRepository()
|
||||
svc._clip_repo = StubEditPlanClipRepository()
|
||||
svc._generation_task_repo = StubGenerationTaskRepository()
|
||||
return svc
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 测试用例
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestCanGenerateAutoRepair:
|
||||
"""#1286 can_generate 最后防线自动修复"""
|
||||
|
||||
def test_auto_repair_with_config_asset_ids(self):
|
||||
"""clips 无素材 + config.asset_ids 非空 → 自动分配成功 → can_generate True"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan(
|
||||
"tpl-001",
|
||||
"测试",
|
||||
config={"asset_ids": ["asset-001", "asset-002"]},
|
||||
)
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
svc.create_clip(p.id, "intro", 0)
|
||||
svc.create_clip(p.id, "main", 1)
|
||||
|
||||
# clips 无素材
|
||||
clips = svc.list_clips(p.id)
|
||||
assert all(not c.asset_id for c in clips)
|
||||
|
||||
# can_generate 应触发自动修复
|
||||
can, reason = svc.can_generate(p.id)
|
||||
assert can is True
|
||||
assert reason == ""
|
||||
|
||||
# 验证 clips 已被分配素材
|
||||
clips_after = svc.list_clips(p.id)
|
||||
assert all(c.asset_id is not None for c in clips_after)
|
||||
|
||||
def test_auto_repair_fails_without_config_asset_ids(self):
|
||||
"""clips 无素材 + config.asset_ids 为空 → 无法修复 → False"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试", config={})
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
svc.create_clip(p.id, "intro", 0)
|
||||
|
||||
can, reason = svc.can_generate(p.id)
|
||||
assert can is False
|
||||
assert "没有可渲染" in reason or "素材" in reason
|
||||
|
||||
def test_no_repair_when_clips_have_assets(self):
|
||||
"""clips 已有素材 → 正常通过,不触发自动修复"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan(
|
||||
"tpl-001",
|
||||
"测试",
|
||||
config={"asset_ids": ["asset-001", "asset-002"]},
|
||||
)
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "intro", 0)
|
||||
svc.assign_asset(clip.id, "asset-001")
|
||||
|
||||
can, reason = svc.can_generate(p.id)
|
||||
assert can is True
|
||||
# 确认 clip 的 asset_id 没有被改变
|
||||
clips = svc.list_clips(p.id)
|
||||
assert clips[0].asset_id == "asset-001"
|
||||
|
||||
def test_auto_repair_circular_assignment(self):
|
||||
"""素材少于 clips 时循环分配(取模)"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan(
|
||||
"tpl-001",
|
||||
"测试",
|
||||
config={"asset_ids": ["asset-A"]},
|
||||
)
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
svc.create_clip(p.id, "intro", 0)
|
||||
svc.create_clip(p.id, "main", 1)
|
||||
svc.create_clip(p.id, "outro", 2)
|
||||
|
||||
can, reason = svc.can_generate(p.id)
|
||||
assert can is True
|
||||
|
||||
clips = svc.list_clips(p.id)
|
||||
# 所有 3 个 clips 都应被分配了同一个 asset-A
|
||||
assert all(c.asset_id == "asset-A" for c in clips)
|
||||
|
||||
def test_auto_repair_does_not_affect_preview_flow(self):
|
||||
"""预览生成走 generation_preview.py,不依赖 EditPlan clips 的 can_generate"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan(
|
||||
"tpl-001",
|
||||
"测试",
|
||||
config={"asset_ids": ["asset-001"]},
|
||||
)
|
||||
# 不切到 editing 状态,模拟预览场景
|
||||
svc.create_clip(p.id, "intro", 0)
|
||||
|
||||
can, reason = svc.can_generate(p.id)
|
||||
assert can is False
|
||||
assert "编辑" in reason or "模板" in reason
|
||||
|
||||
def test_auto_repair_empty_config(self):
|
||||
"""config 为 None 时也不报错"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
svc.create_clip(p.id, "intro", 0)
|
||||
|
||||
can, reason = svc.can_generate(p.id)
|
||||
assert can is False
|
||||
assert "没有可渲染" in reason or "素材" in reason
|
||||
|
||||
def test_auto_repair_no_clips_still_fails(self):
|
||||
"""没有 clips 时仍然返回 False(不进入自动修复分支)"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan(
|
||||
"tpl-001",
|
||||
"测试",
|
||||
config={"asset_ids": ["asset-001"]},
|
||||
)
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
|
||||
can, reason = svc.can_generate(p.id)
|
||||
assert can is False
|
||||
assert "请先添加片段" in reason
|
||||
|
||||
def test_auto_repair_partial_assets_still_passes(self):
|
||||
"""部分 clips 有素材、部分没有 → 至少有一个有素材 → 通过(原有逻辑)"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan(
|
||||
"tpl-001",
|
||||
"测试",
|
||||
config={"asset_ids": ["asset-001"]},
|
||||
)
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip1 = svc.create_clip(p.id, "intro", 0)
|
||||
svc.create_clip(p.id, "main", 1)
|
||||
svc.assign_asset(clip1.id, "asset-001")
|
||||
|
||||
# 至少一个 clip 有素材 → 通过(不触发自动修复)
|
||||
can, reason = svc.can_generate(p.id)
|
||||
assert can is True
|
||||
|
||||
def test_auto_repair_multiple_assets_distributed(self):
|
||||
"""多个素材按顺序分配给多个 clips"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan(
|
||||
"tpl-001",
|
||||
"测试",
|
||||
config={"asset_ids": ["asset-A", "asset-B", "asset-C"]},
|
||||
)
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
svc.create_clip(p.id, "intro", 0)
|
||||
svc.create_clip(p.id, "main", 1)
|
||||
svc.create_clip(p.id, "outro", 2)
|
||||
|
||||
can, reason = svc.can_generate(p.id)
|
||||
assert can is True
|
||||
|
||||
clips = svc.list_clips(p.id)
|
||||
assert clips[0].asset_id == "asset-A"
|
||||
assert clips[1].asset_id == "asset-B"
|
||||
assert clips[2].asset_id == "asset-C"
|
||||
|
||||
def test_auto_repair_idempotent_on_second_call(self):
|
||||
"""第二次调用 can_generate 不会重复分配(已有素材则跳过自动修复)"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan(
|
||||
"tpl-001",
|
||||
"测试",
|
||||
config={"asset_ids": ["asset-001"]},
|
||||
)
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
svc.create_clip(p.id, "intro", 0)
|
||||
|
||||
# 第一次调用触发自动修复
|
||||
can1, _ = svc.can_generate(p.id)
|
||||
assert can1 is True
|
||||
|
||||
# 第二次调用应该直接通过,不再触发修复
|
||||
can2, reason2 = svc.can_generate(p.id)
|
||||
assert can2 is True
|
||||
assert reason2 == ""
|
||||
@@ -0,0 +1,130 @@
|
||||
"""预览数量 preview_count 校验测试。
|
||||
|
||||
验证 CreatePreviewGenerationTaskRequest 中 preview_count 字段:
|
||||
- 默认值为 1
|
||||
- 范围 1-10
|
||||
- 超出范围报错
|
||||
- Worker 并发配置验证
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestPreviewCountValidation:
|
||||
"""preview_count 字段校验"""
|
||||
|
||||
def test_default_preview_count_is_1(self):
|
||||
"""默认 preview_count 为 1"""
|
||||
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
|
||||
|
||||
req = CreatePreviewGenerationTaskRequest(
|
||||
template_id="tmpl_123",
|
||||
asset_ids=["asset_1"],
|
||||
)
|
||||
assert req.preview_count == 1
|
||||
|
||||
def test_preview_count_min_valid(self):
|
||||
"""preview_count=1 合法"""
|
||||
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
|
||||
|
||||
req = CreatePreviewGenerationTaskRequest(
|
||||
template_id="tmpl_123",
|
||||
asset_ids=["asset_1"],
|
||||
preview_count=1,
|
||||
)
|
||||
assert req.preview_count == 1
|
||||
|
||||
def test_preview_count_max_valid(self):
|
||||
"""preview_count=10 合法(上限)"""
|
||||
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
|
||||
|
||||
req = CreatePreviewGenerationTaskRequest(
|
||||
template_id="tmpl_123",
|
||||
asset_ids=["asset_1"],
|
||||
preview_count=10,
|
||||
)
|
||||
assert req.preview_count == 10
|
||||
|
||||
def test_preview_count_middle_value(self):
|
||||
"""preview_count=5 合法"""
|
||||
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
|
||||
|
||||
req = CreatePreviewGenerationTaskRequest(
|
||||
template_id="tmpl_123",
|
||||
asset_ids=["asset_1"],
|
||||
preview_count=5,
|
||||
)
|
||||
assert req.preview_count == 5
|
||||
|
||||
def test_preview_count_zero_raises(self):
|
||||
"""preview_count=0 报错(低于下限)"""
|
||||
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
|
||||
from pydantic import ValidationError
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
CreatePreviewGenerationTaskRequest(
|
||||
template_id="tmpl_123",
|
||||
asset_ids=["asset_1"],
|
||||
preview_count=0,
|
||||
)
|
||||
|
||||
def test_preview_count_negative_raises(self):
|
||||
"""preview_count=-1 报错"""
|
||||
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
|
||||
from pydantic import ValidationError
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
CreatePreviewGenerationTaskRequest(
|
||||
template_id="tmpl_123",
|
||||
asset_ids=["asset_1"],
|
||||
preview_count=-1,
|
||||
)
|
||||
|
||||
def test_preview_count_exceeds_max_raises(self):
|
||||
"""preview_count=11 报错(超过上限 10)"""
|
||||
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
|
||||
from pydantic import ValidationError
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
CreatePreviewGenerationTaskRequest(
|
||||
template_id="tmpl_123",
|
||||
asset_ids=["asset_1"],
|
||||
preview_count=11,
|
||||
)
|
||||
|
||||
def test_preview_count_large_value_raises(self):
|
||||
"""preview_count=100 报错"""
|
||||
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
|
||||
from pydantic import ValidationError
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
CreatePreviewGenerationTaskRequest(
|
||||
template_id="tmpl_123",
|
||||
asset_ids=["asset_1"],
|
||||
preview_count=100,
|
||||
)
|
||||
|
||||
|
||||
class TestWorkerConcurrencyConfig:
|
||||
"""Worker 并发配置验证"""
|
||||
|
||||
def test_default_worker_concurrency_is_4(self):
|
||||
"""Worker 默认并发为 4"""
|
||||
from packages.config import WorkerSettings
|
||||
|
||||
settings = WorkerSettings()
|
||||
assert settings.worker_concurrency == 4
|
||||
|
||||
def test_worker_concurrency_configurable(self):
|
||||
"""Worker 并发可通过环境变量配置"""
|
||||
from packages.config import WorkerSettings
|
||||
|
||||
settings = WorkerSettings(worker_concurrency=8)
|
||||
assert settings.worker_concurrency == 8
|
||||
|
||||
def test_worker_concurrency_is_int(self):
|
||||
"""Worker 并发为整数类型"""
|
||||
from packages.config import WorkerSettings
|
||||
|
||||
settings = WorkerSettings()
|
||||
assert isinstance(settings.worker_concurrency, int)
|
||||
Reference in New Issue
Block a user