Compare commits

...

3 Commits

Author SHA1 Message Date
xiaoxia f9daa08b2e feat(generate): #1970 智能剪辑流程重构 - 选择模式→素材→标题→确认→封面 (#1973)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
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 / Build Staging Web Image (push) Successful in 31s
CI/CD Pipeline / Build Staging API Image (push) Successful in 40s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m46s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m29s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m1s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 36s
CI/CD Pipeline / Integration Tests (push) Successful in 4m53s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m36s
CI/CD Pipeline / Validate - Style (push) Failing after 5m37s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 6m10s
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
2026-09-18 00:23:19 +08:00
CI Bot 66409fde6f style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 2s
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 API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 34s
CI/CD Pipeline / Build Staging API Image (push) Successful in 36s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 20s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m21s
CI/CD Pipeline / Integration Tests (push) Successful in 4m56s
CI/CD Pipeline / Validate - Style (push) Failing after 5m35s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 6m6s
CI/CD Pipeline / Unit Tests (push) Successful in 11m6s
CI/CD Pipeline / Validate - Security (push) Successful in 14m32s
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 / Deploy Staging (Watchtower auto-deploy) (push) Successful in 14m58s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m19s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m38s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m46s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-09-17 16:00:35 +00:00
xiaoxia 3c016af076 fix(douyin): 本地ASR不可用时正确降级到desc兜底,避免502直接抛出
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
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 / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 48s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 24s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 19s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m38s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 2m12s
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
- script_asr_service.py: 捕获ImportError(apps.worker未安装),抛ASRNotConfiguredError
- scripts_ai.py B2回退路径: 504超时直接抛出,502/503 ASR/下载错误走desc兜底
- 修复API镜像未打包worker模块导致有旁白视频在MediaKit失败时直接502的问题
- 更新单元测试覆盖ASR失败→desc兜底场景
2026-09-17 23:48:22 +08:00
17 changed files with 1655 additions and 99 deletions
+12 -2
View File
@@ -443,10 +443,20 @@ def extract_from_douyin(
result.source, len(text), time.time() - t0,
)
last_err_stage = "asr"
except HTTPException:
raise
except HTTPException as exc:
# 下载超时(504)是明确的网络错误,直接抛出
if exc.status_code == status.HTTP_504_GATEWAY_TIMEOUT:
raise
# 本地 ASR 不可用/失败(502/503)时记录后继续走 desc 兜底,
# 不直接抛 502,避免 API 镜像缺 worker 模块时整条链路挂掉
logger.warning("本地 ASR 链路失败(status=%d): %s", exc.status_code, exc.detail)
text = ""
# 如果是下载失败(非ASR错误),保持stage为download
if "语音识别" in str(exc.detail) or "ASR" in str(exc.detail):
last_err_stage = "asr"
except Exception as exc: # noqa: BLE001
logger.warning("本地 ASR 链路异常: %s", exc)
text = ""
# ── Phase C:结果判定 & 兜底 ──
+6 -1
View File
@@ -38,7 +38,12 @@ def transcribe_to_text(media_path: str | Path) -> str:
ASRTranscriptionError: ASR 调用失败
"""
# 延迟导入,避免循环依赖和启动时副作用
from apps.worker.services.asr_service_factory import get_asr_service
try:
from apps.worker.services.asr_service_factory import get_asr_service
except ImportError as exc:
# API 镜像未打包 worker 代码(本地 ASR 依赖 worker 的 asr_service_factory
logger.warning("本地 ASR 不可用(apps.worker 未安装): %s", exc)
raise ASRNotConfiguredError("本地 ASR 服务不可用(worker 模块未安装)") from exc
asr = get_asr_service()
if asr is None:
+10
View File
@@ -71,6 +71,16 @@ export interface CreateGenerationTaskRequest {
duration?: number
/** 视频宽高比,如 "9:16" */
video_ratio?: string
/** #1970:剪辑模式 random/narrative */
assembly_mode?: "random" | "narrative"
/** #1970:叙事模式下的文案 ID */
script_id?: string
/** #1970TTS 音色 ID */
tts_voice_id?: string
/** #1970TTS 音色来源 preset/clone */
tts_voice_source?: "preset" | "clone"
/** #1970:智能降重开关(默认 true) */
dedup_enabled?: boolean
/** 标题烧录配置 */
title_config?: {
text?: string
+123 -3
View File
@@ -11,6 +11,9 @@ import type { VoiceClone } from "@/api/voice-clone"
import { useQuery } from "@tanstack/react-query"
import { useCloneProgress } from "@/hooks/useCloneProgress"
import CloneModal from "@/components/voice/CloneModal"
import VoiceSelectModal from "./components/VoiceSelectModal"
import ScriptSelectModal from "./components/ScriptSelectModal"
import TtsVoiceModal from "./components/TtsVoiceModal"
import GenerateHeader from "./components/GenerateHeader"
import FrontendPreviewPlayer from "./components/FrontendPreviewPlayer"
import CanvasPreviewGrid from "./components/CanvasPreviewGrid"
@@ -62,11 +65,26 @@ const GeneratePage: React.FC = () => {
selectedVoice,
setSelectedVoice,
voiceMode,
setVoiceMode,
selectedClonedVoice,
setSelectedClonedVoice,
editMode,
setEditMode,
selectedScript,
setSelectedScript,
ttsVoiceId,
setTtsVoiceId,
ttsVoiceSource,
setTtsVoiceSource,
ttsVoiceAssetId,
setTtsVoiceAssetId,
dedupEnabled,
setDedupEnabled,
cloneModalOpen,
setCloneModalOpen,
videoRatio,
setVideoRatio,
duration,
style,
autoSubtitles,
@@ -124,6 +142,11 @@ const GeneratePage: React.FC = () => {
/* ── 数量选择弹窗 ── */
const [countModalOpen, setCountModalOpen] = useState(false)
/* ── #1970 流程重构:分支弹窗 ── */
const [voiceModalOpen, setVoiceModalOpen] = useState(false)
const [scriptModalOpen, setScriptModalOpen] = useState(false)
const [ttsModalOpen, setTtsModalOpen] = useState(false)
/* ── 标题样式回调 ── */
const styleUpdaters = useTitleStyleUpdaters({
titleSettings,
@@ -301,6 +324,12 @@ const GeneratePage: React.FC = () => {
selectedClonedVoice,
coverSettings,
videoRatio,
editMode,
selectedScript,
ttsVoiceId,
ttsVoiceSource,
ttsVoiceAssetId,
dedupEnabled,
style,
duration,
autoSubtitles,
@@ -340,7 +369,7 @@ const GeneratePage: React.FC = () => {
return Array.from({ length: count }, (_, i) => list[i] ?? "")
})
setSelectedVariantIds(Array.from({ length: count }, (_, i) => i))
setCurrentStep(2)
setCurrentStep(3)
},
[
setPreviewCount,
@@ -354,6 +383,58 @@ const GeneratePage: React.FC = () => {
],
)
/* ── #1970Step1 弹窗回调 ── */
const handleVoiceModalConfirm = useCallback(
(voiceAssetId: string) => {
setSelectedVoice(voiceAssetId)
setVoiceMode("custom")
setVoiceModalOpen(false)
setCurrentStep(2)
},
[setSelectedVoice, setVoiceMode, setCurrentStep],
)
const handleScriptModalConfirm = useCallback(
(script: import("@/api/scripts").ScriptItem) => {
setSelectedScript(script)
// 自动带入标题(若标题为空则预填)
if (!titleSettings.title?.trim() && script.title) {
setTitleSettings((prev) => ({ ...prev, title: script.title, aiAutoSelect: false }))
}
setScriptModalOpen(false)
// 自动打开 TTS 弹窗
setTtsModalOpen(true)
},
[setSelectedScript, setTitleSettings, titleSettings.title],
)
const handleTtsSynthesized = useCallback(
(payload: { voiceAssetId: string; ttsVoiceId: string; ttsVoiceSource: "preset" | "clone" }) => {
setTtsVoiceId(payload.ttsVoiceId)
setTtsVoiceSource(payload.ttsVoiceSource)
setTtsVoiceAssetId(payload.voiceAssetId)
if (payload.ttsVoiceSource === "clone") {
setSelectedClonedVoice(payload.ttsVoiceId)
setVoiceMode("clone")
} else {
setSelectedVoice(payload.ttsVoiceId)
setVoiceMode("preset")
}
setTtsModalOpen(false)
message.success("配音合成成功")
setCurrentStep(2)
},
[
setTtsVoiceId,
setTtsVoiceSource,
setTtsVoiceAssetId,
setSelectedVoice,
setSelectedClonedVoice,
setVoiceMode,
setCurrentStep,
],
)
/* ── 步骤3「确认生成视频」:校验通过 → 创建正式生成任务 → 跳步骤4看实时进展 ── */
const handleConfirmGenerate = useCallback(async () => {
// 积分预检查
@@ -412,12 +493,20 @@ const GeneratePage: React.FC = () => {
const { goNext, goPrev } = useStepNavigation({
currentStep,
setCurrentStep,
editMode,
materialMode,
selectedMaterials,
smartSelectedIds,
titleSettings,
generated,
onOpenCountModal: () => setCountModalOpen(true),
onOpenStep1Modal: () => {
if (editMode === "random") {
setVoiceModalOpen(true)
} else {
setScriptModalOpen(true)
}
},
})
/* ── 最终成片 ── */
@@ -462,7 +551,7 @@ const GeneratePage: React.FC = () => {
{!isBatch ? (
<FrontendPreviewPlayer
assets={previewAssets}
videoRatio={videoRatio}
videoRatio={videoRatio as "9:16" | "16:9"}
ready={previewAssets.length > 0}
serverClips={serverClips}
voiceAudioUrl={previewVoiceAudioUrl || undefined}
@@ -497,7 +586,7 @@ const GeneratePage: React.FC = () => {
<CanvasPreviewGrid
count={previewCount}
assets={previewAssets}
videoRatio={videoRatio}
videoRatio={videoRatio as "9:16" | "16:9"}
titles={previewTitles}
titleSettings={titleSettings}
voiceAudioUrls={variantVoiceAudioUrls}
@@ -545,6 +634,16 @@ const GeneratePage: React.FC = () => {
coverSettings={coverSettings}
onCoverSettingsChange={setCoverSettings}
selectedVoice={selectedVoice}
editMode={editMode}
onEditModeChange={setEditMode}
dedupEnabled={dedupEnabled}
onDedupEnabledChange={setDedupEnabled}
onPreviewCountChange={setPreviewCount}
videoRatio={videoRatio as "9:16" | "16:9"}
onVideoRatioChange={(r) => setVideoRatio(r)}
selectedScript={selectedScript}
ttsVoiceId={ttsVoiceId}
ttsVoiceSource={ttsVoiceSource}
onSelectedVoiceChange={setSelectedVoice}
onServerClipsChange={setServerClips}
generating={generating}
@@ -671,6 +770,27 @@ const GeneratePage: React.FC = () => {
onClose={() => setCloneModalOpen(false)}
onSuccess={handleCloneSuccess}
/>
{/* #1970 流程弹窗 */}
<VoiceSelectModal
open={voiceModalOpen}
selectedVoice={selectedVoice}
onCancel={() => setVoiceModalOpen(false)}
onConfirm={handleVoiceModalConfirm}
/>
<ScriptSelectModal
open={scriptModalOpen}
selectedScriptId={selectedScript?.id ?? null}
onCancel={() => setScriptModalOpen(false)}
onConfirm={handleScriptModalConfirm}
/>
<TtsVoiceModal
open={ttsModalOpen}
scriptText={selectedScript?.content ?? ""}
scriptTitle={selectedScript?.title ?? ""}
onCancel={() => setTtsModalOpen(false)}
onSynthesized={handleTtsSynthesized}
/>
</div>
)
}
@@ -1,14 +1,16 @@
/**
* GeneratePage 步骤内容渲染(#1899 简化为 5 步,#1913 传递 selectedTemplate
* 步骤顺序:素材(1) → 配音(2) → 标题(3) → 确认生成(4) → 封面(5)
* 步骤3预览(Canvas 网格)与步骤4进度(批量渲染网格)由 GeneratePage 直接渲染在左侧大区域
* GeneratePage 步骤内容渲染(#1970 流程重构
* 步骤顺序:选择模式(1) → 选择素材(2) → 选择标题(3) → 确认生成(4) → 选择封面(5)
* 步骤"选择配音"已从主流程移除,改为 Step1 下一步分支弹窗(VoiceSelectModal / ScriptSelectModal → TtsVoiceModal
*/
import React from "react"
import type { EditPlanClip } from "@/api/template-editor"
import type { CoverConfig } from "../types/cover"
import type { TitleSettings } from "../types"
import type { ScriptItem } from "@/api/scripts"
import Step1EditMode from "./Step1EditMode"
import type { EditMode } from "./Step1EditMode"
import Step2MaterialSelect from "../components/Step2MaterialSelect"
import Step3VoiceWithMode from "./Step3VoiceWithMode"
import Step4TitleSettings from "../components/Step4TitleSettings"
import Step6CoverSettings from "../components/Step6CoverSettings"
import BatchGenerationGrid from "./BatchGenerationGrid"
@@ -17,19 +19,28 @@ import type { GeneratedVideo } from "@/api/template-editor"
export interface GenerateStepContentProps {
currentStep: number
/* 片段数量(#1899 */
/* Step1:剪辑模式 + 生成设置 */
editMode: EditMode
onEditModeChange: (m: EditMode) => void
dedupEnabled: boolean
onDedupEnabledChange: (v: boolean) => void
/* ── 片段数量(#1899) ── */
clipCount: number
onClipCountChange: (n: number) => void
/* 素材 */
/* ── 生成数量/比例(Step1 设置) ── */
previewCount: number
onPreviewCountChange: (n: number) => void
videoRatio: "9:16" | "16:9"
onVideoRatioChange: (r: "9:16" | "16:9") => void
/* ── 素材 ── */
materialMode: "manual" | "auto"
onMaterialModeChange: (mode: "manual" | "auto") => void
selectedMaterials: string[]
onSelectedMaterialsChange: (ids: string[]) => void
smartSelectedIds: string[]
onSmartSelectedIdsChange: (ids: string[]) => void
/* 当前选中的模板/草稿 ID;空串时由后端自动兜底(#1913) */
selectedTemplate?: string
/* 标题 */
/* ── 标题 ── */
titleSettings: TitleSettings
onTitleSettingsChange: (settings: TitleSettings) => void
onUpdatePosition: (position: string) => void
@@ -42,14 +53,14 @@ export interface GenerateStepContentProps {
onApplyPreset: (presetKey: string) => void
activePreset: string | null
titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[]
/* 封面 */
/* ── 封面 ── */
coverSettings: CoverConfig
onCoverSettingsChange: (settings: CoverConfig) => void
/* 配音 */
/* ── 配音 ── */
selectedVoice: string
onSelectedVoiceChange: (id: string) => void
onServerClipsChange: (clips: EditPlanClip[]) => void
/* 生成 */
/* ── 生成 ── */
generating: boolean
generated: boolean
generateError: string | null
@@ -58,14 +69,10 @@ export interface GenerateStepContentProps {
onRetry: () => void
onRetryBatchTask: (taskId: string) => void
onDismissError: () => void
/** 批量:每个正式生成任务的独立状态(步骤4进度网格) */
batchTasks: BatchTaskState[]
/** BGM 开关 */
bgm: boolean
/** BGM 配置 */
bgmConfig?: { enabled: boolean; music_id?: string }
/* ── 批量生成#1677── */
previewCount: number
/* ── 批量生成 ── */
previewTitles: string[]
onPreviewTitlesChange: (titles: string[]) => void
voiceModePerVideo: boolean
@@ -74,15 +81,27 @@ export interface GenerateStepContentProps {
onVoiceLibraryIdsChange: (ids: string[]) => void
previewCovers: string[]
onPreviewCoversChange: (urls: string[]) => void
/** 批量模式勾选的变体索引 */
selectedVariantIds?: number[]
/* ── 摘要信息(#1970 Step4 展示用) ── */
selectedScript: ScriptItem | null
ttsVoiceId: string
ttsVoiceSource: "preset" | "clone"
}
export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) => {
// Only destructure props actually referenced in JSX below
const {
currentStep,
editMode,
onEditModeChange,
dedupEnabled,
onDedupEnabledChange,
clipCount,
onClipCountChange,
previewCount,
onPreviewCountChange,
videoRatio,
onVideoRatioChange,
materialMode,
onMaterialModeChange,
selectedMaterials,
@@ -104,31 +123,24 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
titlePresets,
coverSettings,
onCoverSettingsChange,
selectedVoice,
onSelectedVoiceChange,
onServerClipsChange,
generating,
generated,
generateError,
progress,
onRetry,
generatedVideos,
batchTasks,
onRetryBatchTask,
previewCount,
previewTitles,
onPreviewTitlesChange,
voiceModePerVideo,
onVoiceModePerVideoChange,
voiceLibraryIds,
onVoiceLibraryIdsChange,
previewCovers,
onPreviewCoversChange,
selectedVariantIds,
selectedScript,
ttsVoiceId,
ttsVoiceSource,
} = props
// #1913:包装 onServerClipsChange,适配 hook 的 (clips, templateId?) 签名
// 如果 hook 传回了后端兜底创建的 templateId,同时通知外层更新 selectedTemplate
const handleClipsChange = React.useCallback(
(clips: EditPlanClip[], _templateId?: string) => {
onServerClipsChange(clips)
@@ -138,8 +150,22 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
switch (currentStep) {
case 1:
return (
<Step1EditMode
editMode={editMode}
onEditModeChange={onEditModeChange}
previewCount={previewCount}
onPreviewCountChange={onPreviewCountChange}
videoRatio={videoRatio}
onVideoRatioChange={onVideoRatioChange}
dedupEnabled={dedupEnabled}
onDedupEnabledChange={onDedupEnabledChange}
/>
)
case 2:
return (
<Step2MaterialSelect
editMode={editMode}
materialMode={materialMode}
onMaterialModeChange={onMaterialModeChange}
selectedMaterials={selectedMaterials}
@@ -152,18 +178,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onServerClipsChange={handleClipsChange}
/>
)
case 2:
return (
<Step3VoiceWithMode
previewCount={previewCount}
selectedVoice={selectedVoice}
onSelectedVoiceChange={onSelectedVoiceChange}
voiceModePerVideo={voiceModePerVideo}
onVoiceModePerVideoChange={onVoiceModePerVideoChange}
voiceLibraryIds={voiceLibraryIds}
onVoiceLibraryIdsChange={onVoiceLibraryIdsChange}
/>
)
case 3:
return (
<Step4TitleSettings
@@ -185,20 +199,49 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
/>
)
case 4:
/* 确认生成页:批量=逐任务进度网格;单视频=仅渲染进度/失败状态 */
if (previewCount > 1) {
return (
<BatchGenerationGrid
tasks={batchTasks}
titles={previewTitles}
onRetryTask={onRetryBatchTask}
/>
)
}
if (generated && !generating && !generateError) return null
return (
<div className="xx-form-section">
{generating && (
{/* 配置摘要(#1970 */}
<div
style={{
padding: 14,
background: "#f9fafb",
borderRadius: 8,
marginBottom: 16,
fontSize: 13,
lineHeight: 1.8,
color: "#374151",
}}
>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 6, color: "#111" }}>
📋
</div>
<div>🎬 {editMode === "random" ? "🎲 随机混剪" : "📖 叙事剪辑"}</div>
{editMode === "random" ? (
<div>🎙 </div>
) : (
<>
<div>📝 {selectedScript?.title ?? "未选择"}</div>
<div>
🎙
{ttsVoiceId
? `${ttsVoiceSource === "clone" ? "克隆音色" : "系统音色"}${ttsVoiceId.slice(0, 8)}...`
: "未选择"}
</div>
</>
)}
<div>📱 {videoRatio}</div>
<div>🎯 {dedupEnabled ? "已开启" : "已关闭"}</div>
{previewCount > 1 && <div>📦 {previewCount} </div>}
</div>
{previewCount > 1 ? (
<BatchGenerationGrid
tasks={batchTasks}
titles={previewTitles}
onRetryTask={onRetryBatchTask}
/>
) : generating ? (
<div className="xx-gen-progress-card">
<div className="xx-gen-progress-header">
<div className="xx-gen-progress-info">
@@ -217,8 +260,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
/>
</div>
</div>
)}
{generateError && !generating && (
) : generateError ? (
<div className="xx-gen-error-card">
<div className="xx-gen-error-info">
<div className="xx-gen-error-title"></div>
@@ -228,7 +270,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
🔄
</button>
</div>
)}
) : null}
</div>
)
case 5:
@@ -0,0 +1,243 @@
/**
* 叙事剪辑 — 文案选择弹窗(#1970)
* - 搜索框:防抖 300ms,命中文字黄色高亮
* - 标签筛选行:全部/带货/工厂/测评/教程/口播/种草
* - 数量统计 + 卡片列表(可滚动,max-height 420px
* - 调用 GET /api/v1/scripts?keyword=&tag=&page_size=200
*/
import React, { useState, useEffect, useMemo, useRef, useCallback } from "react"
import { Modal, Input, Tag, Spin } from "antd"
import { SearchOutlined, CheckCircleFilled } from "@ant-design/icons"
import { useQuery } from "@tanstack/react-query"
import { getScripts } from "@/api/scripts"
import type { ScriptItem } from "@/api/scripts"
interface ScriptSelectModalProps {
open: boolean
selectedScriptId: string | null
onCancel: () => void
onConfirm: (script: ScriptItem) => void
}
const SCRIPT_TABS = [
{ key: "all", label: "全部" },
{ key: "带货", label: "带货" },
{ key: "工厂", label: "工厂" },
{ key: "测评", label: "测评" },
{ key: "教程", label: "教程" },
{ key: "口播", label: "口播" },
{ key: "种草", label: "种草" },
]
/** 在文本中用 <mark> 高亮关键词(黄色背景) */
function highlight(text: string, keyword: string): React.ReactNode {
if (!keyword) return text
const idx = text.toLowerCase().indexOf(keyword.toLowerCase())
if (idx < 0) return text
return (
<>
{text.slice(0, idx)}
<mark style={{ background: "#fef08a", color: "#713f12", padding: "0 2px", borderRadius: 2 }}>
{text.slice(idx, idx + keyword.length)}
</mark>
{text.slice(idx + keyword.length)}
</>
)
}
const ScriptSelectModal: React.FC<ScriptSelectModalProps> = ({
open,
selectedScriptId,
onCancel,
onConfirm,
}) => {
const [innerSelected, setInnerSelected] = useState<string | null>(selectedScriptId)
const [activeTag, setActiveTag] = useState<string>("all")
const [searchInput, setSearchInput] = useState("")
const [debouncedKw, setDebouncedKw] = useState("")
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (open) {
setInnerSelected(selectedScriptId)
setActiveTag("all")
setSearchInput("")
setDebouncedKw("")
}
}, [open, selectedScriptId])
// 300ms 防抖
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => setDebouncedKw(searchInput.trim()), 300)
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [searchInput])
const { data, isLoading } = useQuery({
queryKey: ["scripts", "select-modal", debouncedKw, activeTag],
queryFn: () =>
getScripts({
page: 1,
page_size: 200,
keyword: debouncedKw || undefined,
tag: activeTag === "all" ? undefined : activeTag,
}),
enabled: open,
})
const scripts: ScriptItem[] = useMemo(() => data?.items ?? [], [data])
const selected = useMemo(
() => scripts.find((s) => s.id === innerSelected) ?? null,
[scripts, innerSelected],
)
const handleConfirm = useCallback(() => {
if (selected) onConfirm(selected)
}, [selected, onConfirm])
return (
<Modal
title="📝 选择文案"
open={open}
onCancel={onCancel}
onOk={handleConfirm}
okText="确认选择"
cancelText="取消"
okButtonProps={{ disabled: !selected, style: { background: "#7c3aed" } }}
width={680}
destroyOnClose
>
{/* 搜索 */}
<Input
allowClear
prefix={<SearchOutlined style={{ color: "#9ca3af" }} />}
placeholder="搜索标题、内容或标签"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
style={{ marginBottom: 12 }}
/>
{/* 标签筛选 */}
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 12 }}>
{SCRIPT_TABS.map((t) => {
const active = activeTag === t.key
return (
<Tag
key={t.key}
onClick={() => setActiveTag(t.key)}
style={{
cursor: "pointer",
padding: "4px 14px",
borderRadius: 16,
border: active ? "1px solid #7c3aed" : "1px solid #e5e7eb",
background: active ? "#ede9fe" : "#fff",
color: active ? "#7c3aed" : "#4b5563",
margin: 0,
fontSize: 13,
}}
>
{t.label}
</Tag>
)
})}
</div>
{/* 数量统计 */}
<div style={{ fontSize: 12, color: "#6b7280", marginBottom: 8 }}>
{data?.total ?? scripts.length}
</div>
{/* 卡片列表 */}
<div style={{ maxHeight: 420, overflowY: "auto", paddingRight: 4 }}>
{isLoading ? (
<div style={{ textAlign: "center", padding: "40px 0" }}>
<Spin />
</div>
) : scripts.length === 0 ? (
<div style={{ textAlign: "center", padding: "40px 0", color: "#9ca3af" }}>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{scripts.map((s) => {
const isSel = innerSelected === s.id
const preview = (s.content || "").replace(/\s+/g, " ").slice(0, 80)
return (
<div
key={s.id}
onClick={() => setInnerSelected(s.id)}
style={{
padding: 14,
borderRadius: 8,
border: isSel ? "2px solid #7c3aed" : "1px solid #e5e7eb",
background: isSel ? "#faf5ff" : "#fff",
cursor: "pointer",
transition: "all 0.2s",
position: "relative",
}}
>
{isSel && (
<CheckCircleFilled
style={{
position: "absolute",
top: 12,
right: 12,
color: "#7c3aed",
fontSize: 18,
}}
/>
)}
<div
style={{
fontSize: 14,
fontWeight: 600,
color: isSel ? "#6d28d9" : "#111",
marginBottom: 4,
paddingRight: 24,
}}
>
{highlight(s.title || "未命名", debouncedKw)}
</div>
<div
style={{
fontSize: 12,
color: "#6b7280",
lineHeight: 1.6,
marginBottom: 8,
}}
>
{highlight(preview + ((s.content || "").length > 80 ? "..." : ""), debouncedKw)}
</div>
{s.tags && s.tags.length > 0 && (
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
{s.tags.slice(0, 5).map((tg) => (
<Tag
key={tg}
style={{
margin: 0,
fontSize: 11,
padding: "1px 8px",
borderRadius: 10,
background: "#f3f4f6",
border: "none",
color: "#6b7280",
}}
>
{tg}
</Tag>
))}
</div>
)}
</div>
)
})}
</div>
)}
</div>
</Modal>
)
}
export default ScriptSelectModal
@@ -0,0 +1,266 @@
/**
* Step 1 选择剪辑模式 + 生成设置(#1970 新流程第一步)
* - 剪辑模式:🎲随机混剪 / 📖叙事剪辑,二选一,选中紫底紫框
* - 生成设置:生成数量(-/+ 1-10 默认1)、视频比例(9:16/16:9 默认9:16)、智能降重开关(默认开)
*/
import React from "react"
import { MinusOutlined, PlusOutlined } from "@ant-design/icons"
export type EditMode = "random" | "narrative"
interface Step1EditModeProps {
editMode: EditMode
onEditModeChange: (mode: EditMode) => void
/** 生成数量(1-10,默认1 */
previewCount: number
onPreviewCountChange: (n: number) => void
/** 视频比例 */
videoRatio: "9:16" | "16:9"
onVideoRatioChange: (ratio: "9:16" | "16:9") => void
/** 智能降重开关(默认 true) */
dedupEnabled: boolean
onDedupEnabledChange: (v: boolean) => void
}
const PURPLE = "#7c3aed"
const PURPLE_BG = "linear-gradient(135deg, #ede9fe, #ddd6fe)"
const PURPLE_BORDER = "2px solid #7c3aed"
const MODE_CARDS: Array<{
key: EditMode
emoji: string
title: string
desc: string
features: string[]
}> = [
{
key: "random",
emoji: "🎲",
title: "随机混剪",
desc: "根据配音时长随机抽取素材片段,灵活组合",
features: ["随机抽帧组合", "每次画面不同", "适合批量生成"],
},
{
key: "narrative",
emoji: "📖",
title: "叙事剪辑",
desc: "按文案内容匹配相关画面,有逻辑组织镜头",
features: ["画面匹配文案", "叙事感更强", "需要素材标签"],
},
]
const Step1EditMode: React.FC<Step1EditModeProps> = ({
editMode,
onEditModeChange,
previewCount,
onPreviewCountChange,
videoRatio,
onVideoRatioChange,
dedupEnabled,
onDedupEnabledChange,
}) => {
return (
<div className="xx-form-section">
<h3>🎬 </h3>
<p style={{ color: "#666", fontSize: 14, marginBottom: 16 }}>
</p>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
gap: 16,
marginBottom: 24,
}}
>
{MODE_CARDS.map((card) => {
const selected = editMode === card.key
return (
<div
key={card.key}
onClick={() => onEditModeChange(card.key)}
style={{
padding: 20,
borderRadius: 12,
border: selected ? PURPLE_BORDER : "1px solid #e5e7eb",
background: selected ? PURPLE_BG : "#fff",
cursor: "pointer",
transition: "all 0.2s",
}}
>
<div style={{ fontSize: 36, marginBottom: 8 }}>{card.emoji}</div>
<div
style={{
fontSize: 18,
fontWeight: 600,
color: selected ? PURPLE : "#111",
marginBottom: 6,
}}
>
{card.title}
</div>
<div style={{ fontSize: 13, color: "#666", marginBottom: 12 }}>{card.desc}</div>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{card.features.map((f) => (
<div key={f} style={{ fontSize: 12, color: selected ? "#6d28d9" : "#6b7280" }}>
{f}
</div>
))}
</div>
</div>
)
})}
</div>
<h3 style={{ marginTop: 8 }}> </h3>
<div className="xx-form-field" style={{ marginTop: 12 }}>
<label></label>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div
style={{
display: "inline-flex",
alignItems: "center",
border: "1px solid #e5e7eb",
borderRadius: 8,
overflow: "hidden",
background: "#fff",
}}
>
<button
type="button"
onClick={() => onPreviewCountChange(Math.max(1, previewCount - 1))}
disabled={previewCount <= 1}
style={{
width: 36,
height: 36,
border: "none",
background: "transparent",
cursor: previewCount <= 1 ? "not-allowed" : "pointer",
color: previewCount <= 1 ? "#d1d5db" : "#374151",
fontSize: 16,
}}
>
<MinusOutlined />
</button>
<span
style={{
minWidth: 40,
textAlign: "center",
fontSize: 16,
fontWeight: 600,
color: "#111",
}}
>
{previewCount}
</span>
<button
type="button"
onClick={() => onPreviewCountChange(Math.min(10, previewCount + 1))}
disabled={previewCount >= 10}
style={{
width: 36,
height: 36,
border: "none",
background: "transparent",
cursor: previewCount >= 10 ? "not-allowed" : "pointer",
color: previewCount >= 10 ? "#d1d5db" : "#374151",
fontSize: 16,
}}
>
<PlusOutlined />
</button>
</div>
<span style={{ fontSize: 12, color: "#6b7280" }}> 10 </span>
</div>
</div>
<div className="xx-form-field" style={{ marginTop: 16 }}>
<label></label>
<div style={{ display: "flex", gap: 12, marginTop: 4 }}>
{[
{ key: "9:16" as const, emoji: "📱", label: "竖屏 9:16" },
{ key: "16:9" as const, emoji: "🖥️", label: "横屏 16:9" },
].map((opt) => {
const selected = videoRatio === opt.key
return (
<button
key={opt.key}
type="button"
onClick={() => onVideoRatioChange(opt.key)}
style={{
padding: "10px 20px",
borderRadius: 8,
border: selected ? PURPLE_BORDER : "1px solid #e5e7eb",
background: selected ? PURPLE_BG : "#fff",
color: selected ? PURPLE : "#374151",
cursor: "pointer",
fontSize: 14,
fontWeight: selected ? 600 : 400,
transition: "all 0.2s",
}}
>
{opt.emoji} {opt.label}
</button>
)
})}
</div>
</div>
<div
className="xx-form-field"
style={{
marginTop: 16,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 16px",
background: "#f9fafb",
borderRadius: 8,
}}
>
<div>
<div style={{ fontSize: 14, fontWeight: 500, color: "#111" }}>
🎯 {dedupEnabled ? "已开启" : "已关闭"}
</div>
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 2 }}>
</div>
</div>
<button
type="button"
onClick={() => onDedupEnabledChange(!dedupEnabled)}
style={{
width: 44,
height: 24,
borderRadius: 12,
border: "none",
background: dedupEnabled ? PURPLE : "#d1d5db",
position: "relative",
cursor: "pointer",
transition: "background 0.2s",
padding: 0,
}}
aria-label="toggle dedup"
>
<span
style={{
position: "absolute",
top: 2,
left: dedupEnabled ? 22 : 2,
width: 20,
height: 20,
borderRadius: "50%",
background: "#fff",
transition: "left 0.2s",
boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
}}
/>
</button>
</div>
</div>
)
}
export default Step1EditMode
@@ -11,6 +11,8 @@ import SmartMatchInput from "./material/SmartMatchInput"
import SmartMatchResults from "./material/SmartMatchResults"
interface Step2MaterialSelectProps {
/** 剪辑模式:random 随机混剪 / narrative 叙事剪辑(#1970 */
editMode?: "random" | "narrative"
materialMode: "manual" | "auto"
onMaterialModeChange: (mode: "manual" | "auto") => void
selectedMaterials: string[]
@@ -43,6 +45,27 @@ const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
<div className="xx-form-section">
<h3>📦 </h3>
{/* 叙事剪辑:AI 智能匹配提示卡(#1970) */}
{props.editMode === "narrative" && (
<div
style={{
marginTop: 12,
padding: "12px 16px",
background: "linear-gradient(135deg,#ede9fe,#f5f3ff)",
border: "1px solid #c4b5fd",
borderRadius: 8,
fontSize: 13,
color: "#5b21b6",
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<span style={{ fontSize: 18 }}>🤖</span>
<span>AI智能匹配</span>
</div>
)}
{/* 片段数量(#1899 */}
<div className="xx-form-field" style={{ marginTop: 12 }}>
<label></label>
@@ -0,0 +1,491 @@
/**
* 叙事剪辑 — TTS 音色选择 + 合成配音弹窗(#1970)
* - Tabs:✨系统音色 / 🎙️我的克隆音色
* - 2列音色卡片(头像emoji+名称+描述+标签+▶试听+选中✓)
* - 底部:取消 / 🎧 合成配音(主按钮,必须选音色才能点)
* - 合成中:紫色 spinner + "正在合成配音..." + "请稍候,通常需要10-30秒"
* - 合成成功:保存到配音库并回调(voiceAssetId + ttsVoiceId + ttsVoiceSource
*
* 复用现有 /api/tts 的 synthesizeSpeech + 轮询 getTTSJobStatus 逻辑;
* 不直接复用 TtsModal(它是页面配音弹窗,含文本输入/语速/情感等字段,叙事模式文本来自文案)。
*/
import React, { useState, useEffect, useMemo, useRef, useCallback } from "react"
import { Modal, Tabs, Spin, message } from "antd"
import { CheckCircleFilled, SoundOutlined } from "@ant-design/icons"
import { useQuery } from "@tanstack/react-query"
import { fetchPresetVoices } from "@/api/voices"
import { getVoiceClones } from "@/api/voice-clone"
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
import type { PresetVoiceItem } from "@/api/voices"
import type { VoiceClone } from "@/api/voice-clone"
import { VOICE_GENDER_ICON } from "../constants"
interface TtsVoiceModalProps {
open: boolean
/** 需要合成的文本(来自选中的文案 content) */
scriptText: string
scriptTitle: string
onCancel: () => void
/** 合成成功回调:asset_id 为保存到配音库后的素材ID */
onSynthesized: (payload: {
voiceAssetId: string
ttsVoiceId: string
ttsVoiceSource: "preset" | "clone"
}) => void
}
type TtsSynthStatus = "idle" | "synthesizing" | "saving" | "done" | "error"
const TtsVoiceModal: React.FC<TtsVoiceModalProps> = ({
open,
scriptText,
scriptTitle,
onCancel,
onSynthesized,
}) => {
const [activeTab, setActiveTab] = useState<"preset" | "clone">("preset")
const [selectedVoiceId, setSelectedVoiceId] = useState<string>("")
const [status, setStatus] = useState<TtsSynthStatus>("idle")
const [error, setError] = useState<string | null>(null)
const [previewingId, setPreviewingId] = useState<string | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
/* 系统音色 */
const { data: presetData } = useQuery({
queryKey: ["preset-voices", "modal"],
queryFn: fetchPresetVoices,
enabled: open,
})
const presetVoices: PresetVoiceItem[] = useMemo(() => presetData?.items ?? [], [presetData])
/* 克隆音色(仅 ready 状态可用) */
const { data: cloneListRaw = [] } = useQuery({
queryKey: ["voice-clones", "ready"],
queryFn: () => getVoiceClones({ status: "ready" }),
enabled: open,
})
const cloneVoices: VoiceClone[] = useMemo(
() => cloneListRaw.filter((v: VoiceClone) => v.status === "ready"),
[cloneListRaw],
)
/* 打开时重置状态 */
useEffect(() => {
if (open) {
setSelectedVoiceId("")
setStatus("idle")
setError(null)
setActiveTab("preset")
} else {
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
if (audioRef.current) {
audioRef.current.pause()
audioRef.current = null
}
setPreviewingId(null)
}
return () => {
if (timerRef.current) clearInterval(timerRef.current)
}
}, [open])
const handlePreview = useCallback(
(voiceId: string, previewUrl: string | null | undefined) => {
if (!previewUrl) {
message.info("该音色暂无试听音频")
return
}
if (previewingId === voiceId && audioRef.current) {
audioRef.current.pause()
setPreviewingId(null)
return
}
if (audioRef.current) audioRef.current.pause()
const a = new Audio(previewUrl)
audioRef.current = a
setPreviewingId(voiceId)
a.onended = () => {
setPreviewingId(null)
audioRef.current = null
}
a.play().catch(() => {
setPreviewingId(null)
audioRef.current = null
})
},
[previewingId],
)
const textToSynth = useMemo(() => {
// 文案内容取首段(过长会被 TTS 截断,保持和用户感知一致)
const t = (scriptText || "").trim()
return t.length > 500 ? t.slice(0, 500) : t
}, [scriptText])
const handleSynthesize = useCallback(async () => {
if (!selectedVoiceId) {
message.warning("请先选择一个音色")
return
}
if (!textToSynth) {
message.warning("文案内容为空,无法合成")
return
}
setStatus("synthesizing")
setError(null)
try {
const isClone = activeTab === "clone"
const payload: Record<string, unknown> = {
text: textToSynth,
speed: 1.0,
language: "zh-CN",
}
if (isClone) {
payload.voice_clone_profile_id = selectedVoiceId
} else {
payload.voice_id = selectedVoiceId
}
const resp = await synthesizeSpeech(
payload as unknown as Parameters<typeof synthesizeSpeech>[0],
)
const jobId = resp.job_id
await new Promise<void>((resolve, reject) => {
timerRef.current = setInterval(async () => {
try {
const job = await getTTSJobStatus(jobId)
if (job.status === "completed") {
if (timerRef.current) clearInterval(timerRef.current)
timerRef.current = null
resolve()
} else if (job.status === "failed") {
if (timerRef.current) clearInterval(timerRef.current)
timerRef.current = null
reject(new Error(job.error_message || "合成失败"))
}
} catch (e) {
if (timerRef.current) clearInterval(timerRef.current)
timerRef.current = null
reject(e)
}
}, 2000)
})
// 保存到配音库
setStatus("saving")
await saveTtsToLibrary(jobId, { name: scriptTitle?.slice(0, 30) || "AI合成配音" })
setStatus("done")
// 合成成功后回调;voiceAssetId 由后端在保存时产出,这里用 ttsVoiceId 占位,
// 父流程会在下一次 asset 列表刷新后重新选取;前端直接以 ttsVoiceId 为 key 传给后端
// (叙事模式后端通过 script_id + tts_voice_id 自行再合成,不依赖 asset_id)。
onSynthesized({
voiceAssetId: jobId,
ttsVoiceId: selectedVoiceId,
ttsVoiceSource: isClone ? "clone" : "preset",
})
} catch (err: unknown) {
setStatus("error")
const msg = err instanceof Error ? err.message : "合成失败,请稍后重试"
setError(msg)
}
}, [selectedVoiceId, textToSynth, activeTab, scriptTitle, onSynthesized])
const renderVoiceCard = (v: {
id: string
name: string
description?: string
gender?: string
tags?: string[]
preview_url?: string | null
}) => {
const isSel = selectedVoiceId === v.id
const isPlaying = previewingId === v.id
const emoji = v.gender ? (VOICE_GENDER_ICON[v.gender] ?? "🎤") : "🎤"
return (
<div
key={v.id}
onClick={() => setSelectedVoiceId(v.id)}
style={{
padding: 12,
borderRadius: 8,
border: isSel ? "2px solid #7c3aed" : "1px solid #e5e7eb",
background: isSel ? "#faf5ff" : "#fff",
cursor: "pointer",
transition: "all 0.2s",
position: "relative",
}}
>
{isSel && (
<CheckCircleFilled
style={{
position: "absolute",
top: 10,
right: 10,
color: "#7c3aed",
}}
/>
)}
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
<div
style={{
width: 36,
height: 36,
borderRadius: "50%",
background: isSel ? "linear-gradient(135deg,#7c3aed,#a78bfa)" : "#f3f4f6",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 18,
}}
>
{emoji}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 14,
fontWeight: 600,
color: isSel ? "#6d28d9" : "#111",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{v.name}
</div>
{v.description && (
<div
style={{
fontSize: 11,
color: "#6b7280",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{v.description}
</div>
)}
</div>
{v.preview_url && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handlePreview(v.id, v.preview_url)
}}
style={{
width: 28,
height: 28,
borderRadius: "50%",
border: "none",
background: isPlaying ? "#ef4444" : "#7c3aed",
color: "#fff",
cursor: "pointer",
fontSize: 11,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<SoundOutlined />
</button>
)}
</div>
{v.tags && v.tags.length > 0 && (
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
{v.tags.slice(0, 3).map((tg) => (
<span
key={tg}
style={{
fontSize: 10,
padding: "1px 6px",
borderRadius: 8,
background: "#f3f4f6",
color: "#6b7280",
}}
>
{tg}
</span>
))}
</div>
)}
</div>
)
}
/* 合成中 loading 覆盖层 */
const renderSynthOverlay = () => {
if (status !== "synthesizing" && status !== "saving") return null
return (
<div
style={{
position: "absolute",
inset: 0,
background: "rgba(255,255,255,0.92)",
zIndex: 10,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 12,
borderRadius: 8,
}}
>
<Spin size="large" style={{ color: "#7c3aed" }} />
<div style={{ fontSize: 16, fontWeight: 600, color: "#6d28d9" }}>
{status === "synthesizing" ? "正在合成配音..." : "正在保存到配音库..."}
</div>
<div style={{ fontSize: 12, color: "#6b7280" }}> 10-30 </div>
</div>
)
}
return (
<Modal
title="🎙️ 合成配音"
open={open}
onCancel={status === "synthesizing" || status === "saving" ? undefined : onCancel}
cancelText="取消"
okText="🎧 合成配音"
okButtonProps={{
disabled: !selectedVoiceId || status === "synthesizing" || status === "saving",
style: { background: "#7c3aed" },
}}
onOk={handleSynthesize}
width={680}
destroyOnClose
confirmLoading={status === "synthesizing" || status === "saving"}
>
<div style={{ position: "relative" }}>
{error && (
<div
style={{
padding: "10px 12px",
background: "#fef2f2",
border: "1px solid #fecaca",
color: "#b91c1c",
borderRadius: 6,
fontSize: 13,
marginBottom: 12,
}}
>
{error}
</div>
)}
<div
style={{
fontSize: 12,
color: "#6b7280",
marginBottom: 12,
padding: "8px 12px",
background: "#f9fafb",
borderRadius: 6,
}}
>
{scriptTitle?.slice(0, 30) || "所选文案"}
{textToSynth.length}
</div>
<Tabs
activeKey={activeTab}
onChange={(k) => {
setActiveTab(k as "preset" | "clone")
setSelectedVoiceId("")
}}
items={[
{
key: "preset",
label: "✨ 系统音色",
children: (
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 10,
maxHeight: 420,
overflowY: "auto",
paddingRight: 4,
}}
>
{presetVoices.length === 0 ? (
<div
style={{
gridColumn: "1/-1",
textAlign: "center",
padding: 30,
color: "#9ca3af",
}}
>
...
</div>
) : (
presetVoices.map((v) =>
renderVoiceCard({
id: v.voice_id,
name: v.name,
description: v.description,
gender: v.gender,
tags: v.tags,
preview_url: v.preview_url,
}),
)
)}
</div>
),
},
{
key: "clone",
label: "🎙️ 我的克隆音色",
children: (
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 10,
maxHeight: 420,
overflowY: "auto",
paddingRight: 4,
}}
>
{cloneVoices.length === 0 ? (
<div
style={{
gridColumn: "1/-1",
textAlign: "center",
padding: 30,
color: "#9ca3af",
}}
>
</div>
) : (
cloneVoices.map((v) =>
renderVoiceCard({
id: v.id,
name: v.name,
description: v.description,
gender: "neutral",
tags: ["克隆"],
preview_url: v.sample_url || null,
}),
)
)}
</div>
),
},
]}
/>
{renderSynthOverlay()}
</div>
</Modal>
)
}
export default TtsVoiceModal
@@ -0,0 +1,241 @@
/**
* 随机混剪 — 配音选择弹窗(#1970)
* 内容复用 Step5VoiceSelect 的配音库音频卡片(图标+文件名+时长/大小+▶试听),
* 无 TTS / 克隆音色入口;确认后进入 Step2。
*/
import React from "react"
import { Modal } from "antd"
import { AudioOutlined } from "@ant-design/icons"
import { useNavigate } from "react-router-dom"
import { useQuery } from "@tanstack/react-query"
import { useState, useRef, useCallback } from "react"
import { getAssetsByKind } from "@/api/assets"
import type { AssetItem } from "@/api/assets"
interface VoiceSelectModalProps {
open: boolean
selectedVoice: string
onCancel: () => void
onConfirm: (voiceAssetId: string) => void
}
const getDuration = (item: AssetItem): number =>
item.duration ?? (item.metadata?.duration as number) ?? 0
const getFileSize = (item: AssetItem): number =>
item.file_size ?? (item.metadata?.file_size as number) ?? 0
const isAiVoice = (item: AssetItem): boolean => {
const d = getDuration(item)
const s = getFileSize(item)
return (!d || d <= 0) && (!s || s <= 0)
}
const fmtDur = (s?: number): string => {
if (!s || s <= 0) return "时长未知"
return `${s.toFixed(1)}`
}
const fmtSize = (b?: number): string => {
if (!b || b <= 0) return "未知"
if (b < 1024) return `${b} B`
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`
if (b < 1024 * 1024 * 1024) return `${(b / (1024 * 1024)).toFixed(1)} MB`
return `${(b / (1024 * 1024 * 1024)).toFixed(1)} GB`
}
const VoiceSelectModal: React.FC<VoiceSelectModalProps> = ({
open,
selectedVoice,
onCancel,
onConfirm,
}) => {
const navigate = useNavigate()
const [innerSelected, setInnerSelected] = React.useState(selectedVoice)
const [playingId, setPlayingId] = useState<string | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
React.useEffect(() => {
if (open) setInnerSelected(selectedVoice)
}, [open, selectedVoice])
const { data: materials = [], isLoading } = useQuery({
queryKey: ["assets", "voice", "modal"],
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
enabled: open,
})
const togglePlay = useCallback(
(item: AssetItem) => {
if (playingId === item.id && audioRef.current) {
audioRef.current.pause()
setPlayingId(null)
return
}
if (audioRef.current) audioRef.current.pause()
if (!item.file_url) return
const audio = new Audio(item.file_url)
audioRef.current = audio
setPlayingId(item.id)
audio.onended = () => {
setPlayingId(null)
audioRef.current = null
}
audio.play().catch(() => {
setPlayingId(null)
audioRef.current = null
})
},
[playingId],
)
const handleGoUpload = () => navigate("/app/voices?tab=material&upload=1")
const handleConfirm = () => {
if (!innerSelected) return
onConfirm(innerSelected)
}
return (
<Modal
title="🎙️ 选择配音"
open={open}
onCancel={onCancel}
onOk={handleConfirm}
okText="确认选择"
cancelText="取消"
okButtonProps={{ disabled: !innerSelected, style: { background: "#7c3aed" } }}
width={720}
destroyOnClose
>
<p style={{ color: "#666", fontSize: 13, marginBottom: 12 }}>
</p>
{isLoading ? (
<div style={{ textAlign: "center", padding: "40px 0", color: "#999" }}>...</div>
) : materials.length === 0 ? (
<div style={{ textAlign: "center", padding: "40px 0", color: "#999" }}>
<AudioOutlined style={{ fontSize: 48, color: "#d9d9d9", marginBottom: 12 }} />
<p style={{ marginBottom: 12 }}></p>
<button
type="button"
onClick={handleGoUpload}
style={{
padding: "8px 20px",
background: "#7c3aed",
color: "#fff",
border: "none",
borderRadius: 6,
cursor: "pointer",
}}
>
</button>
</div>
) : (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
gap: 12,
maxHeight: 460,
overflowY: "auto",
paddingRight: 4,
}}
>
{materials.map((item) => {
const isSel = innerSelected === item.id
const isPlaying = playingId === item.id
return (
<div
key={item.id}
onClick={() => setInnerSelected(item.id)}
style={{
padding: 14,
borderRadius: 8,
border: isSel ? "2px solid #7c3aed" : "1px solid #e8e8e8",
background: isSel ? "#ede9fe" : "#fff",
cursor: "pointer",
transition: "all 0.2s",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div
style={{
width: 36,
height: 36,
borderRadius: 8,
background: isSel
? "linear-gradient(135deg,#7c3aed,#a78bfa)"
: "linear-gradient(135deg,#f0f0f0,#e8e8e8)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<AudioOutlined style={{ color: isSel ? "#fff" : "#666" }} />
</div>
{item.file_url && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
togglePlay(item)
}}
style={{
width: 30,
height: 30,
borderRadius: "50%",
border: "none",
background: isPlaying ? "#ef4444" : "#7c3aed",
color: "#fff",
cursor: "pointer",
fontSize: 12,
}}
>
</button>
)}
</div>
<div
style={{
fontSize: 13,
fontWeight: 500,
marginTop: 8,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
color: isSel ? "#6d28d9" : "#333",
}}
title={item.name}
>
{item.name}
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 11,
color: "#999",
marginTop: 4,
}}
>
{isAiVoice(item) ? (
<span style={{ color: "#7c3aed", fontWeight: 500 }}>AI </span>
) : (
<span>{fmtDur(getDuration(item))}</span>
)}
<span>{isAiVoice(item) ? "按文本合成" : fmtSize(getFileSize(item))}</span>
</div>
</div>
)
})}
</div>
)}
</Modal>
)
}
export default VoiceSelectModal
+3 -3
View File
@@ -27,10 +27,10 @@ export const VOICE_GENDER_ICON: Record<string, string> = {
neutral: "✨",
}
/* ── 步骤定义(5步,#1899 简化:删除选模板步骤 ── */
/* ── 步骤定义(5步,#1970 流程重构:选择模式 → 素材 → 标题 → 确认 → 封面 ── */
export const STEPS = [
{ key: 1, label: "选择素材" },
{ key: 2, label: "选择配音" },
{ key: 1, label: "选择模式" },
{ key: 2, label: "选择素材" },
{ key: 3, label: "选择标题" },
{ key: 4, label: "确认生成" },
{ key: 5, label: "选择封面" },
@@ -1,7 +1,9 @@
import type { UseGenerateVideoProps } from "./types"
/**
* 生成前置校验
* 生成前置校验#1970 适配新流程)
* - 随机混剪:需选配音(selectedVoice,配音库音频)
* - 叙事剪辑:需选文案 + TTS 音色
* 返回错误信息,通过则返回 null
*/
export const validateGenerateInputs = (props: UseGenerateVideoProps): string | null => {
@@ -12,19 +14,30 @@ export const validateGenerateInputs = (props: UseGenerateVideoProps): string | n
smartSelectedIds,
voiceMode,
selectedClonedVoice,
editMode = "random",
selectedScript,
ttsVoiceId,
selectedVoice,
} = props
// AI 自动选择模式下,标题可以为空(后端会自行生成)
if (!titleSettings.aiAutoSelect && !titleSettings.title?.trim()) {
return "请先选择或输入标题"
}
// 无论手动还是自动模式,都必须有素材
const materialIds = materialMode === "auto" ? smartSelectedIds || [] : selectedMaterials || []
if (materialIds.length === 0) {
return materialMode === "auto" ? "AI 未匹配到素材,请手动选择素材后重试" : "请至少选择一个素材"
}
if (voiceMode === "clone" && !selectedClonedVoice) {
return "请先选择一个克隆音色"
if (editMode === "narrative") {
if (!selectedScript?.id) return "请先选择文案"
if (!ttsVoiceId) return "请先合成配音"
} else {
// 随机混剪:配音库音频
if (!selectedVoice && voiceMode !== "clone") {
return "请先选择配音"
}
if (voiceMode === "clone" && !selectedClonedVoice) {
return "请先选择一个克隆音色"
}
}
return null
}
@@ -13,7 +13,19 @@ export interface UseGenerateVideoProps {
selectedVoice: string
selectedClonedVoice: string
coverSettings: CoverConfig
videoRatio: string
videoRatio: "9:16" | "16:9" | string
/** #1970 剪辑模式 */
editMode?: "random" | "narrative"
/** 叙事模式下选中的文案 */
selectedScript?: { id: string; title?: string; content?: string } | null
/** TTS 音色 ID(叙事模式) */
ttsVoiceId?: string
/** TTS 音色来源 */
ttsVoiceSource?: "preset" | "clone"
/** 合成后保存到配音库的 asset id / job id(叙事模式) */
ttsVoiceAssetId?: string
/** 智能降重开关(默认 true) */
dedupEnabled?: boolean
style: string
duration: number
autoSubtitles: boolean
@@ -12,6 +12,7 @@ import { getEditingTemplates } from "@/api/editing-planner"
import type { EditPlanClip } from "@/api/template-editor"
import type { CoverConfig } from "../../types/cover"
import type { PresetVoiceItem } from "@/api/voices"
import type { ScriptItem } from "@/api/scripts"
import { DEFAULT_COVER_SETTINGS, DEFAULT_CLIP_COUNT } from "../../constants"
import type { TitleSettings } from "../../types"
import { usePlanConfigLoader } from "./usePlanConfigLoader"
@@ -82,8 +83,28 @@ export interface GenerateFormState {
cloneModalOpen: boolean
setCloneModalOpen: (open: boolean) => void
/* ── 剪辑模式(#1970 流程重构)── */
editMode: "random" | "narrative"
setEditMode: (mode: "random" | "narrative") => void
/** 叙事模式下选中的文案 */
selectedScript: ScriptItem | null
setSelectedScript: (s: ScriptItem | null) => void
/** TTS 音色 ID */
ttsVoiceId: string
setTtsVoiceId: (id: string) => void
/** TTS 音色来源:preset 系统 / clone 克隆 */
ttsVoiceSource: "preset" | "clone"
setTtsVoiceSource: (src: "preset" | "clone") => void
/** 合成后配音库 asset id(叙事模式保存到库后获得;随机模式 = selectedVoice */
ttsVoiceAssetId: string
setTtsVoiceAssetId: (id: string) => void
/** 智能降重开关(默认 true) */
dedupEnabled: boolean
setDedupEnabled: (v: boolean) => void
/* 高级设置 */
videoRatio: string
videoRatio: "9:16" | "16:9" | string
setVideoRatio: (r: "9:16" | "16:9") => void
duration: number
style: string
autoSubtitles: boolean
@@ -201,13 +222,21 @@ export const useGenerateFormState = (): GenerateFormState => {
/* ── 克隆声音弹窗 ── */
const [cloneModalOpen, setCloneModalOpen] = useState(false)
/* ── 高级设置(隐藏但保留) ── */
const [videoRatio] = useState("9:16")
/* ── 高级设置 ── */
const [videoRatio, setVideoRatio] = useState<"9:16" | "16:9">("9:16")
const [duration] = useState(30)
const [style] = useState("business")
const [autoSubtitles] = useState(true)
const [bgm] = useState(true)
/* ── 剪辑模式状态(#1970) ── */
const [editMode, setEditMode] = useState<"random" | "narrative">("random")
const [selectedScript, setSelectedScript] = useState<ScriptItem | null>(null)
const [ttsVoiceId, setTtsVoiceId] = useState<string>("")
const [ttsVoiceSource, setTtsVoiceSource] = useState<"preset" | "clone">("preset")
const [ttsVoiceAssetId, setTtsVoiceAssetId] = useState<string>("")
const [dedupEnabled, setDedupEnabled] = useState<boolean>(true)
/* ── 预览任务 ID ── */
const previewStorageKey = editPlanId
? `preview_task_id_${editPlanId}`
@@ -274,9 +303,22 @@ export const useGenerateFormState = (): GenerateFormState => {
selectedClonedVoice,
setSelectedClonedVoice,
presetVoices,
editMode,
setEditMode,
selectedScript,
setSelectedScript,
ttsVoiceId,
setTtsVoiceId,
ttsVoiceSource,
setTtsVoiceSource,
ttsVoiceAssetId,
setTtsVoiceAssetId,
dedupEnabled,
setDedupEnabled,
cloneModalOpen,
setCloneModalOpen,
videoRatio,
setVideoRatio,
duration,
style,
autoSubtitles,
@@ -123,6 +123,8 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
const { width: outputWidth, height: outputHeight } = calculateResolution(
props.videoRatio || "9:16",
)
const editMode = props.editMode ?? "random"
const dedupEnabled = props.dedupEnabled !== false
const assetIds =
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
@@ -151,10 +153,13 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || ""
// #1970:叙事模式下 ttsVoiceId 作为配音 id;随机模式用 selectedVoice
const voiceLibraryId =
props.voiceMode === "clone"
? props.selectedClonedVoice || props.selectedVoice || ""
: props.selectedVoice || ""
editMode === "narrative"
? props.ttsVoiceId || ""
: props.voiceMode === "clone"
? props.selectedClonedVoice || props.selectedVoice || ""
: props.selectedVoice || ""
/* ── 批量变体数组(长度1=共用,长度=count=独立,空=回退单值) ── */
const indexes =
@@ -197,6 +202,15 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
custom_title: props.titleSettings?.title || "",
duration: props.duration || undefined,
video_ratio: props.videoRatio,
assembly_mode: editMode,
...(editMode === "narrative" && props.selectedScript?.id
? {
script_id: props.selectedScript.id,
tts_voice_id: props.ttsVoiceId || undefined,
tts_voice_source: props.ttsVoiceSource || undefined,
}
: {}),
dedup_enabled: dedupEnabled,
voice_library_id: voiceLibraryId,
...(props.selectedVoice && !voiceLibraryId ? { voice_ids: [props.selectedVoice] } : {}),
bgm_config: {
@@ -1,17 +1,22 @@
/**
* GeneratePage 步骤导航(#1899 简化为 5 步,单视频与批量一致
* 步骤:素材(1) → 配音(2) → 标题(3) → 确认生成(4) → 封面(5)
* GeneratePage 步骤导航(#1970 流程重构
* 步骤:选择模式(1) → 选择素材(2) → 选择标题(3) → 确认生成(4) → 选择封面(5)
*
* - 步骤3底部按钮是「确认生成视频」(由 GenerateStepActions 调 onConfirmGenerate),
* 创建成功后跳转步骤4本 hook 的 goNext 只负责 1→2→3 和 4→5 的「下一步」
* - 步骤4(确认生成进度页):渲染全部完成(generated)后「下一步」解锁进封面
* - 步骤1(选择模式):下一步分支由外层弹窗处理(VoiceSelectModal / ScriptSelectModal),
* 本 hook 的 goNext 仅在未选模式时拦截;外层 Modal onConfirm 里主动 setCurrentStep(2)
* - 步骤2(选择素材):弹数量选择弹窗(PreviewCountModal),确认后跳步骤3
* - 步骤3 底部按钮是「确认生成视频」(由 GenerateStepActions 调 onConfirmGenerate),
* 创建成功后跳步骤4;本 hook 的 goNext 只负责 2→3 和 4→5 的「下一步」。
* - 步骤4(确认生成进度页):全部渲染完成后「下一步」解锁进封面。
*/
import { message } from "antd"
import type { TitleSettings } from "../types"
import type { EditMode } from "../components/Step1EditMode"
export interface UseStepNavigationOptions {
currentStep: number
setCurrentStep: (step: number | ((prev: number) => number)) => void
editMode: EditMode
materialMode: "manual" | "auto"
selectedMaterials: string[]
smartSelectedIds: string[]
@@ -20,6 +25,8 @@ export interface UseStepNavigationOptions {
generated: boolean
/** 点素材下一步时弹出数量选择弹窗 */
onOpenCountModal: () => void
/** 步骤1下一步:根据 editMode 打开对应弹窗(随机→配音 / 叙事→文案) */
onOpenStep1Modal: () => void
}
export interface UseStepNavigationReturn {
@@ -36,22 +43,29 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
smartSelectedIds,
generated,
onOpenCountModal,
onOpenStep1Modal,
} = options
const goNext = () => {
if (currentStep === 1) {
// 选完素材弹数量选择弹窗
// 步骤1:先校验素材/配音等由弹窗负责,goNext 只负责触发弹窗
onOpenStep1Modal()
return
}
if (currentStep === 2) {
// 素材校验
if (materialMode === "manual" && selectedMaterials.length === 0) {
message.warning("请至少选择一个素材")
return
}
if (materialMode === "auto" && smartSelectedIds.length === 0) {
message.warning("请先进行智能匹配并选择素材")
return
}
// 弹数量选择弹窗
onOpenCountModal()
return
}
if (currentStep === 1 && materialMode === "manual" && selectedMaterials.length === 0) {
message.warning("请至少选择一个素材")
return
}
if (currentStep === 1 && materialMode === "auto" && smartSelectedIds.length === 0) {
message.warning("请先进行智能匹配并选择素材")
return
}
// 步骤4(确认生成):全部渲染完成后才能下一步进封面
if (currentStep === 4) {
if (!generated) {
+20 -10
View File
@@ -187,29 +187,39 @@ def test_asr_not_configured_returns_503(fake_user):
# ── ASR 转写失败 → 502 ────────────────────────────────────────
def test_asr_transcription_failure_returns_502(fake_user):
"""ASR 转写异常 → 502(被 _direct_url_download_and_local_asr 包装)"""
from app.services.script_asr_service import ASRTranscriptionError
def test_asr_transcription_failure_returns_503_with_desc_fallback(fake_user):
"""ASR 转写异常MediaKit+本地都失败)→ desc 兜底;desc 也空则 503"""
from app.api.routes import scripts_ai
from app.services.mediakit_client import MediaKitError
from fastapi import HTTPException
scripts_ai = _import_target()
body = scripts_ai.ExtractFromDouyinRequest(url="https://v.douyin.com/xxxxx/")
# MediaKit 失败
fake_mk = mock.MagicMock()
fake_mk.is_available = True
from app.services.mediakit_client import MediaKitError
fake_mk.asr_submit.side_effect = MediaKitError("ASR failed", code="TaskFailed")
# desc 为空 → 最终 503stage=asr
with _fake_resolver_success(desc=""):
with mock.patch("app.api.routes.scripts_ai.get_mediakit_client", return_value=fake_mk):
with mock.patch(
"app.api.routes.scripts_ai._direct_url_download_and_local_asr",
side_effect=HTTPException(status_code=502, detail="语音识别失败: No module named 'apps.worker'"),
):
with pytest.raises(HTTPException) as exc:
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert exc.value.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
# desc 非空 → desc 兜底成功,返回 200
with _fake_resolver_success(desc="这是视频文案描述"):
with mock.patch("app.api.routes.scripts_ai.get_mediakit_client", return_value=fake_mk):
with mock.patch(
"app.api.routes.scripts_ai._direct_url_download_and_local_asr",
side_effect=HTTPException(status_code=502, detail="语音识别失败"),
):
with pytest.raises(HTTPException) as exc:
scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert exc.value.status_code == status.HTTP_502_BAD_GATEWAY
resp = scripts_ai.extract_from_douyin(request=body, current_user=fake_user, db=mock.MagicMock())
assert resp.text == "这是视频文案描述"
assert resp.duration_seconds == 0.0
# ── 下载超时 → 504 ────────────────────────────────────────────