feat: 任务 3.14 一键生成功能前端对接 TTS API #172
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/** 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<string, unknown> | 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<TTSSynthesizeResponse> => {
|
||||
const response = await apiClient.post<TTSSynthesizeResponse>(
|
||||
"/tts/synthesize",
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 获取 TTS 任务详情 */
|
||||
export const getTTSJob = async (jobId: string): Promise<TTSJob> => {
|
||||
const response = await apiClient.get<TTSJob>(`/tts/jobs/${jobId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 获取 TTS 任务状态(轻量轮询) */
|
||||
export const getTTSJobStatus = async (jobId: string): Promise<TTSJobStatus> => {
|
||||
const response = await apiClient.get<TTSJobStatus>(
|
||||
`/tts/jobs/${jobId}/status`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 获取 TTS 任务列表 */
|
||||
export const getTTSJobs = async (
|
||||
params?: TTSJobListParams,
|
||||
): Promise<TTSJobListResponse> => {
|
||||
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<TTSJobListResponse>(
|
||||
`/tts/jobs${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 删除 TTS 任务 */
|
||||
export const deleteTTSJob = async (jobId: string): Promise<void> => {
|
||||
await apiClient.delete(`/tts/jobs/${jobId}`);
|
||||
};
|
||||
@@ -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<UploadFile[]>([]);
|
||||
|
||||
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
/* ── 预置音色:通过 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);
|
||||
|
||||
/* ── 素材数据:通过 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<typeof setTimeout>;
|
||||
|
||||
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<string, unknown> = {};
|
||||
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" ? (
|
||||
<div className="xx-voice-grid">
|
||||
{MOCK_VOICES.map((v) => {
|
||||
const selected = selectedVoice === v.id;
|
||||
return (
|
||||
<div
|
||||
key={v.id}
|
||||
className={`xx-voice-card ${selected ? "xx-voice-card-selected" : ""}`}
|
||||
onClick={() => setSelectedVoice(v.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setSelectedVoice(v.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="xx-voice-header">
|
||||
<div className="xx-voice-avatar">
|
||||
<AudioOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Paragraph className="xx-voice-name">
|
||||
{v.name}
|
||||
</Typography.Paragraph>
|
||||
<Typography.Paragraph className="xx-voice-desc">
|
||||
{v.voice_type}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
buttonType="text"
|
||||
buttonSize="sm"
|
||||
icon={
|
||||
playingVoice === v.id ? (
|
||||
<PauseCircleOutlined />
|
||||
) : (
|
||||
<PlayCircleOutlined />
|
||||
)
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleVoicePlay(v.id);
|
||||
{presetVoicesLoading ? (
|
||||
<Typography.Paragraph style={{ color: "var(--text-secondary)", gridColumn: "1 / -1" }}>
|
||||
加载预置音色中…
|
||||
</Typography.Paragraph>
|
||||
) : presetVoices.length === 0 ? (
|
||||
<Typography.Paragraph style={{ color: "var(--text-secondary)", gridColumn: "1 / -1" }}>
|
||||
暂无预置音色
|
||||
</Typography.Paragraph>
|
||||
) : (
|
||||
presetVoices.map((v) => {
|
||||
const selected = selectedVoice === v.voice_id;
|
||||
return (
|
||||
<div
|
||||
key={v.voice_id}
|
||||
className={`xx-voice-card ${selected ? "xx-voice-card-selected" : ""}`}
|
||||
onClick={() => setSelectedVoice(v.voice_id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setSelectedVoice(v.voice_id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{playingVoice === v.id ? "停止" : "试听"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="xx-voice-header">
|
||||
<div className="xx-voice-avatar">
|
||||
<AudioOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Paragraph className="xx-voice-name">
|
||||
{v.name}
|
||||
</Typography.Paragraph>
|
||||
<Typography.Paragraph className="xx-voice-desc">
|
||||
{v.description}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
buttonType="text"
|
||||
buttonSize="sm"
|
||||
icon={
|
||||
playingVoice === v.voice_id ? (
|
||||
<PauseCircleOutlined />
|
||||
) : (
|
||||
<PlayCircleOutlined />
|
||||
)
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleVoicePlay(v.voice_id, v.preview_url);
|
||||
}}
|
||||
>
|
||||
{playingVoice === v.voice_id ? "停止" : "试听"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
) : voiceMode === "custom" ? (
|
||||
<div>
|
||||
<TextArea
|
||||
placeholder="输入配音文案,系统将自动合成语音…"
|
||||
placeholder="输入配音文案,点击合成按钮生成语音…"
|
||||
rows={4}
|
||||
maxLength={500}
|
||||
showCount
|
||||
value={customVoiceText}
|
||||
onChange={(e) => setCustomVoiceText(e.target.value)}
|
||||
/>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div style={{ marginTop: 12, display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
icon={<AudioOutlined />}
|
||||
loading={synthesizeMutation.isPending}
|
||||
disabled={!customVoiceText.trim() || synthesizeMutation.isPending}
|
||||
onClick={handleSynthesizeVoice}
|
||||
>
|
||||
{synthesizeMutation.isPending ? "合成中…" : "合成语音"}
|
||||
</Button>
|
||||
<Button buttonType="ghost" icon={<AudioOutlined />}>
|
||||
上传录音文件
|
||||
</Button>
|
||||
</div>
|
||||
{/* TTS 状态反馈 */}
|
||||
{ttsError && (
|
||||
<Typography.Paragraph
|
||||
style={{ color: "var(--error, #ef4444)", marginTop: 8 }}
|
||||
>
|
||||
{ttsError}
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
{customAudioUrl && (
|
||||
<Typography.Paragraph
|
||||
style={{ color: "var(--success, #10b981)", marginTop: 8 }}
|
||||
>
|
||||
✓ 语音合成完成,可用于视频生成
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* ── 克隆我的声音 ── */
|
||||
|
||||
Reference in New Issue
Block a user