diff --git a/apps/web/src/api/tts.ts b/apps/web/src/api/tts.ts new file mode 100644 index 000000000..6010d8c84 --- /dev/null +++ b/apps/web/src/api/tts.ts @@ -0,0 +1,127 @@ +/** + * TTS 语音合成 API + * 对接后端 /api/v1/tts/* 端点 + * + * 任务 3.14 新增 + */ +import apiClient from "./client"; + +/* ── 类型定义 ──────────────────────────────────── */ + +/** TTS 合成请求参数 */ +export interface TTSSynthesizeRequest { + text: string; + voice_id?: string; + output_name?: string; + language?: string; + speed?: number; + voice_model?: string; + voice_clone_profile_id?: string; + format?: string; + metadata?: Record; +} + +/** TTS 合成创建响应 */ +export interface TTSSynthesizeResponse { + job_id: string; + status: string; + message: string; +} + +/** TTS 任务详情 */ +export interface TTSJob { + id: string; + user_id: string; + project_id: string | null; + text: string; + voice_id: string | null; + voice_model: string | null; + voice_clone_profile_id: string | null; + language: string; + speed: number; + output_name: string | null; + output_audio_url: string | null; + output_format: string; + duration_seconds: number | null; + file_size_bytes: number | null; + sample_rate: number | null; + status: string; + error_message: string | null; + retry_count: number; + max_retries: number; + metadata_: Record | null; + created_at: string; + updated_at: string; +} + +/** TTS 任务状态(轻量轮询用) */ +export interface TTSJobStatus { + id: string; + status: string; + output_audio_url: string | null; + error_message: string | null; + duration_seconds: number | null; + retry_count: number; +} + +/** TTS 任务列表响应 */ +export interface TTSJobListResponse { + items: TTSJob[]; + total: number; + skip: number; + limit: number; +} + +/** TTS 任务列表查询参数 */ +export interface TTSJobListParams { + status?: string; + skip?: number; + limit?: number; +} + +/* ── API 函数 ──────────────────────────────────── */ + +/** 创建 TTS 合成任务 */ +export const synthesizeSpeech = async ( + data: TTSSynthesizeRequest, +): Promise => { + const response = await apiClient.post( + "/tts/synthesize", + data, + ); + return response.data; +}; + +/** 获取 TTS 任务详情 */ +export const getTTSJob = async (jobId: string): Promise => { + const response = await apiClient.get(`/tts/jobs/${jobId}`); + return response.data; +}; + +/** 获取 TTS 任务状态(轻量轮询) */ +export const getTTSJobStatus = async (jobId: string): Promise => { + const response = await apiClient.get( + `/tts/jobs/${jobId}/status`, + ); + return response.data; +}; + +/** 获取 TTS 任务列表 */ +export const getTTSJobs = async ( + params?: TTSJobListParams, +): Promise => { + const searchParams = new URLSearchParams(); + if (params?.status) searchParams.set("status", params.status); + if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)); + if (params?.limit !== undefined) searchParams.set("limit", String(params.limit)); + const qs = searchParams.toString(); + const response = await apiClient.get( + `/tts/jobs${qs ? `?${qs}` : ""}`, + ); + return response.data; +}; + +/** 删除 TTS 任务 */ +export const deleteTTSJob = async (jobId: string): Promise => { + await apiClient.delete(`/tts/jobs/${jobId}`); +}; diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index feb7c9bec..535a01a9c 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -4,7 +4,7 @@ * 使用 Task 1.5 UI 组件 + V21 CSS 变量,Mock 数据 */ import React, { useState, useRef, useCallback, useEffect } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useMutation } from "@tanstack/react-query"; import { Typography, Collapse, @@ -43,54 +43,21 @@ import { generateEditPlan, } from "@/api/editPlans"; import apiClient from "@/api/client"; -import type { VoiceItem } from "@/api/voices"; +import { fetchPresetVoices } from "@/api/voices"; +import type { PresetVoiceItem } from "@/api/voices"; import { getVoiceClones, formatDuration } from "@/api/voiceClone"; import type { VoiceClone } from "@/api/voiceClone"; import VoiceCloneModal from "@/components/modals/VoiceCloneModal"; +import { synthesizeSpeech, getTTSJobStatus } from "@/api/tts"; import "./generate.css"; const { TextArea } = Input; const { Text } = Typography; /* ================================================================ - Mock 数据(配音 & 时间线保留,素材改为 API 获取) + Mock 数据(时间线保留,素材 & 配音改为 API 获取) ================================================================ */ -const MOCK_VOICES: VoiceItem[] = [ - { - id: "v1", - name: "小晓 · 温柔女声", - text: "", - voice_type: "female_gentle", - duration_seconds: 0, - audio_url: "", - }, - { - id: "v2", - name: "小宇 · 磁性男声", - text: "", - voice_type: "male_magnetic", - duration_seconds: 0, - audio_url: "", - }, - { - id: "v3", - name: "小雅 · 专业播音", - text: "", - voice_type: "female_professional", - duration_seconds: 0, - audio_url: "", - }, - { - id: "v4", - name: "小杰 · 活力男声", - text: "", - voice_type: "male_energetic", - duration_seconds: 0, - audio_url: "", - }, -]; - /* ── 时间线 Mock ── */ interface TimelineScene { scene: string; @@ -234,6 +201,19 @@ const GeneratePage: React.FC = () => { const [fileList, setFileList] = useState([]); const progressTimer = useRef>(undefined); + const audioRef = useRef(null); + + /* ── 预置音色:通过 API 获取 ── */ + const { data: presetVoicesData, isLoading: presetVoicesLoading } = useQuery({ + queryKey: ["preset-voices"], + queryFn: fetchPresetVoices, + }); + const presetVoices: PresetVoiceItem[] = presetVoicesData?.items ?? []; + + /* ── TTS 自定义合成状态 ── */ + const [customAudioUrl, setCustomAudioUrl] = useState(null); + const [ttsError, setTtsError] = useState(null); + const [ttsJobId, setTtsJobId] = useState(null); /* ── 素材数据:通过 API 获取 ── */ const { data: libraries = [] } = useQuery({ @@ -282,16 +262,100 @@ const GeneratePage: React.FC = () => { ); }, []); - const toggleVoicePlay = useCallback((id: string) => { - setPlayingVoice((prev) => { - if (prev === id) { - message.info("Mock:停止播放"); - return null; + const toggleVoicePlay = useCallback( + (voiceId: string, previewUrl: string | null) => { + if (playingVoice === voiceId) { + // 停止播放 + audioRef.current?.pause(); + audioRef.current = null; + setPlayingVoice(null); + return; } - message.info("Mock:开始播放配音预览"); - return id; + // 停止之前的 + 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; + + const poll = async () => { + try { + const status = await getTTSJobStatus(ttsJobId); + if (cancelled) return; + if (status.status === "completed") { + setCustomAudioUrl(status.output_audio_url); + 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]); const handleGenerate = useCallback(async () => { if (!title.trim()) { @@ -303,18 +367,44 @@ const GeneratePage: React.FC = () => { return; } + // 配音模式验证 + if (voiceMode === "custom" && !customAudioUrl && !customVoiceText.trim()) { + message.warning("自定义录制模式:请输入配音文案或上传录音"); + return; + } + if (voiceMode === "clone" && !selectedClonedVoice) { + message.warning("请先选择一个克隆音色"); + return; + } + setGenerating(true); setProgress(0); setGenerated(false); try { + // 构建配音配置 + const voiceConfig: Record = {}; + 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(); + } + } + // 1. 创建剪辑计划 const plan = await createEditPlan({ template_id: "default", name: title.trim(), config: { asset_ids: selectedMaterials, - voice_id: selectedVoice || undefined, + ...voiceConfig, ratio: videoRatio, style, duration, @@ -374,6 +464,10 @@ const GeneratePage: React.FC = () => { title, selectedMaterials, selectedVoice, + voiceMode, + selectedClonedVoice, + customAudioUrl, + customVoiceText, videoRatio, style, duration, @@ -578,72 +672,106 @@ const GeneratePage: React.FC = () => { {voiceMode === "preset" ? (
- {MOCK_VOICES.map((v) => { - const selected = selectedVoice === v.id; - return ( -
setSelectedVoice(v.id)} - role="button" - tabIndex={0} - aria-pressed={selected} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - setSelectedVoice(v.id); - } - }} - > -
-
- -
-
- - {v.name} - - - {v.voice_type} - -
-
- -
- ); - })} +
+
+ +
+
+ + {v.name} + + + {v.description} + +
+
+ +
+ ); + }) + )} ) : voiceMode === "custom" ? (