7e62d886de
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 31s
AI Code Review / AI Code Review (pull_request) Failing after 32s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m40s
Auto Merge CI PRs / Auto Merge on CI Green + Approved (pull_request) Successful in 2m18s
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Successful in 2m23s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 9m37s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1415h22m27s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1415h24m4s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1415h24m34s
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1415h31m33s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1415h31m34s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1415h31m34s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1415h31m35s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1415h31m36s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1415h31m36s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1415h31m36s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1415h31m38s
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1416h3m43s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1416h3m40s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1416h3m43s
- 流程调整为7步:选择模板→选择素材→生成预览→选择标题→选择配音→选择封面→确认生成 - 新增「生成预览」步骤,展示剪辑计划概览(模板、素材、时长、比例) - 所有步骤编号顺延调整
2375 lines
83 KiB
TypeScript
Executable File
2375 lines
83 KiB
TypeScript
Executable File
/**
|
|
* 智能剪辑页面 — V21 原型 1:1 还原
|
|
* 5 步向导:选择模板 → 选择素材 → 选择标题 → 选择配音 → 确认生成
|
|
* 左右布局:左侧 generate-form + 右侧 generate-preview
|
|
* 保留所有现有业务逻辑(API 调用、URL 参数、CloneModal、TTS 等)
|
|
*/
|
|
import React, { useState, useRef, useCallback, useEffect } from "react"
|
|
import { useQuery, useMutation } from "@tanstack/react-query"
|
|
import { Typography, message, Select } from "antd"
|
|
import {
|
|
AudioOutlined,
|
|
ThunderboltOutlined,
|
|
CheckCircleFilled,
|
|
CheckCircleOutlined,
|
|
CloseCircleOutlined,
|
|
LoadingOutlined,
|
|
PlayCircleOutlined,
|
|
PauseCircleOutlined,
|
|
DownloadOutlined,
|
|
ShareAltOutlined,
|
|
SaveOutlined,
|
|
PlusOutlined,
|
|
MinusOutlined,
|
|
CloseOutlined,
|
|
} from "@ant-design/icons"
|
|
import type { AssetItem } from "@/api/assets"
|
|
import { getAssets, getAssetLibraries } from "@/api/assets"
|
|
import {
|
|
createEditPlan,
|
|
generateEditPlan,
|
|
updateEditPlan,
|
|
getGenerationTaskResults,
|
|
} from "@/api/editPlans"
|
|
import type { GeneratedVideo, EditPlanConfig, TitleConfig } from "@/api/editPlans"
|
|
import type { CoverConfig } from "../editing-planner/types"
|
|
import { getEditingTemplates } from "@/api/editingPlanner"
|
|
import { getTitles } from "@/api/titles"
|
|
import apiClient from "@/api/client"
|
|
import { fetchPresetVoices } from "@/api/voices"
|
|
import type { PresetVoiceItem } from "@/api/voices"
|
|
import { formatDuration } from "@/api/voiceClone"
|
|
import type { VoiceClone } from "@/api/voiceClone"
|
|
import CloneModal from "@/components/voice/CloneModal"
|
|
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
|
import { getTags, createTag } from "@/api/tags"
|
|
import { useCloneProgress } from "@/hooks/useCloneProgress"
|
|
import { useSearchParams, useNavigate } from "react-router-dom"
|
|
import { getEditPlan } from "@/api/editPlans"
|
|
import "./generate.css"
|
|
|
|
const { Text } = Typography
|
|
|
|
/* ── 克隆声音状态配置 ── */
|
|
const CLONE_STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
|
ready: { label: "就绪", color: "var(--secondary-color, #10b981)" },
|
|
processing: { label: "克隆中", color: "var(--accent-color, #f59e0b)" },
|
|
failed: { label: "失败", color: "var(--error-color, #ef4444)" },
|
|
}
|
|
|
|
/* ── 模板渐变色映射(根据 mode 分配视觉样式) ── */
|
|
const MODE_GRADIENTS: Record<string, string> = {
|
|
pip: "linear-gradient(135deg, #fbbf24, #f59e0b)",
|
|
one_take: "linear-gradient(135deg, #3b82f6, #1d4ed8)",
|
|
voice_over: "linear-gradient(135deg, #6366f1, #4f46e5)",
|
|
voice_pip: "linear-gradient(135deg, #10b981, #059669)",
|
|
}
|
|
/* ── 配音预设卡片:从 API 动态生成,不再硬编码 ── */
|
|
const VOICE_GENDER_ICON: Record<string, string> = {
|
|
female: "🎀",
|
|
male: "🎙️",
|
|
child: "🧒",
|
|
neutral: "✨",
|
|
}
|
|
|
|
/* ── 步骤定义 ── */
|
|
const STEPS = [
|
|
{ key: 1, label: "选择模板" },
|
|
{ key: 2, label: "选择素材" },
|
|
{ key: 3, label: "生成预览" },
|
|
{ key: 4, label: "选择标题" },
|
|
{ key: 5, label: "选择配音" },
|
|
{ key: 6, label: "选择封面" },
|
|
{ key: 7, label: "确认生成" },
|
|
]
|
|
|
|
/* ── 标题设置常量 ── */
|
|
const POSITION_OPTIONS = [
|
|
{ value: "top", label: "顶部" },
|
|
{ value: "center", label: "居中" },
|
|
{ value: "bottom", label: "底部" },
|
|
]
|
|
|
|
const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "PingFang", "微软雅黑", "楷体", "华康俪金黑"]
|
|
|
|
interface TitleSettings {
|
|
aiAutoSelect: boolean
|
|
title: string
|
|
position: string
|
|
font: string
|
|
size: number
|
|
bold: boolean
|
|
italic: boolean
|
|
stroke: boolean
|
|
shadow: boolean
|
|
color: string
|
|
}
|
|
|
|
const DEFAULT_TITLE_SETTINGS: TitleSettings = {
|
|
aiAutoSelect: false,
|
|
title: "",
|
|
position: "bottom",
|
|
font: "思源黑体",
|
|
size: 28,
|
|
bold: true,
|
|
italic: false,
|
|
stroke: true,
|
|
shadow: false,
|
|
color: "#ffffff",
|
|
}
|
|
|
|
const TITLE_PRESETS = [
|
|
{
|
|
key: "classic_white",
|
|
label: "经典白字",
|
|
style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: true, shadow: false },
|
|
previewStyle: {
|
|
fontWeight: 700,
|
|
color: "#ffffff",
|
|
WebkitTextStroke: "1px #000000",
|
|
fontSize: "20px",
|
|
},
|
|
},
|
|
{
|
|
key: "black_gold",
|
|
label: "黑金质感",
|
|
style: { size: 32, color: "#d4a843", bold: true, italic: false, stroke: false, shadow: true },
|
|
previewStyle: {
|
|
fontWeight: 700,
|
|
color: "#d4a843",
|
|
textShadow: "1px 1px 3px rgba(0,0,0,0.8)",
|
|
fontSize: "20px",
|
|
},
|
|
},
|
|
{
|
|
key: "fresh_minimal",
|
|
label: "清新简约",
|
|
style: { size: 24, color: "#333333", bold: false, italic: false, stroke: false, shadow: false },
|
|
previewStyle: { fontWeight: 400, color: "#333333", fontSize: "18px" },
|
|
},
|
|
{
|
|
key: "variety_show",
|
|
label: "综艺花字",
|
|
style: { size: 36, color: "#ff4081", bold: true, italic: false, stroke: true, shadow: true },
|
|
previewStyle: {
|
|
fontWeight: 900,
|
|
color: "#ff4081",
|
|
WebkitTextStroke: "1.5px #ffffff",
|
|
textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
|
|
fontSize: "22px",
|
|
},
|
|
},
|
|
{
|
|
key: "business",
|
|
label: "商务极简",
|
|
style: { size: 24, color: "#1a1a1a", bold: false, italic: false, stroke: false, shadow: false },
|
|
previewStyle: { fontWeight: 400, color: "#1a1a1a", fontSize: "17px" },
|
|
},
|
|
{
|
|
key: "retro_film",
|
|
label: "复古胶片",
|
|
style: { size: 28, color: "#e8d5b7", bold: false, italic: false, stroke: false, shadow: true },
|
|
previewStyle: {
|
|
fontWeight: 400,
|
|
color: "#e8d5b7",
|
|
textShadow: "2px 2px 6px rgba(0,0,0,0.7)",
|
|
fontSize: "18px",
|
|
},
|
|
},
|
|
{
|
|
key: "neon_glow",
|
|
label: "霓虹发光",
|
|
style: { size: 32, color: "#00e5ff", bold: true, italic: false, stroke: false, shadow: true },
|
|
previewStyle: {
|
|
fontWeight: 700,
|
|
color: "#00e5ff",
|
|
textShadow: "0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)",
|
|
fontSize: "20px",
|
|
},
|
|
},
|
|
{
|
|
key: "handwriting",
|
|
label: "手写字",
|
|
style: { size: 28, color: "#333333", bold: false, italic: false, stroke: false, shadow: true },
|
|
previewStyle: {
|
|
fontWeight: 400,
|
|
color: "#333333",
|
|
textShadow: "1px 1px 2px rgba(0,0,0,0.3)",
|
|
fontSize: "20px",
|
|
},
|
|
},
|
|
]
|
|
|
|
/* ── 封面设置常量 ── */
|
|
const COVER_MODE_LABELS: Record<string, string> = {
|
|
auto: "智能封面",
|
|
frame: "抽帧选封面",
|
|
upload: "上传封面",
|
|
}
|
|
|
|
const COVER_MODE_ICONS: Record<string, string> = {
|
|
auto: "🤖",
|
|
frame: "🎞️",
|
|
upload: "📤",
|
|
}
|
|
|
|
const DEFAULT_COVER_SETTINGS: CoverConfig = {
|
|
enabled: true,
|
|
mode: "auto",
|
|
frame_time: 0,
|
|
upload_url: "",
|
|
ai_suggested_time: null,
|
|
thumbnail_url: "",
|
|
}
|
|
|
|
function getActivePreset(settings: TitleSettings): string | null {
|
|
for (const p of TITLE_PRESETS) {
|
|
if (
|
|
settings.size === p.style.size &&
|
|
settings.color === p.style.color &&
|
|
settings.bold === p.style.bold &&
|
|
settings.italic === p.style.italic &&
|
|
settings.stroke === p.style.stroke &&
|
|
settings.shadow === p.style.shadow
|
|
) {
|
|
return p.key
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/* ================================================================
|
|
组件
|
|
================================================================ */
|
|
|
|
const GeneratePage: React.FC = () => {
|
|
const navigate = useNavigate()
|
|
|
|
/* ── 步骤状态 ── */
|
|
const [currentStep, setCurrentStep] = useState(1)
|
|
|
|
/* ── 模板(从 API 加载) ── */
|
|
const [selectedTemplate, setSelectedTemplate] = useState("")
|
|
const { data: userTemplates = [] } = useQuery({
|
|
queryKey: ["generate-templates"],
|
|
queryFn: () => getEditingTemplates(),
|
|
staleTime: 60_000,
|
|
})
|
|
/* 模板加载完成后自动选中第一个 */
|
|
useEffect(() => {
|
|
if (userTemplates.length > 0 && !selectedTemplate) {
|
|
setSelectedTemplate(userTemplates[0].id)
|
|
}
|
|
}, [userTemplates, selectedTemplate])
|
|
|
|
/* ── 素材 ── */
|
|
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([])
|
|
/* 素材选择模式:手动选择 / 自动匹配 */
|
|
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
|
|
|
|
/* ── 标题设置 ── */
|
|
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
|
|
|
|
/* ── 封面设置 ── */
|
|
const [coverSettings, setCoverSettings] = useState<CoverConfig>(DEFAULT_COVER_SETTINGS)
|
|
const { data: userTitles = [] } = useQuery({
|
|
queryKey: ["titles"],
|
|
queryFn: () => getTitles(),
|
|
staleTime: 30_000,
|
|
})
|
|
/* 当选中模板开启了「AI自动匹配标题」,自动填入模板预设标题 */
|
|
useEffect(() => {
|
|
const tpl = userTemplates.find((t) => t.id === selectedTemplate)
|
|
if (tpl?.title_config) {
|
|
setTitleSettings((prev) => ({
|
|
...prev,
|
|
aiAutoSelect: tpl.title_config!.ai_auto_select,
|
|
title: tpl.title_config!.content || prev.title,
|
|
position: tpl.title_config!.position || prev.position,
|
|
font: tpl.title_config!.font_preset || prev.font,
|
|
size: tpl.title_config!.font_size || prev.size,
|
|
color: tpl.title_config!.font_color || prev.color,
|
|
}))
|
|
}
|
|
if (tpl?.cover_config) {
|
|
setCoverSettings((prev) => ({
|
|
...prev,
|
|
enabled: tpl.cover_config!.enabled ?? prev.enabled,
|
|
mode: (tpl.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
|
|
frame_time: tpl.cover_config!.frame_time ?? prev.frame_time,
|
|
upload_url: tpl.cover_config!.upload_url || prev.upload_url,
|
|
ai_suggested_time: tpl.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
|
|
thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url,
|
|
}))
|
|
}
|
|
}, [selectedTemplate, userTemplates])
|
|
|
|
/* ── 配音 ── */
|
|
const [selectedVoice, setSelectedVoice] = useState<string>("")
|
|
const [playingVoice, setPlayingVoice] = useState<string | null>(null)
|
|
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset")
|
|
const [customVoiceText, setCustomVoiceText] = useState("")
|
|
|
|
/* ── 生成数量 ── */
|
|
const [generateCount, setGenerateCount] = useState(1)
|
|
|
|
/* ── 克隆声音 ── */
|
|
const [selectedClonedVoice, setSelectedClonedVoice] = useState<string>("")
|
|
const [cloneModalOpen, setCloneModalOpen] = useState(false)
|
|
const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress()
|
|
|
|
/* ── 高级设置(隐藏但保留) ── */
|
|
const [videoRatio] = useState("16:9")
|
|
const [duration] = useState(30)
|
|
const [style] = useState("business")
|
|
const [autoSubtitles] = useState(true)
|
|
const [bgm] = useState(true)
|
|
|
|
/* ── 生成状态 ── */
|
|
const [generating, setGenerating] = useState(false)
|
|
const [progress, setProgress] = useState(0)
|
|
const [generated, setGenerated] = useState(false)
|
|
const [generateError, setGenerateError] = useState<string | null>(null)
|
|
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([])
|
|
const [videoUrl, setVideoUrl] = useState<string>("")
|
|
const [thumbnailUrl, setThumbnailUrl] = useState<string>("")
|
|
|
|
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined)
|
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
|
|
|
/* ── URL 参数:从剪辑计划跳转过来时携带 edit_plan_id + plan_config ── */
|
|
const [searchParams] = useSearchParams()
|
|
const editPlanId = searchParams.get("edit_plan_id")
|
|
const planConfigStr = searchParams.get("plan_config")
|
|
|
|
/** 解析 plan_config 并自动填充表单 */
|
|
useEffect(() => {
|
|
if (!planConfigStr) return
|
|
try {
|
|
const config = JSON.parse(planConfigStr) as {
|
|
title_config?: { content?: string; ai_auto_select?: boolean }
|
|
subtitle_config?: { enabled?: boolean }
|
|
bgm_config?: { enabled?: boolean; music_id?: string }
|
|
mode?: string
|
|
total_duration?: number
|
|
segments?: Array<{ media_asset_id?: string; material_type?: string }>
|
|
}
|
|
|
|
if (config.title_config) {
|
|
const tc = config.title_config as TitleConfig
|
|
setTitleSettings((prev) => ({
|
|
...prev,
|
|
title: tc.content || "",
|
|
aiAutoSelect: tc.ai_auto_select || false,
|
|
position: tc.position || prev.position,
|
|
font: tc.font_preset || prev.font,
|
|
size: tc.font_size || prev.size,
|
|
color: tc.font_color || prev.color,
|
|
}))
|
|
}
|
|
if (config.segments && config.segments.length > 0) {
|
|
const assetIds = config.segments
|
|
.map((s) => s.media_asset_id)
|
|
.filter((id): id is string => !!id)
|
|
if (assetIds.length > 0) {
|
|
setSelectedMaterials(assetIds)
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn("解析 plan_config 失败:", err)
|
|
}
|
|
}, [planConfigStr])
|
|
|
|
/** 如果没有 plan_config,尝试通过 edit_plan_id 从后端拉取配置 */
|
|
useEffect(() => {
|
|
if (!editPlanId || planConfigStr) return
|
|
const loadPlanConfig = async () => {
|
|
try {
|
|
const plan = await getEditPlan(editPlanId)
|
|
if (plan.name) setTitleSettings((prev) => ({ ...prev, title: plan.name }))
|
|
const cfg = plan.config
|
|
if (cfg?.title_config) {
|
|
setTitleSettings((prev) => ({
|
|
...prev,
|
|
aiAutoSelect: cfg.title_config!.ai_auto_select,
|
|
title: cfg.title_config!.content || prev.title,
|
|
position: cfg.title_config!.position || prev.position,
|
|
font: cfg.title_config!.font_preset || prev.font,
|
|
size: cfg.title_config!.font_size || prev.size,
|
|
color: cfg.title_config!.font_color || prev.color,
|
|
}))
|
|
}
|
|
if (cfg?.cover_config) {
|
|
const cc = cfg.cover_config as CoverConfig
|
|
setCoverSettings((prev) => ({
|
|
...prev,
|
|
enabled: cc.enabled ?? prev.enabled,
|
|
mode: cc.mode || prev.mode,
|
|
frame_time: cc.frame_time ?? prev.frame_time,
|
|
upload_url: cc.upload_url || prev.upload_url,
|
|
ai_suggested_time: cc.ai_suggested_time ?? prev.ai_suggested_time,
|
|
thumbnail_url: cc.thumbnail_url || prev.thumbnail_url,
|
|
}))
|
|
}
|
|
if (cfg?.asset_ids) {
|
|
setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === "string"))
|
|
}
|
|
} catch (err) {
|
|
console.warn("加载剪辑计划配置失败:", err)
|
|
}
|
|
}
|
|
loadPlanConfig()
|
|
}, [editPlanId, planConfigStr])
|
|
|
|
/* ── 预置音色 API ── */
|
|
const { data: presetVoicesData, isLoading: presetVoicesLoading } = useQuery({
|
|
queryKey: ["preset-voices"],
|
|
queryFn: fetchPresetVoices,
|
|
})
|
|
const presetVoices: PresetVoiceItem[] = presetVoicesData?.items ?? []
|
|
|
|
/* ── TTS 自定义合成状态 ── */
|
|
const [customAudioUrl, setCustomAudioUrl] = useState<string | null>(null)
|
|
const [ttsError, setTtsError] = useState<string | null>(null)
|
|
const [ttsJobId, setTtsJobId] = useState<string | null>(null)
|
|
/** 合成完成后保留的 job ID,用于"存为素材" */
|
|
const [completedTtsJobId, setCompletedTtsJobId] = useState<string | null>(null)
|
|
|
|
/* ── 存为素材弹窗状态 ── */
|
|
const [saveModalOpen, setSaveModalOpen] = useState(false)
|
|
const [saveName, setSaveName] = useState("")
|
|
const [saveTagIds, setSaveTagIds] = useState<string[]>([])
|
|
const [saveNewTag, setSaveNewTag] = useState("")
|
|
|
|
/* ── 标签列表(用于存为素材弹窗) ── */
|
|
const { data: allTags = [] } = useQuery({
|
|
queryKey: ["generate-save-tags"],
|
|
queryFn: getTags,
|
|
staleTime: 30_000,
|
|
})
|
|
|
|
/* ── 素材数据 API ── */
|
|
const { data: libraries = [] } = useQuery({
|
|
queryKey: ["asset-libraries"],
|
|
queryFn: getAssetLibraries,
|
|
})
|
|
const [selectedLibraryId, setSelectedLibraryId] = useState<string>("")
|
|
|
|
// 自动选中第一个视频库
|
|
useEffect(() => {
|
|
if (libraries.length > 0 && !selectedLibraryId) {
|
|
setSelectedLibraryId(libraries[0].id)
|
|
}
|
|
}, [libraries, selectedLibraryId])
|
|
|
|
const { data: materials = { items: [], total: 0 }, isLoading: materialsLoading } = useQuery<{
|
|
items: AssetItem[]
|
|
total: number
|
|
}>({
|
|
queryKey: ["generate-assets", selectedLibraryId],
|
|
queryFn: () => getAssets(selectedLibraryId),
|
|
enabled: !!selectedLibraryId,
|
|
})
|
|
|
|
const handleCloneSuccess = useCallback(
|
|
(voice: VoiceClone) => {
|
|
addClone(voice)
|
|
setCloneModalOpen(false)
|
|
message.success("音色克隆成功!")
|
|
},
|
|
[addClone],
|
|
)
|
|
|
|
/* ── 事件 ── */
|
|
|
|
const toggleVoicePlay = useCallback(
|
|
(voiceId: string, previewUrl: string | null) => {
|
|
if (playingVoice === voiceId) {
|
|
audioRef.current?.pause()
|
|
audioRef.current = null
|
|
setPlayingVoice(null)
|
|
return
|
|
}
|
|
audioRef.current?.pause()
|
|
if (!previewUrl) {
|
|
message.warning("该音色暂无试听音频")
|
|
return
|
|
}
|
|
const audio = new Audio(previewUrl)
|
|
audioRef.current = audio
|
|
audio.play().catch(() => {
|
|
message.error("播放失败,请检查网络")
|
|
})
|
|
audio.onended = () => {
|
|
setPlayingVoice(null)
|
|
audioRef.current = null
|
|
}
|
|
setPlayingVoice(voiceId)
|
|
},
|
|
[playingVoice],
|
|
)
|
|
|
|
/* ── TTS mutation ── */
|
|
const synthesizeMutation = useMutation({
|
|
mutationFn: synthesizeSpeech,
|
|
onSuccess: (data) => {
|
|
setTtsJobId(data.job_id)
|
|
message.info("语音合成已提交,等待处理…")
|
|
},
|
|
onError: () => {
|
|
setTtsError("语音合成请求失败,请重试")
|
|
},
|
|
})
|
|
|
|
/** 轮询 TTS 任务状态 */
|
|
useEffect(() => {
|
|
if (!ttsJobId) return
|
|
let cancelled = false
|
|
let timer: ReturnType<typeof setTimeout>
|
|
|
|
const poll = async () => {
|
|
try {
|
|
const status = await getTTSJobStatus(ttsJobId)
|
|
if (cancelled) return
|
|
if (status.status === "completed") {
|
|
setCustomAudioUrl(status.output_audio_url)
|
|
setCompletedTtsJobId(ttsJobId)
|
|
setTtsJobId(null)
|
|
setTtsError(null)
|
|
message.success("语音合成完成!")
|
|
return
|
|
}
|
|
if (status.status === "failed" || status.status === "cancelled") {
|
|
setTtsError(status.error_message || "语音合成失败")
|
|
setTtsJobId(null)
|
|
return
|
|
}
|
|
timer = setTimeout(poll, 2000)
|
|
} catch {
|
|
if (!cancelled) {
|
|
setTtsError("查询合成状态失败")
|
|
setTtsJobId(null)
|
|
}
|
|
}
|
|
}
|
|
|
|
timer = setTimeout(poll, 2000)
|
|
return () => {
|
|
cancelled = true
|
|
clearTimeout(timer)
|
|
}
|
|
}, [ttsJobId])
|
|
|
|
/** 触发自定义文本 TTS 合成 */
|
|
const handleSynthesizeVoice = useCallback(() => {
|
|
if (!customVoiceText.trim()) {
|
|
message.warning("请先输入配音文案")
|
|
return
|
|
}
|
|
setTtsError(null)
|
|
setCustomAudioUrl(null)
|
|
synthesizeMutation.mutate({
|
|
text: customVoiceText.trim(),
|
|
voice_id: selectedVoice || undefined,
|
|
language: "zh-CN",
|
|
})
|
|
}, [customVoiceText, selectedVoice, synthesizeMutation])
|
|
|
|
/* ── 存为素材 mutation ── */
|
|
const saveToLibraryMutation = useMutation({
|
|
mutationFn: (params: { name?: string; tag_ids?: string[] }) =>
|
|
saveTtsToLibrary(completedTtsJobId!, params),
|
|
onSuccess: () => {
|
|
message.success({
|
|
content: (
|
|
<span>
|
|
已保存到配音库!{" "}
|
|
<a
|
|
onClick={handleGoToLibrary}
|
|
style={{
|
|
color: "var(--primary-500, #6366f1)",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
去视频库查看
|
|
</a>
|
|
</span>
|
|
),
|
|
duration: 5,
|
|
})
|
|
setSaveModalOpen(false)
|
|
setSaveName("")
|
|
setSaveTagIds([])
|
|
setSaveNewTag("")
|
|
setCompletedTtsJobId(null)
|
|
setCustomAudioUrl(null)
|
|
},
|
|
onError: (err: Error) => {
|
|
message.error(`保存失败:${err.message || "请重试"}`)
|
|
},
|
|
})
|
|
|
|
/** 打开存为素材弹窗 */
|
|
const handleOpenSaveModal = useCallback(() => {
|
|
setSaveName("")
|
|
setSaveTagIds([])
|
|
setSaveNewTag("")
|
|
setSaveModalOpen(true)
|
|
}, [])
|
|
|
|
/** 确认保存 */
|
|
const handleConfirmSave = useCallback(() => {
|
|
if (!completedTtsJobId) return
|
|
saveToLibraryMutation.mutate({
|
|
name: saveName.trim() || undefined,
|
|
tag_ids: saveTagIds.length > 0 ? saveTagIds : undefined,
|
|
})
|
|
}, [completedTtsJobId, saveName, saveTagIds, saveToLibraryMutation])
|
|
|
|
/** 在弹窗中新增标签(先创建再选中) */
|
|
const handleAddTagInModal = useCallback(
|
|
async (tagName: string) => {
|
|
const trimmed = tagName.trim()
|
|
if (!trimmed) return
|
|
/* 已在选中列表则跳过 */
|
|
const existing = allTags.find((t) => t.name === trimmed)
|
|
if (existing) {
|
|
if (!saveTagIds.includes(existing.id)) {
|
|
setSaveTagIds((prev) => [...prev, existing.id])
|
|
}
|
|
return
|
|
}
|
|
try {
|
|
const created = await createTag(trimmed)
|
|
setSaveTagIds((prev) => [...prev, created.id])
|
|
setSaveNewTag("")
|
|
} catch {
|
|
message.error(`创建标签"${trimmed}"失败`)
|
|
}
|
|
},
|
|
[allTags, saveTagIds],
|
|
)
|
|
|
|
/** 保存成功后跳转到视频库 */
|
|
const handleGoToLibrary = useCallback(() => {
|
|
navigate("/app/voice-materials")
|
|
}, [navigate])
|
|
|
|
const handleGenerate = useCallback(async () => {
|
|
console.log("[handleGenerate] 开始生成, 参数:", {
|
|
titleSettings,
|
|
selectedTemplate,
|
|
selectedMaterials,
|
|
voiceMode,
|
|
})
|
|
if (!titleSettings.title.trim()) {
|
|
message.warning("请先选择或输入标题")
|
|
return
|
|
}
|
|
if (materialMode === "manual" && selectedMaterials.length === 0) {
|
|
message.warning("请至少选择一个素材")
|
|
return
|
|
}
|
|
|
|
if (voiceMode === "clone" && !selectedClonedVoice) {
|
|
message.warning("请先选择一个克隆音色")
|
|
return
|
|
}
|
|
|
|
setGenerating(true)
|
|
setProgress(0)
|
|
setGenerated(false)
|
|
setGenerateError(null)
|
|
|
|
try {
|
|
const voiceConfig: Pick<
|
|
EditPlanConfig,
|
|
"voice_id" | "voice_clone_profile_id" | "custom_audio_url" | "custom_text"
|
|
> = {}
|
|
if (voiceMode === "preset") {
|
|
voiceConfig.voice_id = selectedVoice || undefined
|
|
} else if (voiceMode === "clone") {
|
|
voiceConfig.voice_clone_profile_id = selectedClonedVoice || undefined
|
|
} else if (voiceMode === "custom") {
|
|
voiceConfig.voice_id = selectedVoice || undefined
|
|
if (customAudioUrl) voiceConfig.custom_audio_url = customAudioUrl
|
|
if (customVoiceText.trim()) voiceConfig.custom_text = customVoiceText.trim()
|
|
}
|
|
|
|
const plan = await createEditPlan({
|
|
template_id: selectedTemplate,
|
|
name: titleSettings.title.trim(),
|
|
config: {
|
|
asset_ids: selectedMaterials,
|
|
title_config: {
|
|
ai_auto_select: titleSettings.aiAutoSelect,
|
|
content: titleSettings.title,
|
|
position: titleSettings.position,
|
|
font_preset: titleSettings.font,
|
|
font_color: titleSettings.color,
|
|
font_size: titleSettings.size,
|
|
},
|
|
cover_config: coverSettings.enabled
|
|
? {
|
|
enabled: true,
|
|
mode: coverSettings.mode,
|
|
frame_time: coverSettings.frame_time,
|
|
upload_url: coverSettings.upload_url,
|
|
ai_suggested_time: coverSettings.ai_suggested_time,
|
|
thumbnail_url: coverSettings.thumbnail_url,
|
|
}
|
|
: { enabled: false },
|
|
...voiceConfig,
|
|
ratio: videoRatio,
|
|
style,
|
|
duration,
|
|
auto_subtitles: autoSubtitles,
|
|
bgm,
|
|
generate_count: generateCount,
|
|
material_mode: materialMode,
|
|
},
|
|
total_duration: duration,
|
|
source_edit_plan_id: editPlanId || undefined,
|
|
})
|
|
|
|
// 后端要求计划处于 editing 状态才能触发渲染,自动转换状态
|
|
await updateEditPlan(plan.id, { status: "editing" })
|
|
|
|
await generateEditPlan(plan.id)
|
|
|
|
const poll = async () => {
|
|
try {
|
|
const status = await apiClient.get(`/edit-plans/${plan.id}/generation-status`)
|
|
const data = status.data
|
|
|
|
if (data.plan_status === "completed") {
|
|
setProgress(100)
|
|
setGenerating(false)
|
|
setGenerated(true)
|
|
|
|
// 获取生成的视频结果
|
|
if (data.generation_task_id) {
|
|
try {
|
|
const videos = await getGenerationTaskResults(data.generation_task_id)
|
|
setGeneratedVideos(videos)
|
|
if (videos.length > 0) {
|
|
setVideoUrl(videos[0].file_url || videos[0].download_url || "")
|
|
setThumbnailUrl(videos[0].thumbnail_url || "")
|
|
}
|
|
} catch (err) {
|
|
console.error("[获取生成结果失败]", err)
|
|
}
|
|
}
|
|
|
|
message.success("视频生成完成!")
|
|
return
|
|
}
|
|
if (data.plan_status === "failed") {
|
|
setGenerating(false)
|
|
// 提取后端返回的错误详情,便于排查
|
|
// 注意:后端返回的 error_message/error/message 可能是对象而非字符串
|
|
const rawMsg =
|
|
data.error_message ||
|
|
data.error ||
|
|
data.message ||
|
|
(data.clips || []).find((c: { status: string }) => c.status === "failed")
|
|
?.error_message ||
|
|
"视频生成失败,请联系管理员或重试"
|
|
// 安全提取字符串:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等嵌套结构)
|
|
const safeExtract = (val: unknown): string => {
|
|
if (typeof val === "string") return val
|
|
if (typeof val === "object" && val !== null) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
|
|
const obj = val as Record<string, any>
|
|
if (typeof obj.message === "string") return obj.message
|
|
if (typeof obj.msg === "string") return obj.msg
|
|
if (typeof obj.detail === "string") return obj.detail
|
|
if (obj.message && typeof obj.message === "object") return safeExtract(obj.message)
|
|
return JSON.stringify(val)
|
|
}
|
|
return String(val ?? "")
|
|
}
|
|
const errorMsg = safeExtract(rawMsg)
|
|
console.error("[生成失败] planId:", plan.id, "响应:", data)
|
|
setGenerateError(errorMsg)
|
|
message.error(errorMsg)
|
|
return
|
|
}
|
|
|
|
const clips = data.clips || []
|
|
const total = clips.length || 1
|
|
const done = clips.filter((c: { status: string }) => c.status === "completed").length
|
|
setProgress(Math.round((done / total) * 100))
|
|
|
|
progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType<
|
|
typeof setInterval
|
|
>
|
|
} catch (pollErr) {
|
|
// 轮询接口本身出错(网络/鉴权等),记录并继续轮询一次
|
|
console.error("[轮询出错] planId:", plan.id, pollErr)
|
|
progressTimer.current = setTimeout(poll, 3000) as unknown as ReturnType<
|
|
typeof setInterval
|
|
>
|
|
}
|
|
}
|
|
|
|
progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType<typeof setInterval>
|
|
} catch (err: unknown) {
|
|
console.error("[handleGenerate] 生成失败:", err)
|
|
setGenerating(false)
|
|
// 提取 axios 响应中的后端错误信息
|
|
const axiosErr = err as {
|
|
response?: {
|
|
data?: {
|
|
message?: string | object
|
|
error?: string | object
|
|
detail?: string | object
|
|
msg?: string | object
|
|
}
|
|
}
|
|
message?: string
|
|
}
|
|
// 安全提取错误消息:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等)
|
|
const extractString = (val: unknown): string => {
|
|
if (typeof val === "string") return val
|
|
if (typeof val === "object" && val !== null) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
|
|
const obj = val as Record<string, any>
|
|
if (typeof obj.message === "string") return obj.message
|
|
if (typeof obj.msg === "string") return obj.msg
|
|
if (typeof obj.detail === "string") return obj.detail
|
|
// 嵌套对象:递归提取
|
|
if (typeof obj.message === "object" && obj.message !== null)
|
|
return extractString(obj.message)
|
|
if (typeof obj.msg === "object" && obj.msg !== null) return extractString(obj.msg)
|
|
return JSON.stringify(val)
|
|
}
|
|
return ""
|
|
}
|
|
const backendMsg =
|
|
extractString(axiosErr.response?.data?.message) ||
|
|
extractString(axiosErr.response?.data?.error) ||
|
|
extractString(axiosErr.response?.data?.detail) ||
|
|
extractString(axiosErr.response?.data?.msg) ||
|
|
axiosErr.message ||
|
|
""
|
|
console.error("[handleGenerate] 错误信息:", backendMsg, "完整错误:", axiosErr)
|
|
// 确保 errorMsg 一定是字符串(后端可能返回 {code, message} 嵌套对象)
|
|
const safeExtractErr = (val: unknown): string => {
|
|
if (typeof val === "string") return val
|
|
if (typeof val === "object" && val !== null) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
|
|
const obj = val as Record<string, any>
|
|
if (typeof obj.message === "string") return obj.message
|
|
if (typeof obj.msg === "string") return obj.msg
|
|
if (typeof obj.detail === "string") return obj.detail
|
|
// 嵌套对象:递归提取
|
|
if (typeof obj.message === "object") return safeExtractErr(obj.message)
|
|
return JSON.stringify(val)
|
|
}
|
|
return String(val ?? "")
|
|
}
|
|
const rawError = safeExtractErr(backendMsg)
|
|
// 将技术错误翻译为用户友好提示(不暴露状态机、字段名等内部概念)
|
|
const translateError = (msg: string): string => {
|
|
if (!msg) return "生成失败,请检查网络后重试或联系管理员"
|
|
// 状态机相关错误
|
|
if (msg.includes("editing") || msg.includes("draft") || msg.includes("状态")) {
|
|
return "正在准备生成,请稍候再试"
|
|
}
|
|
// 参数校验错误
|
|
if (msg.includes("template_id") || msg.includes("not found") || msg.includes("不存在")) {
|
|
return "所选模板或素材不可用,请重新选择"
|
|
}
|
|
if (msg.includes("asset") && (msg.includes("not found") || msg.includes("missing"))) {
|
|
return "素材数据异常,请返回视频库重新检查"
|
|
}
|
|
// 网络/超时
|
|
if (msg.includes("timeout") || msg.includes("network") || msg.includes("ECONN")) {
|
|
return "网络连接超时,请检查网络后重试"
|
|
}
|
|
// 配额/限制
|
|
if (msg.includes("quota") || msg.includes("limit") || msg.includes("exceed")) {
|
|
return "已达到生成次数上限,请稍后再试或联系客服"
|
|
}
|
|
// 兜底:返回原始消息(如果已经是中文人话)或默认提示
|
|
if (msg.length > 0 && msg.length < 100 && !msg.includes("{")) return msg
|
|
return "生成失败,请稍后重试或联系管理员"
|
|
}
|
|
const finalMsg = translateError(rawError)
|
|
setGenerateError(finalMsg)
|
|
message.error(finalMsg)
|
|
}
|
|
}, [
|
|
titleSettings,
|
|
selectedMaterials,
|
|
selectedVoice,
|
|
voiceMode,
|
|
selectedClonedVoice,
|
|
customAudioUrl,
|
|
customVoiceText,
|
|
videoRatio,
|
|
style,
|
|
duration,
|
|
autoSubtitles,
|
|
bgm,
|
|
editPlanId,
|
|
selectedTemplate,
|
|
generateCount,
|
|
materialMode,
|
|
])
|
|
|
|
/* ── 下载视频 ── */
|
|
const handleDownload = useCallback(async () => {
|
|
if (!generatedVideos.length) return
|
|
const video = generatedVideos[0]
|
|
try {
|
|
// 优先使用 download_url(签名 URL),回退到 file_url
|
|
const url = video.download_url || video.file_url
|
|
if (url) {
|
|
const a = document.createElement("a")
|
|
a.href = url
|
|
a.download = video.name || "generated-video.mp4"
|
|
a.target = "_blank"
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
}
|
|
} catch (err) {
|
|
console.error("[下载失败]", err)
|
|
message.error("下载失败,请重试")
|
|
}
|
|
}, [generatedVideos])
|
|
|
|
/* ── 分享视频 ── */
|
|
const handleShare = useCallback(async () => {
|
|
if (!generatedVideos.length) return
|
|
const video = generatedVideos[0]
|
|
const shareUrl = video.file_url || window.location.href
|
|
try {
|
|
await navigator.clipboard.writeText(shareUrl)
|
|
message.success("视频链接已复制到剪贴板")
|
|
} catch {
|
|
// fallback: 显示 URL 让用户手动复制
|
|
message.info(`视频链接: ${shareUrl}`)
|
|
}
|
|
}, [generatedVideos])
|
|
|
|
/* ── 步骤导航 ── */
|
|
const goNext = useCallback(() => {
|
|
if (currentStep === 1 && !selectedTemplate) {
|
|
message.warning("请先选择一个模板")
|
|
return
|
|
}
|
|
if (currentStep === 2 && materialMode === "manual" && selectedMaterials.length === 0) {
|
|
message.warning("请至少选择一个素材")
|
|
return
|
|
}
|
|
if (currentStep === 4 && !titleSettings.title.trim()) {
|
|
message.warning("请选择或输入标题")
|
|
return
|
|
}
|
|
if (currentStep < 7) {
|
|
setCurrentStep((s) => s + 1)
|
|
}
|
|
}, [
|
|
currentStep,
|
|
selectedTemplate,
|
|
selectedMaterials.length,
|
|
titleSettings,
|
|
materialMode,
|
|
coverSettings,
|
|
])
|
|
|
|
const goPrev = useCallback(() => {
|
|
if (currentStep > 1) {
|
|
setCurrentStep((s) => s - 1)
|
|
}
|
|
}, [currentStep])
|
|
|
|
/* ── 辅助 ── */
|
|
const getTemplateName = () =>
|
|
userTemplates.find((t) => t.id === selectedTemplate)?.name ?? "未选择"
|
|
|
|
const getVoiceName = () => {
|
|
if (voiceMode === "clone") {
|
|
const cv = clonedVoices.find((v) => v.id === selectedClonedVoice)
|
|
return cv ? cv.name : "未选择"
|
|
}
|
|
const pv = presetVoices.find((v) => v.voice_id === selectedVoice)
|
|
return pv ? pv.name : "未选择"
|
|
}
|
|
|
|
/* ================================================================
|
|
渲染 — 步骤内容
|
|
================================================================ */
|
|
|
|
/** 步骤 1:选择模板 */
|
|
const renderStep1 = () => (
|
|
<div className="xx-form-section">
|
|
<h3>🎨 选择模板</h3>
|
|
{userTemplates.length === 0 ? (
|
|
<div className="xx-empty-state">
|
|
<p>暂无可用模板</p>
|
|
<p style={{ fontSize: 13, color: "var(--text-tertiary)" }}>
|
|
请先在「剪辑计划编辑器」中创建模板
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="xx-choice-list">
|
|
{userTemplates.map((tpl) => (
|
|
<div
|
|
key={tpl.id}
|
|
className={`xx-choice-item ${selectedTemplate === tpl.id ? "selected" : ""}`}
|
|
onClick={() => setSelectedTemplate(tpl.id)}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-pressed={selectedTemplate === tpl.id}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault()
|
|
setSelectedTemplate(tpl.id)
|
|
}
|
|
}}
|
|
>
|
|
<span className="xx-choice-check">✓</span>
|
|
<div
|
|
className="xx-choice-thumb"
|
|
style={{
|
|
background: MODE_GRADIENTS[tpl.mode] || MODE_GRADIENTS.pip,
|
|
}}
|
|
>
|
|
🎬
|
|
</div>
|
|
<h4>{tpl.name}</h4>
|
|
<p>
|
|
{tpl.estimated_duration}s · {tpl.segments.length}片段
|
|
</p>
|
|
{tpl.tags.length > 0 && (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
gap: 4,
|
|
flexWrap: "wrap",
|
|
marginTop: 4,
|
|
}}
|
|
>
|
|
{tpl.tags.map((tag) => (
|
|
<span
|
|
key={tag}
|
|
style={{
|
|
fontSize: 11,
|
|
padding: "1px 6px",
|
|
borderRadius: 6,
|
|
background: "var(--bg-secondary)",
|
|
color: "var(--text-secondary)",
|
|
}}
|
|
>
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
|
|
/** 步骤 2:选择素材(双模式:手动选择 / 自动匹配) */
|
|
const renderStep2 = () => (
|
|
<div className="xx-form-section">
|
|
<h3>📦 选择素材</h3>
|
|
|
|
{/* ── 模式切换 Tab ── */}
|
|
<div className="xx-material-mode-tabs">
|
|
<button
|
|
className={`xx-material-mode-tab ${materialMode === "manual" ? "active" : ""}`}
|
|
onClick={() => setMaterialMode("manual")}
|
|
type="button"
|
|
>
|
|
手动选择素材
|
|
</button>
|
|
<button
|
|
className={`xx-material-mode-tab ${materialMode === "auto" ? "active" : ""}`}
|
|
onClick={() => setMaterialMode("auto")}
|
|
type="button"
|
|
>
|
|
选择视频库自动匹配
|
|
</button>
|
|
</div>
|
|
|
|
{/* ── 视频库选择(两种模式共用) ── */}
|
|
<div className="xx-form-field" style={{ marginTop: 12 }}>
|
|
<label>选择视频库</label>
|
|
<select value={selectedLibraryId} onChange={(e) => setSelectedLibraryId(e.target.value)}>
|
|
{libraries.map((lib) => (
|
|
<option key={lib.id} value={lib.id}>
|
|
{lib.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* ── 手动选择模式 ── */}
|
|
{materialMode === "manual" && (
|
|
<>
|
|
<div
|
|
style={{
|
|
marginTop: 14,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 10,
|
|
}}
|
|
>
|
|
<span className="xx-pill xx-pill-ok">已选 {selectedMaterials.length} 个素材</span>
|
|
</div>
|
|
|
|
{/* 素材列表 */}
|
|
<div style={{ marginTop: 14 }}>
|
|
{materialsLoading ? (
|
|
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>加载素材中…</Text>
|
|
) : materials.items.length === 0 ? (
|
|
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>
|
|
暂无素材,请先在视频库中上传
|
|
</Text>
|
|
) : (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|
{materials.items.map((m) => {
|
|
const checked = selectedMaterials.includes(m.id)
|
|
return (
|
|
<label
|
|
key={m.id}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 10,
|
|
padding: "8px 12px",
|
|
background: checked ? "var(--primary-soft, #eef2ff)" : "#f8fafc",
|
|
borderRadius: 10,
|
|
cursor: "pointer",
|
|
border: checked
|
|
? "1px solid var(--primary-color, #4f46e5)"
|
|
: "1px solid transparent",
|
|
transition: "all 0.15s ease",
|
|
}}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={checked}
|
|
onChange={() => {
|
|
setSelectedMaterials((prev) =>
|
|
prev.includes(m.id)
|
|
? prev.filter((id) => id !== m.id)
|
|
: [...prev, m.id],
|
|
)
|
|
}}
|
|
style={{ accentColor: "var(--primary-color, #4f46e5)" }}
|
|
/>
|
|
<span
|
|
style={{
|
|
fontSize: 13,
|
|
color: "var(--text-primary)",
|
|
flex: 1,
|
|
}}
|
|
>
|
|
{m.name}
|
|
</span>
|
|
<span
|
|
style={{
|
|
fontSize: 11,
|
|
color: "var(--text-tertiary, #94a3b8)",
|
|
}}
|
|
>
|
|
{m.mime_type.split("/")[1].toUpperCase()}
|
|
</span>
|
|
</label>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* ── 自动匹配模式 ── */}
|
|
{materialMode === "auto" && (
|
|
<div className="xx-auto-match-card">
|
|
<div className="xx-auto-match-icon">🤖</div>
|
|
<div className="xx-auto-match-body">
|
|
<h4 className="xx-auto-match-title">智能素材匹配</h4>
|
|
<p className="xx-auto-match-desc">
|
|
系统将根据所选模板和标题,从视频库中自动分析并匹配最合适的素材进行视频生成。
|
|
无需手动挑选,AI 会综合素材质量、时长、内容相关性等维度进行智能筛选。
|
|
</p>
|
|
<div className="xx-auto-match-features">
|
|
<span className="xx-auto-match-feature">📊 质量评分筛选</span>
|
|
<span className="xx-auto-match-feature">🎯 内容相关性匹配</span>
|
|
<span className="xx-auto-match-feature">⏱️ 时长智能分配</span>
|
|
</div>
|
|
</div>
|
|
{materialsLoading ? (
|
|
<Text
|
|
style={{
|
|
color: "var(--text-secondary)",
|
|
fontSize: 12,
|
|
marginTop: 8,
|
|
}}
|
|
>
|
|
扫描视频库中…
|
|
</Text>
|
|
) : (
|
|
<Text
|
|
style={{
|
|
color: "var(--text-tertiary, #94a3b8)",
|
|
fontSize: 12,
|
|
marginTop: 8,
|
|
}}
|
|
>
|
|
当前视频库共 {materials.items.length} 个素材可供匹配
|
|
</Text>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
|
|
/** 步骤 3:生成预览 */
|
|
const renderStep3 = () => (
|
|
<div className="xx-form-section">
|
|
<h3>🎬 生成预览</h3>
|
|
<div className="xx-preview-tip">
|
|
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 8 }} />
|
|
<span>素材已选好,AI 将为您智能匹配剪辑方案</span>
|
|
</div>
|
|
<div className="xx-preview-plan-card">
|
|
<div className="xx-preview-plan-title">剪辑计划预览</div>
|
|
<div className="xx-preview-plan-info">
|
|
<div className="xx-preview-plan-row">
|
|
<span className="xx-preview-plan-label">模板</span>
|
|
<span className="xx-preview-plan-value">{getTemplateName()}</span>
|
|
</div>
|
|
<div className="xx-preview-plan-row">
|
|
<span className="xx-preview-plan-label">素材数量</span>
|
|
<span className="xx-preview-plan-value">
|
|
{materialMode === "auto" ? "AI自动匹配" : `${selectedMaterials.length} 个素材`}
|
|
</span>
|
|
</div>
|
|
<div className="xx-preview-plan-row">
|
|
<span className="xx-preview-plan-label">预计时长</span>
|
|
<span className="xx-preview-plan-value">{duration} 秒</span>
|
|
</div>
|
|
<div className="xx-preview-plan-row">
|
|
<span className="xx-preview-plan-label">视频比例</span>
|
|
<span className="xx-preview-plan-value">{videoRatio}</span>
|
|
</div>
|
|
</div>
|
|
<div className="xx-preview-plan-hint">
|
|
💡 点击「下一步」进入标题设置,AI 将根据素材内容为您推荐标题
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
/** 步骤 4:选择标题 + 标题样式设置 */
|
|
const renderStep4 = () => (
|
|
<div className="xx-form-section">
|
|
<h3>📝 选择标题</h3>
|
|
|
|
{/* AI 自动选择开关 */}
|
|
<div className="xx-title-ai-toggle">
|
|
<span className="xx-toggle-label">AI 自动选择标题</span>
|
|
<div
|
|
className={`xx-switch ${titleSettings.aiAutoSelect ? "active" : ""}`}
|
|
onClick={() =>
|
|
setTitleSettings((prev) => ({ ...prev, aiAutoSelect: !prev.aiAutoSelect }))
|
|
}
|
|
>
|
|
<div className="xx-switch-knob" />
|
|
</div>
|
|
</div>
|
|
|
|
{!titleSettings.aiAutoSelect && (
|
|
<>
|
|
{/* 标题内容选择 */}
|
|
<div className="xx-form-field">
|
|
<label>从标题库选择</label>
|
|
<Select
|
|
placeholder="请选择标题…"
|
|
allowClear
|
|
showSearch
|
|
style={{ width: "100%" }}
|
|
value={titleSettings.title || undefined}
|
|
onChange={(val) => setTitleSettings((prev) => ({ ...prev, title: val || "" }))}
|
|
options={userTitles.map((t) => ({
|
|
label: t.content,
|
|
value: t.content,
|
|
}))}
|
|
filterOption={(input, option) =>
|
|
((option?.label as string) || "").toLowerCase().includes(input.toLowerCase())
|
|
}
|
|
notFoundContent={
|
|
userTitles.length === 0 ? (
|
|
<span style={{ color: "var(--text-tertiary)", fontSize: 13 }}>
|
|
标题库为空,请前往「标题管理」添加
|
|
</span>
|
|
) : null
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="xx-form-field" style={{ marginTop: 14 }}>
|
|
<label>或手动输入</label>
|
|
<input
|
|
placeholder="输入自定义标题…"
|
|
value={titleSettings.title}
|
|
onChange={(e) => setTitleSettings((prev) => ({ ...prev, title: e.target.value }))}
|
|
maxLength={50}
|
|
/>
|
|
</div>
|
|
|
|
{/* 标题样式设置区 */}
|
|
<div className="xx-title-style-section">
|
|
<h4 className="xx-section-subtitle">标题样式</h4>
|
|
|
|
{/* 位置 + 字体 一行 */}
|
|
<div className="xx-title-style-row">
|
|
<div className="xx-form-field xx-half-field">
|
|
<label>位置</label>
|
|
<select
|
|
className="xx-form-select"
|
|
value={titleSettings.position}
|
|
onChange={(e) =>
|
|
setTitleSettings((prev) => ({ ...prev, position: e.target.value }))
|
|
}
|
|
>
|
|
{POSITION_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="xx-form-field xx-half-field">
|
|
<label>字体</label>
|
|
<select
|
|
className="xx-form-select"
|
|
value={titleSettings.font}
|
|
onChange={(e) => setTitleSettings((prev) => ({ ...prev, font: e.target.value }))}
|
|
>
|
|
{FONT_OPTIONS.map((f) => (
|
|
<option key={f} value={f}>
|
|
{f}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 字号滑块 */}
|
|
<div className="xx-form-field">
|
|
<div className="xx-field-label-row">
|
|
<label>字号</label>
|
|
<span className="xx-field-value">{titleSettings.size}px</span>
|
|
</div>
|
|
<input
|
|
className="xx-slider"
|
|
type="range"
|
|
min={12}
|
|
max={48}
|
|
value={titleSettings.size}
|
|
onChange={(e) =>
|
|
setTitleSettings((prev) => ({
|
|
...prev,
|
|
size: Number(e.target.value),
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{/* 预设样式 */}
|
|
<div className="xx-form-field">
|
|
<label>预设样式</label>
|
|
<div className="xx-title-presets-grid">
|
|
{TITLE_PRESETS.map((p) => {
|
|
const isActive = getActivePreset(titleSettings) === p.key
|
|
return (
|
|
<button
|
|
key={p.key}
|
|
className={`xx-title-preset-card${isActive ? " active" : ""}`}
|
|
onClick={() =>
|
|
setTitleSettings((prev) => ({
|
|
...prev,
|
|
size: p.style.size,
|
|
color: p.style.color,
|
|
bold: p.style.bold,
|
|
italic: p.style.italic,
|
|
stroke: p.style.stroke,
|
|
shadow: p.style.shadow,
|
|
}))
|
|
}
|
|
title={p.label}
|
|
>
|
|
<span
|
|
className="xx-title-preset-preview-text"
|
|
style={p.previewStyle as React.CSSProperties}
|
|
>
|
|
标题
|
|
</span>
|
|
<span className="xx-title-preset-card-label">{p.label}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 样式按钮:粗体/斜体/描边/阴影 */}
|
|
<div className="xx-form-field">
|
|
<label>样式</label>
|
|
<div className="xx-style-btns">
|
|
<button
|
|
className={`xx-style-btn ${titleSettings.bold ? "active" : ""}`}
|
|
onClick={() => setTitleSettings((prev) => ({ ...prev, bold: !prev.bold }))}
|
|
title="粗体"
|
|
>
|
|
<b>B</b>
|
|
</button>
|
|
<button
|
|
className={`xx-style-btn ${titleSettings.italic ? "active" : ""}`}
|
|
onClick={() => setTitleSettings((prev) => ({ ...prev, italic: !prev.italic }))}
|
|
title="斜体"
|
|
>
|
|
<i>I</i>
|
|
</button>
|
|
<button
|
|
className={`xx-style-btn ${titleSettings.stroke ? "active" : ""}`}
|
|
onClick={() => setTitleSettings((prev) => ({ ...prev, stroke: !prev.stroke }))}
|
|
title="描边"
|
|
>
|
|
S
|
|
</button>
|
|
<button
|
|
className={`xx-style-btn ${titleSettings.shadow ? "active" : ""}`}
|
|
onClick={() => setTitleSettings((prev) => ({ ...prev, shadow: !prev.shadow }))}
|
|
title="阴影"
|
|
>
|
|
☁
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
|
|
/** 步骤 4:选择配音 */
|
|
const renderStep5 = () => (
|
|
<div className="xx-form-section">
|
|
<h3>🎙️ 选择配音</h3>
|
|
|
|
{/* 配音方式选择卡片 — 从 API 预设音色动态生成 */}
|
|
<div className="xx-voice-choice-list" style={{ marginBottom: 16 }}>
|
|
{presetVoices.slice(0, 3).map((v) => (
|
|
<div
|
|
key={v.voice_id}
|
|
className={`xx-voice-choice-item ${voiceMode === "preset" && selectedVoice === v.voice_id ? "selected" : ""}`}
|
|
onClick={() => {
|
|
setVoiceMode("preset")
|
|
setSelectedVoice(v.voice_id)
|
|
}}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-pressed={voiceMode === "preset" && selectedVoice === v.voice_id}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault()
|
|
setVoiceMode("preset")
|
|
setSelectedVoice(v.voice_id)
|
|
}
|
|
}}
|
|
>
|
|
<span className="xx-voice-choice-check">✓</span>
|
|
<div className="xx-voice-choice-avatar">{VOICE_GENDER_ICON[v.gender] ?? "✨"}</div>
|
|
<div className="xx-voice-choice-info">
|
|
<h4>{v.name}</h4>
|
|
<p>{v.description}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{presetVoicesLoading && (
|
|
<div className="xx-voice-choice-item" style={{ opacity: 0.5 }}>
|
|
<div className="xx-voice-choice-avatar">⏳</div>
|
|
<div className="xx-voice-choice-info">
|
|
<h4>加载中…</h4>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* 克隆我的声音 */}
|
|
<div
|
|
className={`xx-voice-choice-item ${voiceMode === "clone" ? "selected" : ""}`}
|
|
onClick={() => setVoiceMode("clone")}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-pressed={voiceMode === "clone"}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault()
|
|
setVoiceMode("clone")
|
|
}
|
|
}}
|
|
>
|
|
<span className="xx-voice-choice-check">✓</span>
|
|
<div
|
|
className="xx-voice-choice-avatar"
|
|
style={{ background: "linear-gradient(135deg, #10b981, #059669)" }}
|
|
>
|
|
🎤
|
|
</div>
|
|
<div className="xx-voice-choice-info">
|
|
<h4>克隆我的声音</h4>
|
|
<p>上传语音样本克隆</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 预设音色详细列表(当 voiceMode === preset 时显示 API 返回的音色) */}
|
|
{voiceMode === "preset" && (
|
|
<div>
|
|
<div className="xx-form-field">
|
|
<label>从配音库选择</label>
|
|
<select value={selectedVoice} onChange={(e) => setSelectedVoice(e.target.value)}>
|
|
<option value="">请选择配音…</option>
|
|
{presetVoicesLoading ? (
|
|
<option disabled>加载中…</option>
|
|
) : (
|
|
presetVoices.map((v) => (
|
|
<option key={v.voice_id} value={v.voice_id}>
|
|
{v.name} — {v.description}
|
|
</option>
|
|
))
|
|
)}
|
|
</select>
|
|
</div>
|
|
{/* 试听按钮 */}
|
|
{presetVoices.length > 0 && (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
gap: 8,
|
|
flexWrap: "wrap",
|
|
marginTop: 8,
|
|
}}
|
|
>
|
|
{presetVoices.slice(0, 4).map((v) => (
|
|
<button
|
|
key={v.voice_id}
|
|
className="xx-btn xx-btn-ghost"
|
|
style={{ height: 32, padding: "0 12px", fontSize: 12 }}
|
|
onClick={() => toggleVoicePlay(v.voice_id, v.preview_url)}
|
|
>
|
|
{playingVoice === v.voice_id ? (
|
|
<>
|
|
<PauseCircleOutlined /> 停止
|
|
</>
|
|
) : (
|
|
<>
|
|
<PlayCircleOutlined /> {v.name}
|
|
</>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 自定义录制 */}
|
|
{voiceMode === "custom" && (
|
|
<div>
|
|
<textarea
|
|
placeholder="输入配音文案,点击合成按钮生成语音…"
|
|
value={customVoiceText}
|
|
onChange={(e) => setCustomVoiceText(e.target.value)}
|
|
maxLength={500}
|
|
style={{
|
|
width: "100%",
|
|
minHeight: 100,
|
|
border: "1px solid var(--border-color, #e2e8f0)",
|
|
borderRadius: "var(--radius-sm, 10px)",
|
|
padding: 12,
|
|
fontSize: 14,
|
|
resize: "vertical",
|
|
outline: "none",
|
|
}}
|
|
/>
|
|
<div style={{ marginTop: 12, display: "flex", gap: 8 }}>
|
|
<button
|
|
className="xx-btn xx-btn-ghost"
|
|
disabled={!customVoiceText.trim() || synthesizeMutation.isPending}
|
|
onClick={handleSynthesizeVoice}
|
|
>
|
|
<AudioOutlined /> {synthesizeMutation.isPending ? "合成中…" : "合成语音"}
|
|
</button>
|
|
</div>
|
|
{ttsError && (
|
|
<Text
|
|
style={{
|
|
color: "var(--error, #ef4444)",
|
|
marginTop: 8,
|
|
display: "block",
|
|
}}
|
|
>
|
|
{ttsError}
|
|
</Text>
|
|
)}
|
|
{customAudioUrl && completedTtsJobId && (
|
|
<div
|
|
style={{
|
|
marginTop: 8,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 12,
|
|
}}
|
|
>
|
|
<Text style={{ color: "var(--success, #10b981)" }}>✓ 语音合成完成</Text>
|
|
<button
|
|
className="xx-btn xx-btn-primary"
|
|
style={{ height: 30, padding: "0 14px", fontSize: 12 }}
|
|
onClick={handleOpenSaveModal}
|
|
>
|
|
<SaveOutlined /> 存为素材
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── 存为素材弹窗 ── */}
|
|
{saveModalOpen && (
|
|
<div className="xx-save-modal-overlay" onClick={() => setSaveModalOpen(false)}>
|
|
<div className="xx-save-modal" onClick={(e) => e.stopPropagation()}>
|
|
<div className="xx-save-modal-header">
|
|
<span>保存到配音库</span>
|
|
<button className="xx-save-modal-close" onClick={() => setSaveModalOpen(false)}>
|
|
<CloseOutlined />
|
|
</button>
|
|
</div>
|
|
<div className="xx-save-modal-body">
|
|
<label className="xx-save-modal-label">素材名称</label>
|
|
<input
|
|
className="xx-save-modal-input"
|
|
placeholder="留空则自动生成名称"
|
|
value={saveName}
|
|
onChange={(e) => setSaveName(e.target.value)}
|
|
maxLength={50}
|
|
/>
|
|
<label className="xx-save-modal-label">
|
|
标签
|
|
<span
|
|
style={{
|
|
fontWeight: 400,
|
|
color: "var(--text-tertiary, #94a3b8)",
|
|
}}
|
|
>
|
|
(可选)
|
|
</span>
|
|
</label>
|
|
<div className="xx-save-modal-tags">
|
|
{saveTagIds.map((id) => {
|
|
const tag = allTags.find((t) => t.id === id)
|
|
return tag ? (
|
|
<span key={id} className="xx-save-modal-tag active">
|
|
{tag.name}
|
|
<CloseOutlined
|
|
className="xx-save-modal-tag-remove"
|
|
onClick={() => setSaveTagIds((prev) => prev.filter((x) => x !== id))}
|
|
/>
|
|
</span>
|
|
) : null
|
|
})}
|
|
<input
|
|
className="xx-save-modal-tag-input"
|
|
placeholder="输入标签名回车添加"
|
|
value={saveNewTag}
|
|
onChange={(e) => setSaveNewTag(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault()
|
|
handleAddTagInModal(saveNewTag)
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
{allTags.length > 0 && (
|
|
<div className="xx-save-modal-tag-presets">
|
|
{allTags
|
|
.filter((t) => !saveTagIds.includes(t.id))
|
|
.slice(0, 12)
|
|
.map((t) => (
|
|
<button
|
|
key={t.id}
|
|
className="xx-save-modal-tag-preset"
|
|
onClick={() => setSaveTagIds((prev) => [...prev, t.id])}
|
|
>
|
|
{t.name}
|
|
<PlusOutlined style={{ fontSize: 10, marginLeft: 4 }} />
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="xx-save-modal-footer">
|
|
<button className="xx-btn xx-btn-ghost" onClick={() => setSaveModalOpen(false)}>
|
|
取消
|
|
</button>
|
|
<button
|
|
className="xx-btn xx-btn-primary"
|
|
disabled={saveToLibraryMutation.isPending}
|
|
onClick={handleConfirmSave}
|
|
>
|
|
{saveToLibraryMutation.isPending ? "保存中…" : "保存"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 克隆声音展开区域 */}
|
|
{voiceMode === "clone" && (
|
|
<div className="xx-clone-section">
|
|
<p className="xx-clone-section-title">克隆我的声音</p>
|
|
<Text style={{ fontSize: 12, color: "var(--text-tertiary, #94a3b8)" }}>
|
|
上传一段您的语音样本,AI 将克隆您的声音用于视频配音
|
|
</Text>
|
|
|
|
<div style={{ marginTop: 12 }}>
|
|
<button
|
|
className="xx-btn xx-btn-primary"
|
|
style={{ height: 36, fontSize: 13 }}
|
|
onClick={() => setCloneModalOpen(true)}
|
|
>
|
|
<ThunderboltOutlined /> 克隆新声音
|
|
</button>
|
|
</div>
|
|
|
|
{/* 轮询提示 */}
|
|
{hasProcessing && (
|
|
<div className="xx-clone-polling-hint" style={{ marginTop: 10 }}>
|
|
<span className="xx-clone-polling-dot" />
|
|
正在同步克隆进度...
|
|
</div>
|
|
)}
|
|
|
|
{/* 已克隆声音列表 */}
|
|
{clonedVoices.length > 0 && (
|
|
<div className="xx-clone-voices-list">
|
|
{clonedVoices.map((cv) => {
|
|
const statusCfg = CLONE_STATUS_CONFIG[cv.status]
|
|
const isReady = cv.status === "ready"
|
|
const selected = selectedClonedVoice === cv.id
|
|
return (
|
|
<div
|
|
key={cv.id}
|
|
className={`xx-clone-voice-row ${selected ? "selected" : ""} ${!isReady ? "disabled" : ""}`}
|
|
onClick={() => {
|
|
if (isReady) setSelectedClonedVoice(cv.id)
|
|
}}
|
|
role="button"
|
|
tabIndex={isReady ? 0 : -1}
|
|
aria-pressed={selected}
|
|
>
|
|
<div className={`xx-clone-avatar ${cv.status}`}>
|
|
<AudioOutlined />
|
|
</div>
|
|
<div className="xx-clone-info">
|
|
<div className="xx-clone-name">{cv.name}</div>
|
|
<div className="xx-clone-status">
|
|
<span
|
|
className="xx-clone-status-dot"
|
|
style={{ background: statusCfg.color }}
|
|
/>
|
|
<span style={{ color: statusCfg.color }}>{statusCfg.label}</span>
|
|
{isReady && (
|
|
<span
|
|
style={{
|
|
color: "var(--text-secondary)",
|
|
marginLeft: 8,
|
|
}}
|
|
>
|
|
{formatDuration(cv.duration_seconds)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{selected && isReady && (
|
|
<CheckCircleFilled style={{ color: "var(--primary-color, #4f46e5)" }} />
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{clonedVoices.length === 0 && (
|
|
<Text
|
|
style={{
|
|
color: "var(--text-secondary)",
|
|
display: "block",
|
|
textAlign: "center",
|
|
padding: "16px 0",
|
|
}}
|
|
>
|
|
暂无克隆音色,点击「克隆新声音」开始
|
|
</Text>
|
|
)}
|
|
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
color: "var(--text-tertiary, #94a3b8)",
|
|
marginTop: 10,
|
|
display: "block",
|
|
}}
|
|
>
|
|
💡 提示:录音环境越安静,克隆效果越好。
|
|
</Text>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
|
|
/** 步骤 5:选择封面 */
|
|
const renderStep6 = () => {
|
|
const formatTime = (seconds: number) => {
|
|
const m = Math.floor(seconds / 60)
|
|
const s = Math.floor(seconds % 60)
|
|
const ms = Math.floor((seconds % 1) * 10)
|
|
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`
|
|
}
|
|
|
|
const totalDuration = duration || 30
|
|
|
|
return (
|
|
<div className="xx-form-section">
|
|
<h3>🖼️ 选择封面</h3>
|
|
|
|
{/* 启用开关 */}
|
|
<div className="xx-cover-header">
|
|
<span className="xx-cover-header-label">启用自定义封面</span>
|
|
<label className="xx-switch">
|
|
<input
|
|
type="checkbox"
|
|
checked={coverSettings.enabled}
|
|
onChange={(e) => setCoverSettings((prev) => ({ ...prev, enabled: e.target.checked }))}
|
|
/>
|
|
<span className="xx-switch-slider" />
|
|
</label>
|
|
</div>
|
|
|
|
{coverSettings.enabled && (
|
|
<>
|
|
{/* 模式选择 */}
|
|
<div className="xx-section-title">封面来源</div>
|
|
<div className="xx-cover-mode-tabs">
|
|
{(["auto", "frame", "upload"] as const).map((m) => (
|
|
<button
|
|
key={m}
|
|
className={`xx-cover-mode-tab${coverSettings.mode === m ? " active" : ""}`}
|
|
onClick={() => setCoverSettings((prev) => ({ ...prev, mode: m }))}
|
|
>
|
|
<span className="xx-cover-mode-icon">{COVER_MODE_ICONS[m]}</span>
|
|
<span className="xx-cover-mode-label">{COVER_MODE_LABELS[m]}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* 智能封面 */}
|
|
{coverSettings.mode === "auto" && (
|
|
<div className="xx-cover-auto">
|
|
<div className="xx-cover-auto-desc">
|
|
AI 将分析视频内容,自动选择最具吸引力的画面作为封面。
|
|
</div>
|
|
<div className="xx-cover-auto-badge">
|
|
<span style={{ fontSize: 24 }}>🤖</span>
|
|
<span>AI 智能选帧</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 抽帧选封面 */}
|
|
{coverSettings.mode === "frame" && (
|
|
<div className="xx-cover-frame">
|
|
<div className="xx-cover-frame-preview">
|
|
<div className="xx-cover-frame-placeholder">
|
|
<span className="xx-cover-frame-icon">🎞️</span>
|
|
<span className="xx-cover-frame-time">
|
|
{formatTime(coverSettings.frame_time)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="xx-cover-frame-slider">
|
|
<div className="xx-cover-frame-slider-header">
|
|
<span>拖动选择封面帧</span>
|
|
<span className="xx-cover-frame-value">
|
|
{formatTime(coverSettings.frame_time)}
|
|
</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={Math.max(totalDuration, 1)}
|
|
step={0.1}
|
|
value={coverSettings.frame_time}
|
|
onChange={(e) =>
|
|
setCoverSettings((prev) => ({
|
|
...prev,
|
|
frame_time: Number(e.target.value),
|
|
}))
|
|
}
|
|
className="xx-cover-range"
|
|
/>
|
|
<div className="xx-cover-frame-range">
|
|
<span>00:00</span>
|
|
<span>{formatTime(totalDuration)}</span>
|
|
</div>
|
|
</div>
|
|
<div className="xx-cover-frame-quick">
|
|
<span className="xx-cover-quick-label">快捷选帧:</span>
|
|
{[0, 0.25, 0.5, 0.75].map((ratio) => {
|
|
const t = totalDuration * ratio
|
|
return (
|
|
<button
|
|
key={ratio}
|
|
className="xx-cover-quick-btn"
|
|
onClick={() => setCoverSettings((prev) => ({ ...prev, frame_time: t }))}
|
|
>
|
|
{formatTime(t)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 上传封面 */}
|
|
{coverSettings.mode === "upload" && (
|
|
<div className="xx-cover-upload">
|
|
<div
|
|
className="xx-cover-upload-area"
|
|
onClick={() => {
|
|
const input = document.getElementById("cover-upload-input")
|
|
input?.click()
|
|
}}
|
|
>
|
|
{coverSettings.upload_url ? (
|
|
<div className="xx-cover-upload-preview">
|
|
<img src={coverSettings.upload_url} alt="封面预览" />
|
|
<div className="xx-cover-upload-overlay">点击更换</div>
|
|
</div>
|
|
) : (
|
|
<div className="xx-cover-upload-placeholder">
|
|
<span style={{ fontSize: 32 }}>📤</span>
|
|
<span className="xx-cover-upload-text">点击上传封面图片</span>
|
|
<span className="xx-cover-upload-hint">支持 JPG / PNG,建议 16:9 比例</span>
|
|
</div>
|
|
)}
|
|
<input
|
|
id="cover-upload-input"
|
|
type="file"
|
|
accept="image/*"
|
|
style={{ display: "none" }}
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0]
|
|
if (file) {
|
|
const reader = new FileReader()
|
|
reader.onload = (ev) => {
|
|
const url = ev.target?.result as string
|
|
setCoverSettings((prev) => ({
|
|
...prev,
|
|
upload_url: url,
|
|
thumbnail_url: url,
|
|
mode: "upload",
|
|
}))
|
|
}
|
|
reader.readAsDataURL(file)
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 封面预览 */}
|
|
<div className="xx-section-title">封面预览</div>
|
|
<div className="xx-cover-preview-box">
|
|
{coverSettings.upload_url ? (
|
|
<img
|
|
src={coverSettings.upload_url}
|
|
alt="封面预览"
|
|
className="xx-cover-preview-img"
|
|
/>
|
|
) : (
|
|
<div className="xx-cover-preview-placeholder">
|
|
<span style={{ fontSize: 28 }}>🖼️</span>
|
|
<span>
|
|
{coverSettings.mode === "auto"
|
|
? "AI 智能选择"
|
|
: coverSettings.mode === "frame"
|
|
? `帧 ${formatTime(coverSettings.frame_time)}`
|
|
: "未上传封面"}
|
|
</span>
|
|
</div>
|
|
)}
|
|
<div className="xx-cover-preview-ratio">16:9</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** 步骤 6:确认生成 */
|
|
const renderStep7 = () => (
|
|
<div className="xx-form-section">
|
|
<h3>✨ 确认生成</h3>
|
|
<div className="xx-summary-card">
|
|
<div className="xx-summary-row">
|
|
<span className="xx-summary-label">模板</span>
|
|
<span className="xx-summary-value">{getTemplateName()}</span>
|
|
</div>
|
|
<div className="xx-summary-row">
|
|
<span className="xx-summary-label">素材</span>
|
|
<span className="xx-summary-value">
|
|
{materialMode === "auto" ? "自动匹配" : `${selectedMaterials.length} 个素材`}
|
|
</span>
|
|
</div>
|
|
<div className="xx-summary-row">
|
|
<span className="xx-summary-label">标题</span>
|
|
<span className="xx-summary-value">{titleSettings.title || "未选择"}</span>
|
|
</div>
|
|
<div className="xx-summary-row">
|
|
<span className="xx-summary-label">配音</span>
|
|
<span className="xx-summary-value">{getVoiceName()}</span>
|
|
</div>
|
|
<div className="xx-summary-row">
|
|
<span className="xx-summary-label">封面</span>
|
|
<span className="xx-summary-value">
|
|
{coverSettings.enabled ? COVER_MODE_LABELS[coverSettings.mode] || "智能封面" : "不使用"}
|
|
</span>
|
|
</div>
|
|
<div className="xx-summary-row">
|
|
<span className="xx-summary-label">生成数量</span>
|
|
<span className="xx-summary-value">
|
|
<div className="xx-count-stepper">
|
|
<button
|
|
className="xx-count-stepper-btn"
|
|
disabled={generateCount <= 1 || generating}
|
|
onClick={() => setGenerateCount((c) => Math.max(1, c - 1))}
|
|
>
|
|
<MinusOutlined />
|
|
</button>
|
|
<span className="xx-count-stepper-value">{generateCount}</span>
|
|
<button
|
|
className="xx-count-stepper-btn"
|
|
disabled={generateCount >= 10 || generating}
|
|
onClick={() => setGenerateCount((c) => Math.min(10, c + 1))}
|
|
>
|
|
<PlusOutlined />
|
|
</button>
|
|
<span className="xx-count-stepper-hint">条视频</span>
|
|
</div>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 生成进度 / 结果反馈 */}
|
|
{(generating || generated || generateError) && (
|
|
<div style={{ marginTop: 16 }}>
|
|
{generating && (
|
|
<>
|
|
<div className="xx-progress-bar">
|
|
<div
|
|
className="xx-progress-bar-fill"
|
|
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
|
|
/>
|
|
</div>
|
|
<Text style={{ color: "var(--text-secondary)", fontSize: 13 }}>
|
|
<LoadingOutlined style={{ marginRight: 6 }} />
|
|
正在生成视频,请稍候… {Math.round(progress)}%
|
|
</Text>
|
|
</>
|
|
)}
|
|
{generated && !generating && (
|
|
<div
|
|
style={{
|
|
padding: "12px 16px",
|
|
borderRadius: 8,
|
|
background: "rgba(82, 196, 26, 0.08)",
|
|
border: "1px solid rgba(82, 196, 26, 0.3)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
}}
|
|
>
|
|
<CheckCircleOutlined style={{ color: "#52c41a", fontSize: 18 }} />
|
|
<div>
|
|
<Text strong style={{ color: "#52c41a", display: "block", fontSize: 14 }}>
|
|
视频生成完成!
|
|
</Text>
|
|
<Text style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
|
可在右侧预览或前往成片库查看
|
|
</Text>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{generateError && !generating && (
|
|
<div
|
|
style={{
|
|
padding: "12px 16px",
|
|
borderRadius: 8,
|
|
background: "rgba(255, 77, 79, 0.08)",
|
|
border: "1px solid rgba(255, 77, 79, 0.3)",
|
|
display: "flex",
|
|
alignItems: "flex-start",
|
|
gap: 8,
|
|
}}
|
|
>
|
|
<CloseCircleOutlined
|
|
style={{
|
|
color: "#ff4d4f",
|
|
fontSize: 18,
|
|
marginTop: 2,
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
<div>
|
|
<Text strong style={{ color: "#ff4d4f", display: "block", fontSize: 14 }}>
|
|
生成失败
|
|
</Text>
|
|
<Text style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
|
{typeof generateError === "string"
|
|
? generateError
|
|
: JSON.stringify(generateError)}
|
|
</Text>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
|
|
/** 渲染当前步骤 */
|
|
const renderCurrentStep = () => {
|
|
switch (currentStep) {
|
|
case 1:
|
|
return renderStep1()
|
|
case 2:
|
|
return renderStep2()
|
|
case 3:
|
|
return renderStep3()
|
|
case 4:
|
|
return renderStep4()
|
|
case 5:
|
|
return renderStep5()
|
|
case 6:
|
|
return renderStep6()
|
|
case 7:
|
|
return renderStep7()
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
/* ================================================================
|
|
渲染 — 主页面
|
|
================================================================ */
|
|
|
|
return (
|
|
<div className="xx-generate-page">
|
|
{/* ── 页头 ── */}
|
|
<div className="xx-generate-head">
|
|
<div>
|
|
<h2>
|
|
<ThunderboltOutlined style={{ marginRight: 8 }} />
|
|
智能剪辑
|
|
</h2>
|
|
<p>快速生成短视频,支持多种风格和素材组合</p>
|
|
</div>
|
|
{editPlanId && (
|
|
<span
|
|
style={{
|
|
background: "#dbeafe",
|
|
color: "#1d4ed8",
|
|
fontSize: 12,
|
|
padding: "4px 10px",
|
|
borderRadius: 999,
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
🎬 来自剪辑计划
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── 步骤条 ── */}
|
|
<div className="xx-steps-bar">
|
|
{STEPS.map((step, idx) => {
|
|
const isActive = currentStep === step.key
|
|
const isDone = currentStep > step.key
|
|
const cls = ["xx-step-item", isActive ? "active" : "", isDone ? "done" : ""]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
return (
|
|
<React.Fragment key={step.key}>
|
|
{idx > 0 && <span className="xx-step-arrow">→</span>}
|
|
<div
|
|
className={cls}
|
|
onClick={() => {
|
|
// 允许点击已完成的步骤回退
|
|
if (isDone) setCurrentStep(step.key)
|
|
}}
|
|
role="button"
|
|
tabIndex={0}
|
|
>
|
|
<div className="xx-step-num">{isDone ? "✓" : step.key}</div>
|
|
<span className="xx-step-label">{step.label}</span>
|
|
</div>
|
|
</React.Fragment>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* ── 主布局 ── */}
|
|
<div className="xx-generate-layout">
|
|
{/* ════ 左侧:表单区 ════ */}
|
|
<div className="xx-generate-form">
|
|
{/* 当前步骤 */}
|
|
{renderCurrentStep()}
|
|
|
|
{/* 底部操作按钮 */}
|
|
<div className="xx-step-actions">
|
|
<button className="xx-btn xx-btn-ghost" onClick={goPrev} disabled={currentStep === 1}>
|
|
← 上一步
|
|
</button>
|
|
{currentStep < 7 ? (
|
|
<button className="xx-btn xx-btn-primary" onClick={goNext}>
|
|
下一步 →
|
|
</button>
|
|
) : (
|
|
<button
|
|
className="xx-btn xx-btn-primary"
|
|
onClick={handleGenerate}
|
|
disabled={generating || (generated && !generateError)}
|
|
>
|
|
<ThunderboltOutlined />
|
|
{generating
|
|
? "生成中…"
|
|
: generated && !generateError
|
|
? "已生成"
|
|
: generateError
|
|
? "🔄 重新生成"
|
|
: "✨ 确认生成"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ════ 右侧:预览区 ════ */}
|
|
<div className="xx-generate-preview">
|
|
{/* 视频预览 */}
|
|
<div className="xx-preview-video">
|
|
{generated && videoUrl ? (
|
|
<video
|
|
src={videoUrl}
|
|
controls
|
|
preload="metadata"
|
|
poster={thumbnailUrl || undefined}
|
|
style={{ width: "100%", height: "100%", objectFit: "contain" }}
|
|
/>
|
|
) : generated ? (
|
|
<div
|
|
style={{
|
|
textAlign: "center",
|
|
padding: 24,
|
|
color: "var(--text-secondary)",
|
|
}}
|
|
>
|
|
<LoadingOutlined style={{ fontSize: 24, marginBottom: 8 }} />
|
|
<div>视频处理中,请稍候…</div>
|
|
</div>
|
|
) : (
|
|
<button className="xx-play-btn" type="button">
|
|
<PlayCircleOutlined />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* 字幕预览 */}
|
|
<div className="xx-preview-caption">
|
|
{titleSettings.title || "3秒抓住注意力,30秒讲清卖点"}
|
|
</div>
|
|
|
|
{/* 时间线标题 */}
|
|
<div className="xx-preview-title">剪辑计划预览</div>
|
|
|
|
{/* 时间线列表 */}
|
|
{generated ? (
|
|
<div className="xx-preview-timeline">
|
|
<div className="xx-timeline-item">
|
|
<span className="scene-name">生成完成,可下载或分享视频</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="xx-preview-timeline">
|
|
<div className="xx-timeline-item">
|
|
<span className="scene-name">确认标题后自动生成剪辑计划</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 生成操作按钮 */}
|
|
<div className="xx-generate-actions">
|
|
<button className="xx-btn xx-btn-ghost" onClick={handleGenerate} disabled={generating}>
|
|
重新生成
|
|
</button>
|
|
<button
|
|
className="xx-btn xx-btn-primary"
|
|
onClick={handleGenerate}
|
|
disabled={generating}
|
|
>
|
|
<ThunderboltOutlined />
|
|
{generating ? "生成中…" : "✨ 确认生成"}
|
|
</button>
|
|
</div>
|
|
|
|
{/* 生成完成后显示下载/分享 */}
|
|
{generated && (
|
|
<div className="xx-generate-actions" style={{ marginTop: 8 }}>
|
|
<button className="xx-btn xx-btn-ghost" onClick={handleDownload}>
|
|
<DownloadOutlined /> 下载视频
|
|
</button>
|
|
<button className="xx-btn xx-btn-ghost" onClick={handleShare}>
|
|
<ShareAltOutlined /> 分享
|
|
</button>
|
|
<button className="xx-btn xx-btn-ghost" onClick={() => navigate("/app/products")}>
|
|
前往成片库 →
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── 音色克隆弹窗 ── */}
|
|
<CloneModal
|
|
open={cloneModalOpen}
|
|
onClose={() => setCloneModalOpen(false)}
|
|
onSuccess={handleCloneSuccess}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default GeneratePage
|