diff --git a/app/api/canvas/audio/models/route.ts b/app/api/canvas/audio/models/route.ts new file mode 100644 index 000000000..92418a3dc --- /dev/null +++ b/app/api/canvas/audio/models/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { listCanvasAudioModels } from "@/lib/video-canvas/audio-generation"; +import { audioContract } from "@/lib/video-canvas/audio-contract"; +export async function GET() { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 }); + try { + const models = await listCanvasAudioModels(); + return NextResponse.json( + { + models: models.map(({ model }) => ({ + id: model.id, + modelId: model.modelId, + name: model.displayName, + ...audioContract(model.modelId), + })), + }, + { headers: { "Cache-Control": "no-store" } }, + ); + } catch { + return NextResponse.json({ error: "音频模型读取失败,请稍后重试" }, { status: 503 }); + } +} diff --git a/app/api/canvas/generation-tasks/route.ts b/app/api/canvas/generation-tasks/route.ts index e78f299a4..da41018a6 100644 --- a/app/api/canvas/generation-tasks/route.ts +++ b/app/api/canvas/generation-tasks/route.ts @@ -1,3 +1,4 @@ +import { audioRequestSchema } from "@/lib/video-canvas/audio-contract"; import { canResumeCanvasTask } from "@/lib/video-canvas/task-recovery"; import { imageOperationSchema } from "@/lib/video-canvas/image-operation"; import { after, NextResponse } from "next/server"; @@ -52,6 +53,8 @@ export async function POST(request: Request) { (parsed.data.attachment.type === "story-prompts" && parsed.data.kind !== "analysis")) ) throw Object.assign(Error("脚本结果类型不匹配"), { status: 400 }); + if (parsed.data.kind === "audio" && !audioRequestSchema.safeParse(parsed.data.params).success) + throw Object.assign(Error("音频参数不合法"), { status: 400 }); const userId = await owner(parsed.data.projectId); const task = await createCanvasGenerationTask(userId, parsed.data); kick(userId, parsed.data.projectId); diff --git a/components/canvas/AudioGenerationComposer.tsx b/components/canvas/AudioGenerationComposer.tsx new file mode 100644 index 000000000..0c429f849 --- /dev/null +++ b/components/canvas/AudioGenerationComposer.tsx @@ -0,0 +1,306 @@ +"use client"; +import { useEffect, useState } from "react"; +import { + audioContract, + audioRequestSchema, + buildAudioRequest, + type AudioRequest, +} from "@/lib/video-canvas/audio-contract"; +type Model = { id: string; modelId: string; name: string; kind: string }; +export function AudioGenerationComposer({ + initial, + busy, + onSubmit, + onUpload, + onDraft, +}: { + initial?: Record; + busy: boolean; + onSubmit: (params: AudioRequest) => Promise; + onUpload: (file: File) => Promise; + onDraft: (params: Record) => void; +}) { + const [models, setModels] = useState([]), + [error, setError] = useState(""), + [loading, setLoading] = useState(true), + [sending, setSending] = useState(false); + const [draft, setDraft] = useState>(() => ({ + mode: "tts", + text: "", + voice: "Cherry", + format: "wav", + language: "Chinese", + instrumental: true, + gender: "female", + rate: 1, + pitch: 1, + volume: 50, + ...initial, + })); + const patch = (value: Record) => { + const next = { ...draft, ...value }; + setDraft(next); + onDraft(next); + }; + useEffect(() => { + const controller = new AbortController(); + fetch("/api/canvas/audio/models", { signal: controller.signal }) + .then(async (r) => { + const data = await r.json(); + if (!r.ok) throw Error(data.error || "音频模型读取失败"); + setModels(data.models || []); + }) + .catch((e) => { + if (!controller.signal.aborted) setError(e.message); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, []); + const options = models.filter((m) => m.kind === draft.mode), + model = options.find((m) => m.id === draft.modelRef) || options[0], + contract = model ? audioContract(model.modelId) : undefined; + const voice = contract?.voices.some((v) => v.id === draft.voice) + ? draft.voice + : contract?.voices[0]?.id; + const format = contract?.formats.includes(draft.format) ? draft.format : contract?.formats[0]; + const input = "mt-1 w-full rounded-lg border border-white/15 bg-[#303030] p-2 text-sm"; + const run = async () => { + setError(""); + setSending(true); + try { + if (!model) throw Error("当前模式没有已启用的官方模型"); + const params = audioRequestSchema.parse({ + ...draft, + modelRef: model.id, + voice, + format, + ...(!contract?.controls ? { rate: 1, pitch: 1, volume: 50 } : {}), + ...(!contract?.instructions ? { instructions: "" } : {}), + }); + buildAudioRequest(params, model.modelId); + patch(params); + await onSubmit(params); + } catch (e) { + setError(e instanceof Error ? e.message : "提交失败"); + } finally { + setSending(false); + } + }; + return ( +
+

语音与音乐 · 阿里云官方

+
+
+ + +
+