Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4c6f492a7 |
@@ -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
|
||||
/** #1970:TTS 音色 ID */
|
||||
tts_voice_id?: string
|
||||
/** #1970:TTS 音色来源 preset/clone */
|
||||
tts_voice_source?: "preset" | "clone"
|
||||
/** #1970:智能降重开关(默认 true) */
|
||||
dedup_enabled?: boolean
|
||||
/** 标题烧录配置 */
|
||||
title_config?: {
|
||||
text?: string
|
||||
|
||||
@@ -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 = () => {
|
||||
],
|
||||
)
|
||||
|
||||
/* ── #1970:Step1 弹窗回调 ── */
|
||||
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
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user