Add official Alibaba audio generation and editable director object tracks
This commit is contained in:
parent
292eddc5a6
commit
cb42f27ee9
24
app/api/canvas/audio/models/route.ts
Normal file
24
app/api/canvas/audio/models/route.ts
Normal file
@ -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 });
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
306
components/canvas/AudioGenerationComposer.tsx
Normal file
306
components/canvas/AudioGenerationComposer.tsx
Normal file
@ -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<string, unknown>;
|
||||
busy: boolean;
|
||||
onSubmit: (params: AudioRequest) => Promise<unknown>;
|
||||
onUpload: (file: File) => Promise<void>;
|
||||
onDraft: (params: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const [models, setModels] = useState<Model[]>([]),
|
||||
[error, setError] = useState(""),
|
||||
[loading, setLoading] = useState(true),
|
||||
[sending, setSending] = useState(false);
|
||||
const [draft, setDraft] = useState<Record<string, any>>(() => ({
|
||||
mode: "tts",
|
||||
text: "",
|
||||
voice: "Cherry",
|
||||
format: "wav",
|
||||
language: "Chinese",
|
||||
instrumental: true,
|
||||
gender: "female",
|
||||
rate: 1,
|
||||
pitch: 1,
|
||||
volume: 50,
|
||||
...initial,
|
||||
}));
|
||||
const patch = (value: Record<string, unknown>) => {
|
||||
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 (
|
||||
<section
|
||||
className="fixed bottom-8 left-1/2 z-[100] max-h-[75vh] w-[min(620px,94vw)] -translate-x-1/2 overflow-y-auto rounded-2xl border border-white/10 bg-[#222] p-5 text-white shadow-xl"
|
||||
data-canvas-overlay="true"
|
||||
aria-label="阿里云音频生成"
|
||||
>
|
||||
<h3 className="font-semibold">语音与音乐 · 阿里云官方</h3>
|
||||
<fieldset disabled={busy || sending} className="mt-3 space-y-3 disabled:opacity-60">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label>
|
||||
生成类型
|
||||
<select
|
||||
aria-label="音频生成类型"
|
||||
className={input}
|
||||
value={draft.mode}
|
||||
onChange={(e) => patch({ mode: e.target.value, modelRef: "" })}
|
||||
>
|
||||
<option value="tts">文字配音</option>
|
||||
<option value="music">音乐 / 歌曲</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
模型
|
||||
<select
|
||||
aria-label="音频模型"
|
||||
className={input}
|
||||
value={model?.id || ""}
|
||||
onChange={(e) => patch({ modelRef: e.target.value })}
|
||||
>
|
||||
{!options.length ? (
|
||||
<option value="">{loading ? "读取模型中…" : "暂无已开通模型"}</option>
|
||||
) : (
|
||||
options.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
{draft.mode === "tts" ? "配音文本(最多 600 字)" : "音乐描述"}
|
||||
<textarea
|
||||
aria-label="音频内容"
|
||||
className={input}
|
||||
rows={4}
|
||||
maxLength={draft.mode === "tts" ? 600 : 2000}
|
||||
value={draft.text}
|
||||
onChange={(e) => patch({ text: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
{draft.mode === "tts" ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label>
|
||||
音色
|
||||
<select
|
||||
aria-label="配音音色"
|
||||
className={input}
|
||||
value={voice || ""}
|
||||
onChange={(e) => patch({ voice: e.target.value })}
|
||||
>
|
||||
{contract?.voices.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
语言
|
||||
<select
|
||||
aria-label="配音语言"
|
||||
className={input}
|
||||
value={draft.language}
|
||||
onChange={(e) => patch({ language: e.target.value })}
|
||||
>
|
||||
<option value="Chinese">中文</option>
|
||||
<option value="English">英文</option>
|
||||
<option value="Auto">自动 / 中英混合</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{contract?.instructions ? (
|
||||
<label className="block">
|
||||
声音风格指令
|
||||
<input
|
||||
className={input}
|
||||
aria-label="声音风格指令"
|
||||
placeholder="例如:温暖自然,语速略慢"
|
||||
maxLength={1200}
|
||||
value={draft.instructions || ""}
|
||||
onChange={(e) => patch({ instructions: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{contract?.controls ? (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(["rate", "pitch", "volume"] as const).map((key, i) => (
|
||||
<label key={key}>
|
||||
{["语速", "音调", "音量"][i]}
|
||||
<input
|
||||
aria-label={["语速", "音调", "音量"][i]}
|
||||
className={input}
|
||||
type="number"
|
||||
min={key === "volume" ? 0 : 0.5}
|
||||
max={key === "volume" ? 100 : 2}
|
||||
step={key === "volume" ? 1 : 0.1}
|
||||
value={draft[key]}
|
||||
onChange={(e) => patch({ [key]: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="flex gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.instrumental}
|
||||
onChange={(e) => patch({ instrumental: e.target.checked })}
|
||||
/>
|
||||
纯音乐(无人声)
|
||||
</label>
|
||||
{!draft.instrumental ? (
|
||||
<>
|
||||
<label className="block">
|
||||
自定义歌词(选填,填写后优先于描述)
|
||||
<textarea
|
||||
aria-label="音乐歌词"
|
||||
className={input}
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
value={draft.lyrics || ""}
|
||||
onChange={(e) => patch({ lyrics: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
演唱声音
|
||||
<select
|
||||
className={input}
|
||||
aria-label="演唱声音"
|
||||
value={draft.gender}
|
||||
onChange={(e) => patch({ gender: e.target.value })}
|
||||
>
|
||||
<option value="female">女声</option>
|
||||
<option value="male">男声</option>
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
{!options.length ? (
|
||||
<p className="text-sm text-amber-200">
|
||||
Fun-Music 需要阿里云邀测权限,开通并启用模型后即可生成。
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-end gap-3">
|
||||
<label>
|
||||
格式
|
||||
<select
|
||||
aria-label="音频格式"
|
||||
className={input}
|
||||
value={format || "wav"}
|
||||
onChange={(e) => patch({ format: e.target.value })}
|
||||
>
|
||||
{(contract?.formats || ["wav"]).map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!model || loading}
|
||||
className="rounded-lg bg-white px-4 py-2 text-black disabled:opacity-40"
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{busy || sending ? "处理中…" : "生成音频"}
|
||||
</button>
|
||||
<label className="cursor-pointer rounded-lg border border-white/20 px-3 py-2">
|
||||
上传音频
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setSending(true);
|
||||
try {
|
||||
await onUpload(file);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "上传失败");
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
{error ? (
|
||||
<p role="alert" className="mt-3 text-sm text-red-300">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { AudioGenerationComposer } from "./AudioGenerationComposer";
|
||||
import { VideoCanvasConflictNotice } from "./VideoCanvasConflictNotice";
|
||||
import { normalizeVideoAnalysisRows } from "@/lib/video-canvas/video-story";
|
||||
import dynamic from "next/dynamic";
|
||||
@ -8722,6 +8723,13 @@ export function LiblibVideoCanvasReplica({
|
||||
setNodeContextMenu(null);
|
||||
setConnectionMenu(null);
|
||||
|
||||
if (node.inputType === "audio" && node.draftValues?.audioParams) {
|
||||
await generationTasks.submit(
|
||||
node, "audio", node.draftValues.audioParams as Record<string, unknown>,
|
||||
edges.filter(edge => edge.target === nodeId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (getDraftString(node, "action") === "VIDEO_STORY_ANALYSIS") {
|
||||
await requestVideoAnalysis(getDraftString(node, "sourceVideoNodeId"), true);
|
||||
return;
|
||||
@ -10524,7 +10532,17 @@ export function LiblibVideoCanvasReplica({
|
||||
) : null}
|
||||
|
||||
{selectedNode && selectedTargetModuleKind === "audio" && selectedTargetAnchor ? (
|
||||
<AudioTargetModuleComposer anchor={selectedTargetAnchor} onUpload={async file => {
|
||||
<AudioGenerationComposer
|
||||
key={selectedNode.id}
|
||||
initial={selectedNode.draftValues?.audioParams as Record<string, unknown> | undefined}
|
||||
busy={selectedNode.draftValues?.generationStatus === "pending"}
|
||||
onDraft={params => setNodes(current => current.map(node => node.id === selectedNode.id
|
||||
? { ...node, draftValues: { ...node.draftValues, audioParams: params } } : node))}
|
||||
onSubmit={params => generationTasks.submit({
|
||||
...selectedNode, kind: "media", inputType: "audio",
|
||||
draftValues: { ...selectedNode.draftValues, audioParams: params },
|
||||
}, "audio", params, edges.filter(edge => edge.target === selectedNode.id))}
|
||||
onUpload={async file => {
|
||||
const form = new FormData(); form.append("file", file);
|
||||
const response = await fetch("/api/canvas/media/upload", {method:"POST",body:form,signal:AbortSignal.timeout(120000)});
|
||||
const result = await response.json(); const url = result.url || result.data?.url;
|
||||
@ -10944,6 +10962,19 @@ export function LiblibVideoCanvasReplica({
|
||||
{directorConsoleNode ? (
|
||||
<DirectorStudioDialog
|
||||
initialScene={directorConsoleNode.draftValues?.directorScene}
|
||||
reconstructionTask={generationTasks.tasks.filter(task =>
|
||||
task.request.params.sourceDirectorNodeId === directorConsoleNode.id &&
|
||||
task.request.params.mode === "director-reconstruct").slice(-1)[0]}
|
||||
onReconstruct={async ({ imageUrl, prompt }) => {
|
||||
const node = createTargetModuleNode("director", directorConsoleNode.x + 600,
|
||||
directorConsoleNode.y, nodes.length + 1);
|
||||
node.title = "AI 识图重建布景";
|
||||
await generationTasks.submit(node, "analysis", {
|
||||
mode: "director-reconstruct", prompt, imageUrls: [imageUrl],
|
||||
preferredModelRef: videoCanvasFunctionConfig.defaultMultimodalModel,
|
||||
sourceDirectorNodeId: directorConsoleNode.id,
|
||||
}, [{ id: makeId("edge"), source: directorConsoleNode.id, target: node.id }]);
|
||||
}}
|
||||
onChange={scene => setNodes(current => current.map(node => node.id === directorConsoleNode.id ? {...node, draftValues: {...node.draftValues, directorScene: scene}} : node))}
|
||||
onSend={snapshots => sendDirectorSnapshotsToCanvas(directorConsoleNode, snapshots)}
|
||||
onSendMotion={(snapshot, motion) => {
|
||||
@ -13505,49 +13536,6 @@ function TextTargetModuleComposer({
|
||||
);
|
||||
}
|
||||
|
||||
function AudioTargetModuleComposer({
|
||||
anchor,
|
||||
onUpload,
|
||||
}: {
|
||||
anchor: PresetControlAnchor;
|
||||
onUpload: (file: File) => Promise<void>;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
return (
|
||||
<div
|
||||
className="fixed bottom-8 left-1/2 z-[100] w-[min(520px,90vw)] -translate-x-1/2 rounded-2xl border border-white/10 bg-[#222] p-5 text-white shadow-xl"
|
||||
data-canvas-overlay="true"
|
||||
>
|
||||
<h3 className="text-sm font-semibold">音频素材</h3>
|
||||
<p className="my-3 text-sm leading-6 text-white/60">
|
||||
当前尚未接通语音与音乐生成线路。可上传音频用于预览,或从视频工具提取音轨,再连接到支持音频参考的视频节点。
|
||||
</p>
|
||||
<label className="inline-flex cursor-pointer rounded-lg bg-white px-4 py-2 text-sm text-black">
|
||||
{busy ? "上传中…" : "上传音频"}
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
className="hidden"
|
||||
disabled={busy}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await onUpload(file);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "上传失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReferenceSelectionToolbar({
|
||||
onExit,
|
||||
onReturn,
|
||||
|
||||
@ -13,6 +13,8 @@ import {
|
||||
} from "@/lib/video-canvas/director-motion";
|
||||
export function DirectorMotionPanel({
|
||||
motion,
|
||||
hasObjectMotion = false,
|
||||
externalSeek,
|
||||
compact = false,
|
||||
pose,
|
||||
disabled,
|
||||
@ -26,6 +28,8 @@ export function DirectorMotionPanel({
|
||||
onOverview,
|
||||
}: {
|
||||
compact?: boolean;
|
||||
hasObjectMotion?: boolean;
|
||||
externalSeek?: { time: number; editing?: boolean };
|
||||
motion: DirectorMotion;
|
||||
pose: DirectorPose;
|
||||
disabled: boolean;
|
||||
@ -136,6 +140,13 @@ export function DirectorMotionPanel({
|
||||
previousPose.current = signature;
|
||||
if (autoKeyframe && changed && !preview && !disabled) add();
|
||||
}, [pose, autoKeyframe, preview, disabled]);
|
||||
useEffect(() => {
|
||||
if (!externalSeek) return;
|
||||
setPlaying(false);
|
||||
setTime(externalSeek.time);
|
||||
timeRef.current = externalSeek.time;
|
||||
setPreview(!externalSeek.editing);
|
||||
}, [externalSeek]);
|
||||
const downloadJson = () => {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([JSON.stringify({ version: 1, motion }, null, 2)], {
|
||||
@ -222,7 +233,7 @@ export function DirectorMotionPanel({
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={motion.keyframes.length < 2}
|
||||
disabled={motion.keyframes.length < 2 && !hasObjectMotion}
|
||||
onClick={() => {
|
||||
edit();
|
||||
onOverview();
|
||||
@ -253,7 +264,7 @@ export function DirectorMotionPanel({
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={motion.keyframes.length < 2}
|
||||
disabled={motion.keyframes.length < 2 && !hasObjectMotion}
|
||||
onClick={() => {
|
||||
if (playing) {
|
||||
setPlaying(false);
|
||||
@ -397,7 +408,7 @@ export function DirectorMotionPanel({
|
||||
<button
|
||||
type="button"
|
||||
className={`${button} w-full`}
|
||||
disabled={motion.keyframes.length < 2}
|
||||
disabled={motion.keyframes.length < 2 && !hasObjectMotion}
|
||||
onClick={() => {
|
||||
edit();
|
||||
onExport();
|
||||
|
||||
256
components/canvas/director/DirectorObjectTimeline.tsx
Normal file
256
components/canvas/director/DirectorObjectTimeline.tsx
Normal file
@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { DirectorScene } from "@/lib/video-canvas/director-scene";
|
||||
import { recordObjectFrames } from "@/lib/video-canvas/director-object-tracks";
|
||||
export function DirectorObjectTimeline({
|
||||
scene,
|
||||
time,
|
||||
selected,
|
||||
disabled,
|
||||
preview,
|
||||
auto,
|
||||
onAuto,
|
||||
onChange,
|
||||
onSeek,
|
||||
onEdit,
|
||||
onSelect,
|
||||
}: {
|
||||
scene: DirectorScene;
|
||||
time: number;
|
||||
selected: string | null;
|
||||
disabled: boolean;
|
||||
preview: boolean;
|
||||
auto: boolean;
|
||||
onAuto: (v: boolean) => void;
|
||||
onChange: (s: DirectorScene) => void;
|
||||
onSeek: (t: number) => void;
|
||||
onEdit: () => void;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const [unit, setUnit] = useState("seconds"),
|
||||
[zoom, setZoom] = useState(1);
|
||||
const factor = unit === "frames" ? 30 : 1,
|
||||
track = selected ? scene.objectTracks?.[selected] : undefined;
|
||||
const button = "rounded border border-white/20 px-2 py-1 text-xs disabled:opacity-40",
|
||||
field = "rounded bg-[#303030] p-1 text-xs";
|
||||
return (
|
||||
<fieldset
|
||||
disabled={disabled}
|
||||
className="space-y-3 border-t border-white/15 p-3"
|
||||
aria-label="对象多轨时间轴"
|
||||
>
|
||||
<legend className="px-2 text-sm">对象多轨 · 与相机同步</legend>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label>
|
||||
单位{" "}
|
||||
<select
|
||||
aria-label="时间轴单位"
|
||||
value={unit}
|
||||
className={field}
|
||||
onChange={(e) => setUnit(e.target.value)}
|
||||
>
|
||||
<option value="seconds">秒</option>
|
||||
<option value="frames">帧(30 fps)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
缩放{" "}
|
||||
<select
|
||||
aria-label="对象时间轴缩放"
|
||||
value={zoom}
|
||||
className={field}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
>
|
||||
{[1, 2, 4, 8].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}×
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span>
|
||||
{(time * factor).toFixed(unit === "frames" ? 0 : 2)} /{" "}
|
||||
{(scene.motion.duration * factor).toFixed(unit === "frames" ? 0 : 2)}{" "}
|
||||
{unit === "frames" ? "帧" : "秒"}
|
||||
</span>
|
||||
<label>
|
||||
<input type="checkbox" checked={auto} onChange={(e) => onAuto(e.target.checked)} />{" "}
|
||||
自动记录对象帧
|
||||
</label>
|
||||
<button type="button" className={button} onClick={onEdit}>
|
||||
编辑当前时间对象
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={preview || !selected}
|
||||
onClick={() => {
|
||||
try {
|
||||
onChange(recordObjectFrames(scene, [selected!], time, () => crypto.randomUUID()));
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
记录所选对象帧
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<div style={{ minWidth: `${zoom * 100}%` }}>
|
||||
<input
|
||||
aria-label="对象播放头"
|
||||
type="range"
|
||||
min={0}
|
||||
max={scene.motion.duration}
|
||||
step={1 / 30}
|
||||
value={time}
|
||||
className="w-full"
|
||||
onChange={(e) => onSeek(Number(e.target.value))}
|
||||
/>
|
||||
{scene.objects.map((o) => {
|
||||
const t = scene.objectTracks?.[o.id];
|
||||
return (
|
||||
<div
|
||||
key={o.id}
|
||||
className="grid grid-cols-[150px_60px_1fr] items-center gap-2 border-t border-white/10 py-2 text-xs"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`truncate text-left ${selected === o.id ? "text-amber-300" : ""}`}
|
||||
onClick={() => onSelect(o.id)}
|
||||
>
|
||||
{o.name}
|
||||
</button>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${o.name}启用轨道`}
|
||||
checked={!t?.muted}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...scene,
|
||||
objectTracks: {
|
||||
...scene.objectTracks,
|
||||
[o.id]: {
|
||||
keyframes: t?.keyframes || [],
|
||||
easing: t?.easing || "linear",
|
||||
muted: !e.target.checked,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
<div className="relative h-6 rounded bg-white/5">
|
||||
{t?.keyframes.map((k, i) => (
|
||||
<button
|
||||
key={k.id}
|
||||
type="button"
|
||||
aria-label={`${o.name}关键帧${i + 1}`}
|
||||
className="absolute top-0 -translate-x-1/2 text-lg text-amber-300"
|
||||
style={{ left: `${(k.time / scene.motion.duration) * 100}%` }}
|
||||
onClick={() => {
|
||||
onSelect(o.id);
|
||||
onSeek(k.time);
|
||||
}}
|
||||
>
|
||||
◆
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{track && selected ? (
|
||||
<div className="space-y-2">
|
||||
<label>
|
||||
对象插值{" "}
|
||||
<select
|
||||
aria-label="对象插值"
|
||||
value={track.easing}
|
||||
className={field}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...scene,
|
||||
objectTracks: {
|
||||
...scene.objectTracks,
|
||||
[selected]: { ...track, easing: e.target.value as "smooth" | "linear" },
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="linear">匀速</option>
|
||||
<option value="smooth">缓入缓出</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{track.keyframes.map((k, i) => (
|
||||
<label className="flex items-center gap-1" key={k.id}>
|
||||
帧 {i + 1}
|
||||
<input
|
||||
className={`${field} w-20`}
|
||||
aria-label={`对象关键帧${i + 1}时间`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={scene.motion.duration * factor}
|
||||
step={unit === "frames" ? 1 : 0.01}
|
||||
value={Number((k.time * factor).toFixed(2))}
|
||||
onChange={(e) => {
|
||||
const next = Number(e.target.value) / factor;
|
||||
if (
|
||||
!Number.isFinite(next) ||
|
||||
next < 0 ||
|
||||
next > scene.motion.duration ||
|
||||
track.keyframes.some(
|
||||
(other) => other.id !== k.id && Math.abs(other.time - next) < 0.001,
|
||||
)
|
||||
)
|
||||
return;
|
||||
onChange({
|
||||
...scene,
|
||||
objectTracks: {
|
||||
...scene.objectTracks,
|
||||
[selected]: {
|
||||
...track,
|
||||
keyframes: track.keyframes
|
||||
.map((old) => (old.id === k.id ? { ...old, time: next } : old))
|
||||
.sort((a, b) => a.time - b.time),
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
aria-label={`删除对象关键帧${i + 1}`}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...scene,
|
||||
objectTracks: {
|
||||
...scene.objectTracks,
|
||||
[selected]: {
|
||||
...track,
|
||||
keyframes: track.keyframes.filter((old) => old.id !== k.id),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-xs text-white/50">
|
||||
拖动播放头预览全部对象;点击“编辑当前时间对象”后调整场景对象,再记录关键帧。真实视频导出使用相同轨道。
|
||||
</p>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
118
components/canvas/director/DirectorReconstructionPanel.tsx
Normal file
118
components/canvas/director/DirectorReconstructionPanel.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { readDirectorScene, type DirectorScene } from "@/lib/video-canvas/director-scene";
|
||||
import { insertReconstructedScene } from "@/lib/video-canvas/director-reconstruction";
|
||||
export type ReconstructionTask = {
|
||||
status: string;
|
||||
error?: string | null;
|
||||
result?: Record<string, unknown> | null;
|
||||
};
|
||||
export function DirectorReconstructionPanel({
|
||||
scene,
|
||||
onChange,
|
||||
onSubmit,
|
||||
task,
|
||||
disabled,
|
||||
}: {
|
||||
scene: DirectorScene;
|
||||
onChange: (s: DirectorScene) => void;
|
||||
onSubmit: (input: { imageUrl: string; prompt: string }) => Promise<unknown>;
|
||||
task?: ReconstructionTask;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null),
|
||||
[prompt, setPrompt] = useState(""),
|
||||
[busy, setBusy] = useState(false);
|
||||
const waiting = task && ["pending", "running"].includes(task.status);
|
||||
const result = task?.status === "succeeded" ? task.result : undefined;
|
||||
return (
|
||||
<fieldset
|
||||
disabled={disabled || busy}
|
||||
className="space-y-2 rounded border border-white/15 p-3 text-xs"
|
||||
>
|
||||
<legend>AI 识图重建布景</legend>
|
||||
<p className="text-white/60">
|
||||
从参考图识别人物、物体与机位,生成可编辑的简化几何布景;不包含精细网格和纹理。
|
||||
</p>
|
||||
<input
|
||||
aria-label="重建参考图"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
||||
/>
|
||||
<textarea
|
||||
aria-label="重建要求"
|
||||
className="w-full rounded bg-[#303030] p-2"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="需要保留的主体、构图与大致尺寸"
|
||||
maxLength={2000}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!file || !!waiting}
|
||||
className="rounded bg-amber-400 px-3 py-2 text-black disabled:opacity-40"
|
||||
onClick={async () => {
|
||||
if (!file) return;
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
toast.error("参考图需小于 20 MiB");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("purpose", "director-studio");
|
||||
const response = await fetch("/api/files/upload", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
signal: AbortSignal.timeout(120000),
|
||||
});
|
||||
const data = await response.json();
|
||||
const url = data.url || data.data?.url;
|
||||
if (!response.ok || !url) throw Error(data.error || "参考图上传失败");
|
||||
await onSubmit({ imageUrl: url, prompt });
|
||||
toast.success("重建任务已提交,结果将保存为独立导演台节点");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "重建失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy ? "正在提交…" : waiting ? "正在重建…" : "识图重建"}
|
||||
</button>
|
||||
{task?.error ? <p role="alert">{task.error}</p> : null}
|
||||
{result?.directorScene ? (
|
||||
<div>
|
||||
<p className="my-2">{String(result.summary || "布景已生成")}</p>
|
||||
{[false, true].map((replace) => (
|
||||
<button
|
||||
type="button"
|
||||
key={String(replace)}
|
||||
className="mr-2 rounded border border-white/20 px-2 py-1"
|
||||
onClick={() => {
|
||||
try {
|
||||
onChange(
|
||||
insertReconstructedScene(
|
||||
scene,
|
||||
readDirectorScene(result.directorScene),
|
||||
replace,
|
||||
() => crypto.randomUUID(),
|
||||
),
|
||||
);
|
||||
toast.success(replace ? "已替换场景对象" : "已插入场景对象");
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{replace ? "替换场景对象" : "插入当前场景"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@ -21,6 +21,15 @@ import {
|
||||
type DirectorObject,
|
||||
type DirectorVector,
|
||||
} from "@/lib/video-canvas/director-scene";
|
||||
import { DirectorObjectTimeline } from "./DirectorObjectTimeline";
|
||||
import {
|
||||
DirectorReconstructionPanel,
|
||||
type ReconstructionTask,
|
||||
} from "./DirectorReconstructionPanel";
|
||||
import {
|
||||
recordObjectFrames,
|
||||
sampleDirectorObjects,
|
||||
} from "@/lib/video-canvas/director-object-tracks";
|
||||
import { DirectorMotionPanel } from "./DirectorMotionPanel";
|
||||
import {
|
||||
sampleDirectorMotion,
|
||||
@ -54,7 +63,11 @@ export function DirectorStudioDialog({
|
||||
onSend,
|
||||
onSendMotion,
|
||||
onSendVideo,
|
||||
onReconstruct,
|
||||
reconstructionTask,
|
||||
}: {
|
||||
onReconstruct?: (input: { imageUrl: string; prompt: string }) => Promise<unknown>;
|
||||
reconstructionTask?: ReconstructionTask;
|
||||
initialScene: unknown;
|
||||
onChange: (scene: DirectorScene) => void;
|
||||
onClose: () => void;
|
||||
@ -78,7 +91,16 @@ export function DirectorStudioDialog({
|
||||
const [cameraView, setCameraView] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const viewport = useRef<DirectorViewportHandle>(null);
|
||||
const [previewTime, setPreviewTime] = useState<number | null>(null);
|
||||
const timelineDrawer = useRef<HTMLDetailsElement>(null);
|
||||
const reconstructionAnchor = useRef<HTMLDivElement>(null);
|
||||
const [previewTime, setPreviewState] = useState<number | null>(null);
|
||||
const [timelineTime, setTimelineTime] = useState(0);
|
||||
const [externalSeek, setExternalSeek] = useState<{ time: number; editing?: boolean }>();
|
||||
const [autoObjectFrame, setAutoObjectFrame] = useState(false);
|
||||
const setPreviewTime = (time: number | null) => {
|
||||
setPreviewState(time);
|
||||
if (time !== null) setTimelineTime(time);
|
||||
};
|
||||
const [exportProgress, setExportProgress] = useState<number | null>(null);
|
||||
const recording = useRef<AbortController | null>(null);
|
||||
useEffect(() => () => recording.current?.abort(), []);
|
||||
@ -135,7 +157,7 @@ export function DirectorStudioDialog({
|
||||
};
|
||||
const pose = sampleDirectorMotion(motion, 0);
|
||||
if (!pose || motion.keyframes.length < 2) throw Error("至少需要两个关键帧");
|
||||
const image = await viewport.current!.capture(pose);
|
||||
const image = await viewport.current!.capture(pose, 0);
|
||||
const url = await upload(
|
||||
new File([image.blob], `director-motion-${Date.now()}.png`, {
|
||||
type: "image/png",
|
||||
@ -166,11 +188,23 @@ export function DirectorStudioDialog({
|
||||
const camera = resolveDirectorCamera(scene);
|
||||
const angles = cameraAngles(camera);
|
||||
const object = scene.objects.find((o) => o.id === selected);
|
||||
const patchObject = (id: string, patch: Partial<DirectorObject>) =>
|
||||
commit({
|
||||
const patchObject = (id: string, patch: Partial<DirectorObject>) => {
|
||||
let next = {
|
||||
...current.current,
|
||||
objects: current.current.objects.map((o) => (o.id === id ? { ...o, ...patch } : o)),
|
||||
});
|
||||
};
|
||||
try {
|
||||
if (
|
||||
autoObjectFrame &&
|
||||
previewTime === null &&
|
||||
["position", "rotation", "scale", "visible"].some((key) => Object.hasOwn(patch, key))
|
||||
)
|
||||
next = recordObjectFrames(next, [id], timelineTime, () => crypto.randomUUID());
|
||||
commit(next);
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message);
|
||||
}
|
||||
};
|
||||
const patchCamera = (patch: Partial<typeof camera>) =>
|
||||
commit({
|
||||
...current.current,
|
||||
@ -338,8 +372,18 @@ export function DirectorStudioDialog({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button className={button} disabled title="尚未接入图片识别与场景重建">
|
||||
AI 识图导入(未接通)
|
||||
<button
|
||||
className={button}
|
||||
disabled={!onReconstruct || busy}
|
||||
onClick={() => {
|
||||
if (timelineDrawer.current) timelineDrawer.current.open = true;
|
||||
reconstructionAnchor.current?.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}}
|
||||
>
|
||||
AI 识图重建布景
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-4 text-xs leading-5 text-white/45">
|
||||
@ -789,6 +833,7 @@ export function DirectorStudioDialog({
|
||||
</aside>
|
||||
</div>
|
||||
<details
|
||||
ref={timelineDrawer}
|
||||
open
|
||||
className="max-h-[32vh] shrink-0 overflow-auto border-t border-white/15 bg-[#1c1c1c] px-4 pb-3"
|
||||
>
|
||||
@ -798,6 +843,10 @@ export function DirectorStudioDialog({
|
||||
<DirectorMotionPanel
|
||||
key={scene.activeCameraId}
|
||||
compact
|
||||
hasObjectMotion={Object.values(scene.objectTracks || {}).some(
|
||||
(t) => !t.muted && t.keyframes.length >= 2,
|
||||
)}
|
||||
externalSeek={externalSeek}
|
||||
motion={scene.motion}
|
||||
pose={camera}
|
||||
disabled={busy}
|
||||
@ -813,6 +862,40 @@ export function DirectorStudioDialog({
|
||||
viewport.current?.showPath();
|
||||
}}
|
||||
/>
|
||||
<DirectorObjectTimeline
|
||||
scene={scene}
|
||||
time={timelineTime}
|
||||
selected={selected}
|
||||
disabled={busy}
|
||||
preview={previewTime !== null}
|
||||
auto={autoObjectFrame}
|
||||
onAuto={setAutoObjectFrame}
|
||||
onChange={commit}
|
||||
onSelect={setSelected}
|
||||
onSeek={(time) => {
|
||||
setExternalSeek({ time });
|
||||
setPreviewTime(time);
|
||||
}}
|
||||
onEdit={() => {
|
||||
commit({
|
||||
...current.current,
|
||||
objects: sampleDirectorObjects(current.current, timelineTime),
|
||||
});
|
||||
setPreviewTime(null);
|
||||
setExternalSeek({ time: timelineTime, editing: true });
|
||||
}}
|
||||
/>
|
||||
{onReconstruct ? (
|
||||
<div ref={reconstructionAnchor}>
|
||||
<DirectorReconstructionPanel
|
||||
scene={scene}
|
||||
onChange={commit}
|
||||
onSubmit={onReconstruct}
|
||||
task={reconstructionTask}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</details>
|
||||
<input
|
||||
ref={backgroundInput}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { sampleDirectorObjects } from "@/lib/video-canvas/director-object-tracks";
|
||||
import { resolveDirectorCamera, directorBodies } from "@/lib/video-canvas/director-extensions";
|
||||
import { directorPoses } from "@/lib/video-canvas/director-poses";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
@ -17,7 +18,10 @@ import { recordDirectorVideo } from "@/lib/video-canvas/director-recording";
|
||||
export type DirectorViewportHandle = {
|
||||
showPath: () => void;
|
||||
record: (signal: AbortSignal, onProgress: (time: number) => void) => Promise<Blob>;
|
||||
capture: (pose?: DirectorPose) => Promise<{ blob: Blob; width: number; height: number }>;
|
||||
capture: (
|
||||
pose?: DirectorPose,
|
||||
time?: number,
|
||||
) => Promise<{ blob: Blob; width: number; height: number }>;
|
||||
};
|
||||
type Props = {
|
||||
scene: DirectorScene;
|
||||
@ -65,6 +69,21 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [backgroundPending, setBackgroundPending] = useState(false);
|
||||
const [backgroundError, setBackgroundError] = useState("");
|
||||
const applyObjectTime = (time: number | null) => {
|
||||
const objects = sampleDirectorObjects(latest.current.scene, time),
|
||||
r = runtime.current;
|
||||
if (r)
|
||||
for (const item of objects) {
|
||||
const mesh = r.objects.children.find((o) => o.name === item.id);
|
||||
if (mesh) {
|
||||
mesh.position.fromArray(item.position);
|
||||
mesh.rotation.set(...item.rotation);
|
||||
mesh.scale.fromArray(item.scale);
|
||||
mesh.visible = item.visible !== false;
|
||||
}
|
||||
}
|
||||
return { ...latest.current.scene, objects };
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!host.current) return;
|
||||
setError("");
|
||||
@ -210,7 +229,14 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
return;
|
||||
}
|
||||
object.name = item.id;
|
||||
object.visible = item.visible !== false;
|
||||
// A GLB may finish loading after playback has paused at a nonzero time.
|
||||
const sampled =
|
||||
latest.current.previewTime === null
|
||||
? item
|
||||
: sampleDirectorObjects(latest.current.scene, latest.current.previewTime).find(
|
||||
(o) => o.id === item.id,
|
||||
) || item;
|
||||
object.visible = sampled.visible !== false;
|
||||
object.userData.directorSignature = JSON.stringify([
|
||||
item.kind,
|
||||
item.url,
|
||||
@ -218,9 +244,9 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
item.color,
|
||||
item.body,
|
||||
]);
|
||||
object.position.fromArray(item.position);
|
||||
object.rotation.set(...item.rotation);
|
||||
object.scale.fromArray(item.scale);
|
||||
object.position.fromArray(sampled.position);
|
||||
object.rotation.set(...sampled.rotation);
|
||||
object.scale.fromArray(sampled.scale);
|
||||
r.objects.add(object);
|
||||
if (
|
||||
item.id === latest.current.selected &&
|
||||
@ -477,35 +503,64 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
dispose(r.path);
|
||||
r.path.clear();
|
||||
const motion = props.scene.motion;
|
||||
if (motion.keyframes.length < 2) return;
|
||||
const points = Array.from(
|
||||
{ length: 121 },
|
||||
(_, i) =>
|
||||
new THREE.Vector3(...sampleDirectorMotion(motion, (motion.duration * i) / 120)!.position),
|
||||
);
|
||||
r.path.add(
|
||||
new THREE.Line(
|
||||
new THREE.BufferGeometry().setFromPoints(points),
|
||||
new THREE.LineBasicMaterial({ color: 0xfbbf24 }),
|
||||
),
|
||||
);
|
||||
for (const k of motion.keyframes) {
|
||||
const dot = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.08, 10, 8),
|
||||
new THREE.MeshBasicMaterial({ color: 0xfbbf24 }),
|
||||
const tracks = [
|
||||
...(motion.keyframes.length >= 2
|
||||
? [
|
||||
{
|
||||
color: 0xfbbf24,
|
||||
keys: motion.keyframes,
|
||||
points: Array.from(
|
||||
{ length: 121 },
|
||||
(_, i) =>
|
||||
new THREE.Vector3(
|
||||
...sampleDirectorMotion(motion, (motion.duration * i) / 120)!.position,
|
||||
),
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...Object.entries(props.scene.objectTracks || {})
|
||||
.filter(([, t]) => !t.muted && t.keyframes.length >= 2)
|
||||
.map(([id, t]) => ({
|
||||
color: 0x67e8f9,
|
||||
keys: t.keyframes,
|
||||
points: Array.from(
|
||||
{ length: 121 },
|
||||
(_, i) =>
|
||||
new THREE.Vector3(
|
||||
...sampleDirectorObjects(props.scene, (motion.duration * i) / 120).find(
|
||||
(o) => o.id === id,
|
||||
)!.position,
|
||||
),
|
||||
),
|
||||
})),
|
||||
];
|
||||
for (const track of tracks) {
|
||||
r.path.add(
|
||||
new THREE.Line(
|
||||
new THREE.BufferGeometry().setFromPoints(track.points),
|
||||
new THREE.LineBasicMaterial({ color: track.color }),
|
||||
),
|
||||
);
|
||||
dot.position.fromArray(k.position);
|
||||
r.path.add(dot);
|
||||
for (const k of track.keys) {
|
||||
const dot = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.08, 10, 8),
|
||||
new THREE.MeshBasicMaterial({ color: track.color }),
|
||||
);
|
||||
dot.position.fromArray(k.position);
|
||||
r.path.add(dot);
|
||||
}
|
||||
}
|
||||
}, [props.scene.motion, ready]);
|
||||
}, [props.scene.motion, props.scene.objectTracks, props.scene.objects, ready]);
|
||||
useEffect(() => {
|
||||
const r = runtime.current;
|
||||
if (!r) return;
|
||||
r.path.visible = !props.locked && !props.cameraView && props.previewTime === null;
|
||||
if (props.locked || props.previewTime === null) return;
|
||||
if (props.locked) return;
|
||||
const sampledScene = applyObjectTime(props.previewTime);
|
||||
if (props.previewTime === null) return;
|
||||
const sampled = sampleDirectorMotion(props.scene.motion, props.previewTime);
|
||||
if (!sampled) return;
|
||||
const pose = resolveDirectorCamera(props.scene, sampled);
|
||||
const pose = resolveDirectorCamera(sampledScene, sampled || undefined);
|
||||
r.camera.position.fromArray(pose.position);
|
||||
r.orbit.target.fromArray(pose.target);
|
||||
r.camera.fov = pose.fov;
|
||||
@ -517,6 +572,7 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
props.scene.motion,
|
||||
props.scene.cameras,
|
||||
props.scene.objects,
|
||||
props.scene.objectTracks,
|
||||
props.cameraView,
|
||||
props.locked,
|
||||
ready,
|
||||
@ -533,6 +589,9 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
const points = latest.current.scene.motion.keyframes.map(
|
||||
(k) => new THREE.Vector3(...k.position),
|
||||
);
|
||||
for (const track of Object.values(latest.current.scene.objectTracks || {}))
|
||||
if (!track.muted)
|
||||
for (const key of track.keyframes) points.push(new THREE.Vector3(...key.position));
|
||||
if (!points.length) return;
|
||||
points.push(new THREE.Vector3(0, 1, 0));
|
||||
const box = new THREE.Box3().setFromPoints(points),
|
||||
@ -548,7 +607,13 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
throw Error(backgroundError || "全景背景仍在加载,请稍后导出");
|
||||
const r = runtime.current;
|
||||
if (!r || !ready || r.pending || error) throw Error(error || "3D 场景尚未准备好");
|
||||
if (latest.current.scene.motion.keyframes.length < 2) throw Error("至少需要两个关键帧");
|
||||
if (
|
||||
latest.current.scene.motion.keyframes.length < 2 &&
|
||||
!Object.values(latest.current.scene.objectTracks || {}).some(
|
||||
(t) => !t.muted && t.keyframes.length >= 2,
|
||||
)
|
||||
)
|
||||
throw Error("相机或对象轨道至少需要两个关键帧");
|
||||
const motion = latest.current.scene.motion;
|
||||
const size = r.renderer.getSize(new THREE.Vector2()),
|
||||
pixelRatio = r.renderer.getPixelRatio();
|
||||
@ -576,8 +641,8 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
if (r.renderer.getContext().isContextLost())
|
||||
throw Error("3D 显示连接中断,已取消视频导出");
|
||||
const pose = resolveDirectorCamera(
|
||||
latest.current.scene,
|
||||
sampleDirectorMotion(motion, time)!,
|
||||
applyObjectTime(time),
|
||||
sampleDirectorMotion(motion, time) || undefined,
|
||||
);
|
||||
r.camera.position.fromArray(pose.position);
|
||||
r.orbit.target.fromArray(pose.target);
|
||||
@ -589,6 +654,7 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
});
|
||||
} finally {
|
||||
if (runtime.current === r) {
|
||||
applyObjectTime(latest.current.previewTime);
|
||||
r.renderer.setPixelRatio(pixelRatio);
|
||||
r.renderer.setSize(size.x, size.y);
|
||||
r.camera.position.copy(position);
|
||||
@ -602,7 +668,7 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
}
|
||||
}
|
||||
},
|
||||
capture: async (pose) => {
|
||||
capture: async (pose, time) => {
|
||||
if (backgroundPending || backgroundError)
|
||||
throw Error(backgroundError || "全景背景仍在加载,请稍后截图");
|
||||
const r = runtime.current;
|
||||
@ -617,8 +683,9 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
const visibility = [r.helper.visible, r.grid.visible, r.path.visible];
|
||||
const enabled = r.orbit.enabled;
|
||||
r.orbit.enabled = false;
|
||||
const capturedScene = applyObjectTime(time ?? latest.current.previewTime);
|
||||
if (pose) {
|
||||
pose = resolveDirectorCamera(latest.current.scene, pose);
|
||||
pose = resolveDirectorCamera(capturedScene, pose);
|
||||
r.camera.position.fromArray(pose.position);
|
||||
r.orbit.target.fromArray(pose.target);
|
||||
r.camera.fov = pose.fov;
|
||||
@ -641,6 +708,7 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
);
|
||||
return { blob, width, height };
|
||||
} finally {
|
||||
applyObjectTime(latest.current.previewTime);
|
||||
r.renderer.setPixelRatio(pixelRatio);
|
||||
r.renderer.setSize(previousSize.x, previousSize.y);
|
||||
r.camera.aspect = aspect;
|
||||
|
||||
40
docs/video-canvas-audio-director-2026-09-17.md
Normal file
40
docs/video-canvas-audio-director-2026-09-17.md
Normal file
@ -0,0 +1,40 @@
|
||||
# 阿里云音频与导演台扩展实施记录
|
||||
|
||||
日期:2026-09-17。此记录接续 video-canvas-task-repairs-2026-09-17.md。
|
||||
|
||||
## 已完成代码
|
||||
|
||||
- 语音/音乐节点从仅上传改为官方模型选择、文本、音色、语言、格式、指令和歌曲参数。控件严格跟随所选模型能力,千问3 TTS 不发送其不支持的数值音调/速度参数。
|
||||
- 阿里云 HTTP 适配:qwen3-tts-flash、qwen3-tts-instruct-flash、qwen-audio-3.0-tts-flash/plus、fun-music-v1。北京限定模型不能错用国际服务地址;使用原服务商密钥,不给浏览器返回密钥。
|
||||
- 音频加入持久任务队列。先记录供应商原文件,再下载校验 WAV/MP3 并归档;转存失败只恢复下载,网络受理状态不明不会自动再次生成。
|
||||
- 导演台 AI 识图入口:调用当前视觉理解模型,把参考图转为人物/立方体/球体和机位组成的可编辑简化布景。结果独立保存,允许显式插入或替换原对象。不是精细纹理/网格重建,也没有凭空生成 GLB。
|
||||
- 对象轨道包含位置、旋转、缩放、可见性、启用开关、线性/缓入缓出插值、逐帧时间编辑/删除、自动记录、秒/30fps 显示单位、时间轴缩放。旋转按最短弧插值;对象删除清理轨道;修改总时长同步缩放轨道。
|
||||
- 相机与对象共用播放头和导出时间;跟随相机采样运动后的目标对象。纯对象动画也可预览和导出。场景骨骼动作序列、声音轨道、跨机位自动切镜尚未实现。
|
||||
- 修正浏览器 crypto.randomUUID 失去调用上下文导致记录帧/插入布景报 Illegal invocation;修正视频编码器立即停止可能丢掉末帧,预热首帧并留出末帧呈现时间。
|
||||
|
||||
## 48 服务器配置
|
||||
|
||||
已使用 `scripts/configure-aliyun-canvas-audio.cjs --apply` 写入现有阿里云官方北京服务商,保留其他模型和全局默认设置;变更前模型记录备份在应用目录 `.model-config-backups/`(不含服务商密钥)。
|
||||
|
||||
- qwen3-tts-instruct-flash:启用,音频页面优先顺序 900。
|
||||
- qwen3-tts-flash:启用,顺序 901。
|
||||
- fun-music-v1:已建配置,禁用,标记 activationRequired,等待账号邀测权限确认。
|
||||
- Qwen Audio 3.0:适配已写,当前 Key 的只读 models 目录未列出,未盲目启用。
|
||||
- 千问3.8既有视觉理解配置保留。
|
||||
|
||||
只读调用官方模型目录返回 HTTP 200,包含千问3 TTS/指令版;未发起真实付费语音/音乐/识图生成。模型目录可见性不能代替生成效果验收。
|
||||
|
||||
[千问 TTS 官方文档](https://help.aliyun.com/zh/model-studio/qwen-tts-api);[新音频 HTTP 文档](https://help.aliyun.com/zh/model-studio/cosyvoice-tts-http-api);[Fun-Music 官方文档](https://help.aliyun.com/zh/model-studio/fun-music-api)(邀测、北京地域、临时文件 24 小时)。
|
||||
|
||||
## 验证
|
||||
|
||||
- helpers 全套 475 项通过;追加总时长/删除对象轨道用例后导演台 24 项通过,合计覆盖 476 项。
|
||||
- TypeScript `tsc --noEmit --incremental false` 通过。
|
||||
- `scripts/qa/video-canvas-audio.cjs` 使用实际服务适配器和本地替身:请求参数、归档前检查点、存储失败后零重复 POST、链接过期、服务商错误、租约丢失均通过。
|
||||
- `scripts/qa/video-canvas-task-db.cjs` 实际临时 PostgreSQL 验证通过:并发幂等、独占领取、恢复与失效 worker 隔离。
|
||||
- 本地浏览器调用实际 React 组件和 Three.js:音频能力控件、未开通音乐拦截、对象播放头/关键帧编辑/帧单位、独立对象动画视频导出、重建结果插入/替换通过,无浏览器运行错误。模型与上传返回使用本地替身。
|
||||
- 真实浏览器录制文件经 ffprobe 确认为 VP9、1280×720;设置 2 秒,文件约 1.974 秒。提取首尾帧确认对象实际移动。MediaRecorder 属于实时录制,受浏览器帧调度影响,不能宣称离线逐帧精确编码。
|
||||
|
||||
参考站及开源自动剪辑算法详见 `video-canvas-smart-edit-research-2026-09-17.md`。该部分为已完成研究和实施方案,尚未接入完整自动混剪服务。
|
||||
|
||||
部署状态:本次生产环境仅更新上述模型配置;应用代码在开发分支完成,未发布到生产应用。
|
||||
65
docs/video-canvas-smart-edit-research-2026-09-17.md
Normal file
65
docs/video-canvas-smart-edit-research-2026-09-17.md
Normal file
@ -0,0 +1,65 @@
|
||||
# 智能剪辑:参考站实测与开源算法选型
|
||||
|
||||
检查日期:2026-09-17。本文区分实际观察、开源代码事实、建议方案与尚未实现的功能。
|
||||
|
||||
## 参考站已观察
|
||||
|
||||
[LibTV 画布](https://www.liblib.tv/canvas) 的智能剪辑入口有讲解视频、批量广告、口播视频、素材混剪四种预设。素材混剪预设连接七个素材节点,提示词包含卡点和突出产品,参数显示 16:9、1080P、20 秒。打开编辑器后,示例时间轴约 14 秒,含七个片段;这只是预设示例,不能当成自动生成成功的结果。没有点击收费生成。
|
||||
|
||||
编辑器可见分割、向左/右裁剪、字幕轨道、转场、吸附、缩放、原声静音、全屏预览,导出到本地或画布。分割/裁剪会检查播放头位置与最短片段长度。界面没有公开它实际使用的后台选片或卡点算法,不能推断它使用了以下任何项目。
|
||||
|
||||
## 已阅读的开源项目
|
||||
|
||||
| 项目 | 可复用能力及实现 | 适用场景 | 许可证/接入边界 |
|
||||
| --- | --- | --- | --- |
|
||||
| [FunClip](https://github.com/modelscope/FunClip) | FunASR 生成转写与时间戳;把选定文字、说话人或 LLM 给出的区间映射回视频;裁剪后重排字幕时间 | 中文口播、讲解、访谈精选 | [源码 MIT](https://github.com/modelscope/FunClip/blob/main/LICENSE),模型权重单独授权。Python/ASR 服务不能直接放进 Next 请求进程 |
|
||||
| [Auto-Editor](https://github.com/WyattBlue/auto-editor) | 按音量或运动量标记保留区间,可组合条件,前后 margin 保留呼吸和语句边界 | 去停顿、去静音粗剪 | [Unlicense](https://github.com/WyattBlue/auto-editor/blob/master/LICENSE);低音量不是无价值内容,不能直接当产品亮点评分 |
|
||||
| [PySceneDetect](https://github.com/Breakthrough/PySceneDetect) | ContentDetector 计算相邻帧变化;AdaptiveDetector 将目标帧分数与周围帧均值比较,同时检查最小内容变化和最短镜头 | 多素材拆镜头、混剪候选片段 | [BSD-3-Clause](https://github.com/Breakthrough/PySceneDetect/blob/main/LICENSE);负责边界检测,不负责语义或商品识别 |
|
||||
| [librosa](https://github.com/librosa/librosa) | onset strength → 节奏估计 → 动态规划选节拍;综合局部强度与节拍间隔一致性 | 背景音乐卡点 | [ISC](https://github.com/librosa/librosa/blob/main/LICENSE.md);弱节奏/静音可能无有效节拍,必须显式回退 |
|
||||
| [FFmpeg](https://ffmpeg.org/ffmpeg-filters.html) | trim/atrim、setpts/asetpts、concat、xfade/acrossfade、字幕渲染、响度处理 | 最终确定性合成 | 使用部署环境既有构建;具体许可依构建组件,不把所有构建概括成同一许可 |
|
||||
|
||||
阅读位置:FunClip `funclip/videoclipper.py` 的 `recog`、`clip`、`video_clip`;PySceneDetect `scenedetect/detectors/adaptive_detector.py` 的 `process_frame`;librosa `librosa/beat.py` 的 `__beat_tracker`、`__beat_track_dp`。这些代码已下载到本地研究目录查看,没有安装来源不明的脚本或启动重模型服务。
|
||||
|
||||
FunClip 的关键点是可靠的时间戳映射,而不是让语言模型随意猜视频秒数。它既接收文字匹配,也接收显式时间区间,再重算片段字幕。不能把“ASR 文字剪辑”直接等同于“无对白产品素材混剪”。
|
||||
|
||||
PySceneDetect 的自适应比值为目标变化分数除以邻域平均变化分数,另加内容阈值和最短镜头限制。这能减轻连续快速运镜带来的误切,但闪光和缓慢转场仍需结合素材调阈值。[算法实现](https://github.com/Breakthrough/PySceneDetect/blob/main/scenedetect/detectors/adaptive_detector.py)
|
||||
|
||||
librosa 的节拍跟踪先形成起音强度曲线,估计速度,再用动态规划寻找与速度相容的强起音序列,不是简单按固定秒数平均切。低置信度时保留普通剪辑并标明未检测到稳定节拍。[算法文档](https://librosa.org/doc/0.10.2/generated/librosa.beat.beat_track.html)
|
||||
|
||||
## 与现有系统的接入方案(尚未上线)
|
||||
|
||||
优先组合 FunClip 的时间戳选片思路、PySceneDetect、librosa 和现有 FFmpeg;千问3.8继续负责素材语义、脚本要求和候选镜头排序。无需为了剪辑更换现有生图/视频模型。
|
||||
|
||||
1. 预处理:现有受限下载器取得素材;ffprobe 读真实时长/FPS/音轨;以素材 hash 缓存低清代理、镜头边界、ASR 和节拍,避免反复解析。
|
||||
2. 候选分段:有对白时按 ASR 句子和说话人形成候选;无对白时按场景边界形成候选。去静音只作可选粗剪,保留前后余量,不切断语词。
|
||||
3. 语义选片:把候选 ID、真实开始/结束、抽帧与转写传给现有千问3.8,只允许模型返回候选 ID、用途和排序。服务端校验所有 ID 和区间;无素材时不能虚构成片。
|
||||
4. 卡点:分析用户选定/生成的音乐,输出真实 beat 秒数。根据最低镜头时长与素材余量,选每 2/4 拍换镜头,平衡节奏、语义连续性和目标总时长。口播优先语句边界,音乐卡点不能强行截断对白。
|
||||
5. 形成可编辑 EDL:记录素材 ID、sourceIn/sourceOut、timelineStart、speed、音量、字幕、转场。前端预览、时间轴修改和后台导出使用同一份 EDL。用整数微秒或有理数时间避免浮点累计漂移,显示时转换为帧。
|
||||
6. 合成:统一尺寸/FPS/像素格式/采样率;每段 trim 后时间归零。转场重叠计入总时长;无音轨片段补静音以保持 A/V 对齐;FFmpeg 执行显式参数数组,进程限时限并发,上传归档。
|
||||
7. 持久任务:分为解析、选片、编辑计划、渲染、转存,各步保存结果和输入版本。刷新或断线恢复同一任务,编辑后旧任务不能覆盖新版本;失败显示具体阶段。
|
||||
|
||||
示例 EDL 合同:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"timebase": "microseconds",
|
||||
"canvas": {"width": 1920, "height": 1080, "fps": 30},
|
||||
"clips": [{"id":"clip-1","assetId":"asset-a","sourceIn":1000000,"sourceOut":3000000,"timelineStart":0,"speed":1,"volume":1}],
|
||||
"music": {"assetId":"music-a","start":0,"gain":0.2},
|
||||
"subtitles": [],
|
||||
"transitions": []
|
||||
}
|
||||
```
|
||||
|
||||
## 对照记录与剩余工作
|
||||
|
||||
| 能力 | 当前项目状态 | 后续验收条件 |
|
||||
| --- | --- | --- |
|
||||
| 单视频裁剪、抽帧、音轨提取、慢放 | 现有真实 FFmpeg 功能 | 已有测试继续保持 |
|
||||
| 四类智能剪辑预设的自动成片 | 本次完成参考站与开源源码研究,未接入完整剪辑服务 | 上传不同素材后产生可编辑 EDL 与真实成片 |
|
||||
| 去静音/ASR 语义选片 | 选定开源候选,尚未部署 | 验证中文、低音量、无音轨、多人语音 |
|
||||
| 音乐卡点 | 已明确 librosa 算法与回退策略,尚未接入 | 合成脉冲音乐和真实音乐的切点误差可量化 |
|
||||
| 多素材时间轴、字幕、转场 | 仍需 EDL 编辑器和合成执行器;不把单片裁剪宣称为智能混剪 | 预览/导出逐片一致,跨转场音画同步 |
|
||||
|
||||
48 服务器此前反馈过内存高。部署 ASR 时应先核对实际可用资源,再使用独立受限 worker;不要在每个 Web 请求内加载一份模型。此处是实施建议,本次没有启动新的常驻模型服务。
|
||||
197
lib/video-canvas/audio-contract.ts
Normal file
197
lib/video-canvas/audio-contract.ts
Normal file
@ -0,0 +1,197 @@
|
||||
import { z } from "zod";
|
||||
const qwenVoices = [
|
||||
{ id: "Cherry", name: "芊悦" },
|
||||
{ id: "Serena", name: "苏瑶" },
|
||||
];
|
||||
export const audioModelContracts = {
|
||||
"qwen3-tts-instruct-flash": {
|
||||
kind: "tts",
|
||||
label: "千问3 TTS 指令版",
|
||||
voices: qwenVoices,
|
||||
instructions: true,
|
||||
controls: false,
|
||||
formats: ["wav"],
|
||||
},
|
||||
"qwen3-tts-flash": {
|
||||
kind: "tts",
|
||||
label: "千问3 TTS",
|
||||
voices: [...qwenVoices, { id: "Ryan", name: "甜茶" }, { id: "Jennifer", name: "詹妮弗" }],
|
||||
instructions: false,
|
||||
controls: false,
|
||||
formats: ["wav"],
|
||||
},
|
||||
"qwen-audio-3.0-tts-flash": {
|
||||
kind: "tts",
|
||||
label: "千问 Audio 3.0 Flash",
|
||||
voices: [
|
||||
{ id: "longanhuan_v3.6", name: "龙安欢" },
|
||||
{ id: "longanfengyue", name: "龙安风悦" },
|
||||
{ id: "longanyuanfei", name: "龙安元妃" },
|
||||
{ id: "longanlingxi", name: "龙安灵希" },
|
||||
],
|
||||
instructions: true,
|
||||
controls: true,
|
||||
formats: ["mp3", "wav"],
|
||||
},
|
||||
"qwen-audio-3.0-tts-plus": {
|
||||
kind: "tts",
|
||||
label: "千问 Audio 3.0 Plus",
|
||||
voices: [
|
||||
{ id: "longanlingxin", name: "龙安灵心" },
|
||||
{ id: "longanlufeng", name: "龙安鲁风" },
|
||||
],
|
||||
instructions: true,
|
||||
controls: true,
|
||||
formats: ["mp3", "wav"],
|
||||
},
|
||||
"fun-music-v1": {
|
||||
kind: "music",
|
||||
label: "Fun-Music 音乐",
|
||||
voices: [],
|
||||
instructions: false,
|
||||
controls: false,
|
||||
formats: ["mp3", "wav"],
|
||||
},
|
||||
} satisfies Record<
|
||||
string,
|
||||
{
|
||||
kind: string;
|
||||
label: string;
|
||||
voices: { id: string; name: string }[];
|
||||
instructions: boolean;
|
||||
controls: boolean;
|
||||
formats: string[];
|
||||
}
|
||||
>;
|
||||
export type AudioModelId = keyof typeof audioModelContracts;
|
||||
export const audioRequestSchema = z.object({
|
||||
mode: z.enum(["tts", "music"]),
|
||||
modelRef: z.string().min(1),
|
||||
text: z.string().trim().max(2000).default(""),
|
||||
lyrics: z.string().trim().max(2000).default(""),
|
||||
voice: z.string().max(100).optional(),
|
||||
language: z
|
||||
.enum([
|
||||
"Auto",
|
||||
"Chinese",
|
||||
"English",
|
||||
"German",
|
||||
"Italian",
|
||||
"Portuguese",
|
||||
"Spanish",
|
||||
"Japanese",
|
||||
"Korean",
|
||||
"French",
|
||||
"Russian",
|
||||
])
|
||||
.default("Chinese"),
|
||||
instructions: z.string().trim().max(1200).default(""),
|
||||
format: z.enum(["mp3", "wav"]).default("wav"),
|
||||
instrumental: z.boolean().default(true),
|
||||
gender: z.enum(["male", "female"]).default("female"),
|
||||
rate: z.number().min(0.5).max(2).default(1),
|
||||
pitch: z.number().min(0.5).max(2).default(1),
|
||||
volume: z.number().int().min(0).max(100).default(50),
|
||||
});
|
||||
export type AudioRequest = z.infer<typeof audioRequestSchema>;
|
||||
export function audioContract(id: string) {
|
||||
return Object.hasOwn(audioModelContracts, id)
|
||||
? audioModelContracts[id as AudioModelId]
|
||||
: undefined;
|
||||
}
|
||||
export function officialAudioBase(base: string, modelId?: string) {
|
||||
const url = new URL(base);
|
||||
if (
|
||||
url.protocol !== "https:" ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
!/^(?:dashscope(?:-intl|-us)?\.aliyuncs\.com|[a-zA-Z0-9-]+\.(?:cn-beijing|ap-southeast-1)\.maas\.aliyuncs\.com)$/.test(
|
||||
url.hostname,
|
||||
)
|
||||
)
|
||||
throw Error("请选择阿里云官方音频服务线路");
|
||||
if (
|
||||
(modelId === "fun-music-v1" || modelId?.startsWith("qwen-audio-")) &&
|
||||
url.hostname !== "dashscope.aliyuncs.com" &&
|
||||
!url.hostname.endsWith(".cn-beijing.maas.aliyuncs.com")
|
||||
)
|
||||
throw Error("所选模型仅支持阿里云北京地域");
|
||||
return url.origin;
|
||||
}
|
||||
export function buildAudioRequest(value: unknown, modelId: string) {
|
||||
const p = audioRequestSchema.parse(value),
|
||||
contract = audioContract(modelId);
|
||||
if (!contract || contract.kind !== p.mode) throw Error("音频模式与模型不匹配");
|
||||
if (!contract.formats.includes(p.format)) throw Error("所选模型不支持这个音频格式");
|
||||
if (p.mode === "music") {
|
||||
if ((p.instrumental && !p.text) || (!p.text && !p.lyrics)) throw Error("请输入音乐描述或歌词");
|
||||
if (
|
||||
!p.instrumental &&
|
||||
p.lyrics &&
|
||||
(p.lyrics.length < 5 || (/[\u3400-\u9fff]/.test(p.lyrics) && p.lyrics.length > 350))
|
||||
)
|
||||
throw Error("中文歌词需 5–350 字,英文歌词需 5–2000 字");
|
||||
return {
|
||||
path: "/api/v1/services/audio/music/generation",
|
||||
body: {
|
||||
model: modelId,
|
||||
input: {
|
||||
...(p.text ? { prompt: p.text } : {}),
|
||||
...(!p.instrumental && p.lyrics ? { lyrics: p.lyrics } : {}),
|
||||
is_instrumental: p.instrumental,
|
||||
...(!p.instrumental ? { gender: p.gender } : {}),
|
||||
format: p.format,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!p.text || p.text.length > 600) throw Error("配音文本需 1–600 字");
|
||||
if (!contract.voices.some((v) => v.id === p.voice)) throw Error("请选择当前模型支持的音色");
|
||||
if (!contract.instructions && p.instructions) throw Error("所选模型不支持风格指令");
|
||||
if (!contract.controls && (p.rate !== 1 || p.pitch !== 1 || p.volume !== 50))
|
||||
throw Error("所选模型不支持数值语速、音调和音量参数,可改用风格指令");
|
||||
const modern = modelId.startsWith("qwen-audio-");
|
||||
if (modern && !["Chinese", "English", "Auto"].includes(p.language))
|
||||
throw Error("当前系统音色支持中文和英文");
|
||||
return {
|
||||
path: modern
|
||||
? "/api/v1/services/audio/tts/SpeechSynthesizer"
|
||||
: "/api/v1/services/aigc/multimodal-generation/generation",
|
||||
body: {
|
||||
model: modelId,
|
||||
input: {
|
||||
text: p.text,
|
||||
voice: p.voice,
|
||||
...(modern
|
||||
? {
|
||||
format: p.format,
|
||||
rate: p.rate,
|
||||
pitch: p.pitch,
|
||||
volume: p.volume,
|
||||
...(p.instructions ? { instruction: p.instructions } : {}),
|
||||
...(p.language !== "Auto"
|
||||
? { language_hints: [p.language === "Chinese" ? "zh" : "en"] }
|
||||
: {}),
|
||||
}
|
||||
: {
|
||||
language_type: p.language,
|
||||
...(p.instructions
|
||||
? { instructions: p.instructions, optimize_instructions: true }
|
||||
: {}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
export const audioCheckpointSchema = z.object({
|
||||
operation: z.literal("audio"),
|
||||
modelRef: z.string(),
|
||||
modelId: z.string(),
|
||||
url: z
|
||||
.string()
|
||||
.url()
|
||||
.refine((v) => /^https?:\/\//i.test(v)),
|
||||
format: z.enum(["mp3", "wav"]),
|
||||
requestId: z.string().optional(),
|
||||
expiresAt: z.number().optional(),
|
||||
});
|
||||
132
lib/video-canvas/audio-generation.ts
Normal file
132
lib/video-canvas/audio-generation.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import "server-only";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { downloadCanvasMedia } from "./download-media";
|
||||
import {
|
||||
audioContract,
|
||||
audioCheckpointSchema,
|
||||
audioRequestSchema,
|
||||
buildAudioRequest,
|
||||
officialAudioBase,
|
||||
} from "./audio-contract";
|
||||
import { ProviderTaskUncertainError } from "./video-task-lifecycle";
|
||||
export async function listCanvasAudioModels() {
|
||||
return (await getEnabledAiModelsWithProvider()).filter(({ model, provider }) => {
|
||||
try {
|
||||
return (
|
||||
provider.type === "alibaba" &&
|
||||
!!audioContract(model.modelId) &&
|
||||
!!officialAudioBase(provider.baseUrl, model.modelId)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
export async function executeCanvasAudio(
|
||||
params: unknown,
|
||||
lifecycle: {
|
||||
resume?: unknown;
|
||||
assertActive: () => Promise<void>;
|
||||
checkpoint: (value: Record<string, unknown>) => Promise<void>;
|
||||
},
|
||||
) {
|
||||
const input = audioRequestSchema.parse(params);
|
||||
let checkpoint = lifecycle.resume ? audioCheckpointSchema.parse(lifecycle.resume) : undefined;
|
||||
if (checkpoint && checkpoint.modelRef !== input.modelRef) throw Error("音频记录与原模型不匹配");
|
||||
if (!checkpoint) {
|
||||
const item = (await listCanvasAudioModels()).find((x) => x.model.id === input.modelRef);
|
||||
if (!item) throw Error("音频模型未启用,请先在阿里云和模型设置中开通");
|
||||
const request = buildAudioRequest(input, item.model.modelId);
|
||||
await lifecycle.assertActive();
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(
|
||||
officialAudioBase(item.provider.baseUrl, item.model.modelId) + request.path,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${item.provider.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request.body),
|
||||
signal: AbortSignal.timeout(input.mode === "music" ? 600000 : 180000),
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
throw new ProviderTaskUncertainError(
|
||||
"音频请求连接中断,无法确认是否已受理;请核对服务商记录,系统不会自动重发",
|
||||
);
|
||||
}
|
||||
let data: any;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
throw new ProviderTaskUncertainError("音频响应无法解析,请核对服务商记录,系统不会自动重发");
|
||||
}
|
||||
if (!response.ok)
|
||||
throw Error(
|
||||
`阿里云音频请求失败(${response.status},${String(data.code || data.error?.code || "UnknownError").slice(0, 120)})${input.mode === "music" ? ",请确认 Fun-Music 邀测权限已开通" : ""}`,
|
||||
);
|
||||
const url = data.output?.audio?.url;
|
||||
if (typeof url !== "string" || !/^https?:\/\//i.test(url))
|
||||
throw new ProviderTaskUncertainError("音频服务未返回有效文件地址,请核对原请求,勿重复生成");
|
||||
checkpoint = {
|
||||
operation: "audio",
|
||||
url,
|
||||
format: input.format,
|
||||
modelRef: item.model.id,
|
||||
modelId: item.model.modelId,
|
||||
requestId: data.request_id,
|
||||
expiresAt:
|
||||
typeof data.output.audio.expires_at === "number" ? data.output.audio.expires_at : undefined,
|
||||
};
|
||||
try {
|
||||
await lifecycle.assertActive();
|
||||
await lifecycle.checkpoint({ upstreamAudio: checkpoint });
|
||||
} catch {
|
||||
throw new ProviderTaskUncertainError(
|
||||
"音频已生成,但保存任务记录失败;请核对原请求,系统不会自动重发",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (checkpoint.expiresAt && checkpoint.expiresAt * 1000 < Date.now())
|
||||
throw Error("阿里云音频临时地址已过期,无法继续转存");
|
||||
await lifecycle.assertActive();
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "canvas-audio-"));
|
||||
try {
|
||||
const file = path.join(dir, "audio." + checkpoint.format);
|
||||
await downloadCanvasMedia(checkpoint.url, file);
|
||||
const bytes = await readFile(file);
|
||||
if (bytes.length < 12) throw Error("音频内容为空");
|
||||
const wav =
|
||||
bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WAVE";
|
||||
const mp3 =
|
||||
bytes.toString("ascii", 0, 3) === "ID3" || (bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0);
|
||||
if (!(checkpoint.format === "wav" ? wav : mp3)) throw Error("返回文件不是有效音频格式");
|
||||
await lifecycle.assertActive();
|
||||
const saved = await uploadFile(
|
||||
`audio-${crypto.randomUUID()}.${checkpoint.format}`,
|
||||
Uint8Array.from(bytes).buffer,
|
||||
);
|
||||
return {
|
||||
url: saved.url,
|
||||
modelRef: checkpoint.modelRef,
|
||||
modelId: checkpoint.modelId,
|
||||
metadata: {
|
||||
storageStatus: "archived",
|
||||
format: checkpoint.format,
|
||||
requestId: checkpoint.requestId,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
throw new ProviderTaskUncertainError(
|
||||
`音频已生成,转存暂未完成,将继续转存原文件:${error instanceof Error ? error.message : "存储失败"}`,
|
||||
);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,10 @@
|
||||
import { defaultDirectorMotion, type DirectorPose, type DirectorMotion } from "./director-motion";
|
||||
import { resizeObjectTracks } from "./director-object-tracks";
|
||||
import {
|
||||
defaultDirectorMotion,
|
||||
resizeDirectorMotion,
|
||||
type DirectorPose,
|
||||
type DirectorMotion,
|
||||
} from "./director-motion";
|
||||
import type {
|
||||
DirectorScene,
|
||||
DirectorCamera,
|
||||
@ -14,11 +20,27 @@ export function commitDirectorScene(previous: DirectorScene, next: DirectorScene
|
||||
const motion =
|
||||
previous.activeCameraId === next.activeCameraId
|
||||
? next.motion
|
||||
: motionTracks[next.activeCameraId] || defaultDirectorMotion();
|
||||
: motionTracks[next.activeCameraId] || {
|
||||
...defaultDirectorMotion(),
|
||||
duration: previous.motion.duration,
|
||||
};
|
||||
motionTracks[next.activeCameraId] = motion;
|
||||
for (const id of Object.keys(motionTracks))
|
||||
if (!next.cameras.some((c) => c.id === id)) delete motionTracks[id];
|
||||
return { ...next, motion, motionTracks };
|
||||
let objectTracks = next.objectTracks;
|
||||
if (
|
||||
previous.activeCameraId === next.activeCameraId &&
|
||||
previous.motion.duration !== next.motion.duration
|
||||
) {
|
||||
for (const id of Object.keys(motionTracks))
|
||||
if (id !== next.activeCameraId)
|
||||
motionTracks[id] = resizeDirectorMotion(motionTracks[id], next.motion.duration);
|
||||
objectTracks = resizeObjectTracks(objectTracks, previous.motion.duration, next.motion.duration);
|
||||
}
|
||||
objectTracks = Object.fromEntries(
|
||||
Object.entries(objectTracks || {}).filter(([id]) => next.objects.some((o) => o.id === id)),
|
||||
);
|
||||
return { ...next, motion, motionTracks, objectTracks };
|
||||
}
|
||||
export function resolveDirectorCamera(scene: DirectorScene, pose?: DirectorPose): DirectorCamera {
|
||||
const camera = scene.cameras.find((c) => c.id === scene.activeCameraId) || scene.cameras[0];
|
||||
|
||||
147
lib/video-canvas/director-object-tracks.ts
Normal file
147
lib/video-canvas/director-object-tracks.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import type { DirectorScene, DirectorObject, DirectorVector } from "./director-scene";
|
||||
export type DirectorObjectFrame = {
|
||||
id: string;
|
||||
time: number;
|
||||
position: DirectorVector;
|
||||
rotation: DirectorVector;
|
||||
scale: DirectorVector;
|
||||
visible: boolean;
|
||||
};
|
||||
export type DirectorObjectTrack = {
|
||||
muted: boolean;
|
||||
easing: "linear" | "smooth";
|
||||
keyframes: DirectorObjectFrame[];
|
||||
};
|
||||
export function readObjectTracks(
|
||||
value: unknown,
|
||||
objects: DirectorObject[],
|
||||
duration: number,
|
||||
): Record<string, DirectorObjectTrack> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
const vector = (v: any, positive = false) =>
|
||||
Array.isArray(v) &&
|
||||
v.length === 3 &&
|
||||
v.every(
|
||||
(n: any) =>
|
||||
typeof n === "number" && Number.isFinite(n) && Math.abs(n) <= 10000 && (!positive || n > 0),
|
||||
);
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter(([id]) => objects.some((o) => o.id === id))
|
||||
.map(([id, raw]) => {
|
||||
const t = raw as any,
|
||||
seen = new Set<number>(),
|
||||
ids = new Set<string>();
|
||||
const keyframes = (Array.isArray(t?.keyframes) ? t.keyframes : [])
|
||||
.filter((k: any) => {
|
||||
if (
|
||||
!k ||
|
||||
typeof k.id !== "string" ||
|
||||
ids.has(k.id) ||
|
||||
typeof k.time !== "number" ||
|
||||
!Number.isFinite(k.time) ||
|
||||
k.time < 0 ||
|
||||
k.time > duration ||
|
||||
seen.has(k.time) ||
|
||||
!vector(k.position) ||
|
||||
!vector(k.rotation) ||
|
||||
!vector(k.scale, true)
|
||||
)
|
||||
return false;
|
||||
seen.add(k.time);
|
||||
ids.add(k.id);
|
||||
return true;
|
||||
})
|
||||
.slice(0, 100)
|
||||
.map((k: any) => ({
|
||||
id: k.id,
|
||||
time: k.time,
|
||||
position: [...k.position],
|
||||
rotation: [...k.rotation],
|
||||
scale: [...k.scale],
|
||||
visible: k.visible !== false,
|
||||
}))
|
||||
.sort((a: any, b: any) => a.time - b.time);
|
||||
return [
|
||||
id,
|
||||
{
|
||||
muted: t?.muted === true,
|
||||
easing: t?.easing === "smooth" ? "smooth" : "linear",
|
||||
keyframes,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
export function sampleDirectorObjects(scene: DirectorScene, time: number | null): DirectorObject[] {
|
||||
if (time === null) return scene.objects;
|
||||
return scene.objects.map((object) => {
|
||||
const track = scene.objectTracks?.[object.id];
|
||||
if (!track || track.muted || !track.keyframes.length) return object;
|
||||
const keys = track.keyframes,
|
||||
t = Math.max(0, Math.min(scene.motion.duration, time)),
|
||||
right = keys.findIndex((k) => k.time > t);
|
||||
const a = right < 0 ? keys[keys.length - 1] : keys[Math.max(0, right - 1)],
|
||||
b = right < 0 ? a : keys[right];
|
||||
let u = a === b ? 0 : Math.max(0, Math.min(1, (t - a.time) / (b.time - a.time)));
|
||||
if (track.easing === "smooth") u = u * u * (3 - 2 * u);
|
||||
const vector = (x: DirectorVector, y: DirectorVector, rotation = false): DirectorVector =>
|
||||
x.map((n, i) => {
|
||||
let delta = y[i] - n;
|
||||
if (rotation) delta = Math.atan2(Math.sin(delta), Math.cos(delta));
|
||||
return n + delta * u;
|
||||
}) as DirectorVector;
|
||||
return {
|
||||
...object,
|
||||
position: vector(a.position, b.position),
|
||||
rotation: vector(a.rotation, b.rotation, true),
|
||||
scale: vector(a.scale, b.scale),
|
||||
visible: a.visible,
|
||||
};
|
||||
});
|
||||
}
|
||||
export function recordObjectFrames(
|
||||
scene: DirectorScene,
|
||||
ids: string[],
|
||||
time: number,
|
||||
id: () => string,
|
||||
): DirectorScene {
|
||||
if (!Number.isFinite(time) || time < 0 || time > scene.motion.duration)
|
||||
throw Error("关键帧时间超出时间轴");
|
||||
const tracks = { ...scene.objectTracks };
|
||||
for (const object of scene.objects.filter((o) => ids.includes(o.id))) {
|
||||
const track = tracks[object.id] || { muted: false, easing: "linear" as const, keyframes: [] };
|
||||
const existing = track.keyframes.find((k) => Math.abs(k.time - time) < 0.01);
|
||||
if (!existing && track.keyframes.length >= 100) throw Error("每个对象最多 100 个关键帧");
|
||||
const frame = {
|
||||
id: existing?.id || id(),
|
||||
time: existing?.time ?? Math.round(time * 100) / 100,
|
||||
position: [...object.position] as DirectorVector,
|
||||
rotation: [...object.rotation] as DirectorVector,
|
||||
scale: [...object.scale] as DirectorVector,
|
||||
visible: object.visible !== false,
|
||||
};
|
||||
tracks[object.id] = {
|
||||
...track,
|
||||
keyframes: [...track.keyframes.filter((k) => k.id !== frame.id), frame].sort(
|
||||
(a, b) => a.time - b.time,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { ...scene, objectTracks: tracks };
|
||||
}
|
||||
export function resizeObjectTracks(
|
||||
tracks: Record<string, DirectorObjectTrack> | undefined,
|
||||
oldDuration: number,
|
||||
newDuration: number,
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(tracks || {}).map(([id, t]) => [
|
||||
id,
|
||||
{
|
||||
...t,
|
||||
keyframes: t.keyframes.map((k) => ({ ...k, time: (k.time * newDuration) / oldDuration })),
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
69
lib/video-canvas/director-reconstruction.ts
Normal file
69
lib/video-canvas/director-reconstruction.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { z } from "zod";
|
||||
import { defaultDirectorScene, type DirectorScene } from "./director-scene";
|
||||
import { validDirectorPose } from "./director-motion";
|
||||
const vector = z.tuple([
|
||||
z.number().finite().min(-100).max(100),
|
||||
z.number().finite().min(-100).max(100),
|
||||
z.number().finite().min(-100).max(100),
|
||||
]);
|
||||
const scale = z.tuple([
|
||||
z.number().min(0.05).max(30),
|
||||
z.number().min(0.05).max(30),
|
||||
z.number().min(0.05).max(30),
|
||||
]);
|
||||
const reconstructionSchema = z.object({
|
||||
summary: z.string().min(1).max(2000),
|
||||
objects: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().min(1).max(80),
|
||||
kind: z.enum(["actor", "box", "sphere"]),
|
||||
position: vector,
|
||||
rotation: vector,
|
||||
scale,
|
||||
color: z.string().regex(/^#[0-9a-f]{6}$/i),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(30),
|
||||
camera: z
|
||||
.object({ position: vector, target: vector, fov: z.number().min(15).max(90) })
|
||||
.refine(validDirectorPose, "相机不能与注视点重合"),
|
||||
});
|
||||
export function reconstructionPrompt(prompt: string) {
|
||||
return `根据参考图重建可编辑的3D布景(灰盒),不是输出图片或GLB。只输出JSON,字段:summary、objects、camera。只重建看得见的主要人物和物体,忽略细碎纹理,不编造遮挡后的形状。以米为近似单位,Y轴向上,地面y=0;对象局部几何 actor 约2米高,box边长1米,sphere直径1米,scale必须据此设置;对象中心/脚底位置遵循 actor脚底、box和sphere中心。rotation是弧度。所有坐标在±100内,scale在0.05到30内。objects最多30项,每项{name,kind:"actor"|"box"|"sphere",position:[x,y,z],rotation:[rx,ry,rz],scale:[sx,sy,sz],color:"#RRGGBB"}。camera={position:[x,y,z],target:[x,y,z],fov:15到90},还原参考图的构图。summary说明不确定的比例与缺失信息。用户布景要求:${prompt || "尽量保留原图主体布局与机位"}`;
|
||||
}
|
||||
export function parseReconstructedScene(value: unknown): {
|
||||
summary: string;
|
||||
directorScene: DirectorScene;
|
||||
} {
|
||||
const parsed = reconstructionSchema.parse(value),
|
||||
base = defaultDirectorScene();
|
||||
return {
|
||||
summary: parsed.summary,
|
||||
directorScene: {
|
||||
...base,
|
||||
objects: parsed.objects.map((o, i) => ({ ...o, id: `reconstructed-${i}` })),
|
||||
cameras: [{ ...parsed.camera, id: "reconstructed-camera", name: "参考图机位" }],
|
||||
activeCameraId: "reconstructed-camera",
|
||||
},
|
||||
};
|
||||
}
|
||||
export function insertReconstructedScene(
|
||||
current: DirectorScene,
|
||||
generated: DirectorScene,
|
||||
replace: boolean,
|
||||
id: () => string,
|
||||
): DirectorScene {
|
||||
const objects = generated.objects.map((o) => ({ ...o, id: id() }));
|
||||
if ((replace ? 0 : current.objects.length) + objects.length > 200)
|
||||
throw Error("场景最多支持 200 个对象");
|
||||
const camera = { ...generated.cameras[0], id: id(), followObjectId: undefined };
|
||||
return {
|
||||
...current,
|
||||
objects: replace ? objects : [...current.objects, ...objects],
|
||||
cameras: [...current.cameras, camera],
|
||||
activeCameraId: camera.id,
|
||||
...(replace ? { objectTracks: {} } : {}),
|
||||
};
|
||||
}
|
||||
@ -21,6 +21,30 @@ export async function recordDirectorVideo({
|
||||
if (!mimeType || !canvas.captureStream)
|
||||
throw Error("当前浏览器不支持视频录制,请更换支持视频录制的浏览器");
|
||||
if (signal.aborted || document.hidden) throw Error("请保持当前页面可见后导出");
|
||||
// Let WebGL present the first frame before opening the encoder stream.
|
||||
render(0);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let frame = 0;
|
||||
const finish = (error?: Error) => {
|
||||
cancelAnimationFrame(frame);
|
||||
clearTimeout(timeout);
|
||||
signal.removeEventListener("abort", cancel);
|
||||
document.removeEventListener("visibilitychange", hidden);
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
};
|
||||
const cancel = () => finish(Error("已取消轨迹视频导出"));
|
||||
const hidden = () => {
|
||||
if (document.hidden) finish(Error("导出时切换了页面,请回到当前页面重新导出"));
|
||||
};
|
||||
const timeout = setTimeout(() => finish(Error("画面准备超时,请保持页面可见后重试")), 2000);
|
||||
signal.addEventListener("abort", cancel, { once: true });
|
||||
document.addEventListener("visibilitychange", hidden);
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = requestAnimationFrame(() => finish());
|
||||
});
|
||||
});
|
||||
if (signal.aborted || document.hidden) throw Error("请保持当前页面可见后导出");
|
||||
const stream = canvas.captureStream(30);
|
||||
try {
|
||||
return await new Promise<Blob>((resolve, reject) => {
|
||||
@ -78,7 +102,12 @@ export async function recordDirectorVideo({
|
||||
stop(e instanceof Error ? e : Error("轨迹渲染失败"));
|
||||
return;
|
||||
}
|
||||
if (t >= duration) stop();
|
||||
// MediaRecorder consumes the canvas asynchronously. Stopping in the rendering
|
||||
// callback drops the final pose; allow two presentation frames to flush it.
|
||||
if (t >= duration)
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = requestAnimationFrame(() => stop());
|
||||
});
|
||||
else frame = requestAnimationFrame(tick);
|
||||
};
|
||||
try {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { readObjectTracks, type DirectorObjectTrack } from "./director-object-tracks";
|
||||
import { directorBodies } from "./director-extensions";
|
||||
import { directorPoses, type DirectorPoseName } from "./director-poses";
|
||||
import {
|
||||
@ -45,6 +46,7 @@ export type DirectorScene = {
|
||||
snapshots: DirectorSnapshot[];
|
||||
motion: DirectorMotion;
|
||||
motionTracks?: Record<string, DirectorMotion>;
|
||||
objectTracks?: Record<string, DirectorObjectTrack>;
|
||||
backgroundUrl?: string;
|
||||
backgroundHistory?: string[];
|
||||
};
|
||||
@ -81,6 +83,7 @@ export function defaultDirectorScene(): DirectorScene {
|
||||
snapshots: [],
|
||||
motion: defaultDirectorMotion(),
|
||||
motionTracks: {},
|
||||
objectTracks: {},
|
||||
};
|
||||
}
|
||||
export function readDirectorScene(value: unknown): DirectorScene {
|
||||
@ -170,6 +173,7 @@ export function readDirectorScene(value: unknown): DirectorScene {
|
||||
].slice(-20),
|
||||
}
|
||||
: {}),
|
||||
objectTracks: readObjectTracks(v.objectTracks, objects, readDirectorMotion(v.motion).duration),
|
||||
motionTracks: Object.fromEntries(
|
||||
Object.entries(v.motionTracks || {})
|
||||
.filter(([id]) => validCameras.some((c) => c.id === id))
|
||||
|
||||
@ -6,7 +6,7 @@ import { z } from "zod";
|
||||
export const canvasTaskRequestSchema = z.object({
|
||||
requestId: z.string().uuid(),
|
||||
projectId: z.string().uuid(),
|
||||
kind: z.enum(["video", "image", "analysis", "image-operation"]),
|
||||
kind: z.enum(["video", "image", "analysis", "image-operation", "audio"]),
|
||||
node: z
|
||||
.object({
|
||||
id: z.string().min(1).max(180),
|
||||
@ -51,6 +51,14 @@ export function requireAnalysisResult(data: Record<string, any> | undefined, mod
|
||||
if (!data) throw Error("分析没有返回结果,原内容已保留");
|
||||
if (mode === "video-analysis" && !normalizeVideoAnalysisRows(data.analysisRows).length)
|
||||
throw Error("视频解析没有返回有效镜头,原内容已保留");
|
||||
if (
|
||||
mode === "director-reconstruct" &&
|
||||
(!Array.isArray(data.directorScene?.objects) ||
|
||||
!data.directorScene.objects.length ||
|
||||
!Array.isArray(data.directorScene?.cameras) ||
|
||||
!data.directorScene.cameras.length)
|
||||
)
|
||||
throw Error("识图重建没有返回有效布景,原场景已保留");
|
||||
const rows = data.analysisRows || data.shotIdeas;
|
||||
if (
|
||||
mode === "story-script" &&
|
||||
@ -130,6 +138,15 @@ export function reconcileCanvasTasks<
|
||||
pendingLabel: "",
|
||||
});
|
||||
subtitle = "生成结果";
|
||||
if (task.request.kind === "audio")
|
||||
Object.assign(nextDraft, {
|
||||
mediaUrl: data.url,
|
||||
url: [data.url],
|
||||
action: "AUDIO_RESOURCE",
|
||||
mediaType: "audio",
|
||||
audioMeta: data.metadata,
|
||||
audioParams: task.request.params,
|
||||
});
|
||||
if (task.request.kind === "video")
|
||||
Object.assign(nextDraft, { mediaUrl: data.url, url: [data.url], videoMeta: data.metadata });
|
||||
if (task.request.kind === "image" || task.request.kind === "image-operation") {
|
||||
@ -156,6 +173,11 @@ export function reconcileCanvasTasks<
|
||||
];
|
||||
}
|
||||
}
|
||||
if (task.request.kind === "analysis" && task.request.params.mode === "director-reconstruct")
|
||||
Object.assign(nextDraft, {
|
||||
directorScene: data.directorScene,
|
||||
reconstructionSummary: data.summary,
|
||||
});
|
||||
if (task.request.kind === "analysis")
|
||||
Object.assign(nextDraft, {
|
||||
content: [data.raw || data.summary || data.agentPlan?.join("\n") || ""],
|
||||
@ -210,7 +232,15 @@ export function reconcileCanvasTasks<
|
||||
? {
|
||||
...item,
|
||||
...(task.status === "succeeded" && task.request.kind !== "analysis"
|
||||
? { kind: "media", inputType: task.request.kind === "video" ? "video" : "image" }
|
||||
? {
|
||||
kind: "media",
|
||||
inputType:
|
||||
task.request.kind === "video"
|
||||
? "video"
|
||||
: task.request.kind === "audio"
|
||||
? "audio"
|
||||
: "image",
|
||||
}
|
||||
: {}),
|
||||
subtitle,
|
||||
draftValues: nextDraft,
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { audioCheckpointSchema } from "./audio-contract";
|
||||
import { videoCheckpointSchema } from "./video-task-lifecycle";
|
||||
// Resume only server-recorded provider jobs. A lost request without an upstream ID is never resubmitted.
|
||||
export type RecoveryTask = {
|
||||
@ -39,10 +40,17 @@ export function getVideoCheckpoint(task: Pick<RecoveryTask, "request" | "result"
|
||||
return null;
|
||||
return parsed.data;
|
||||
}
|
||||
export function getAudioCheckpoint(task: Pick<RecoveryTask, "request" | "result">) {
|
||||
if (task.request.kind !== "audio") return null;
|
||||
const parsed = audioCheckpointSchema.safeParse(task.result?.upstreamAudio);
|
||||
return parsed.success && parsed.data.modelRef === task.request.params.modelRef
|
||||
? parsed.data
|
||||
: null;
|
||||
}
|
||||
export function hasRecoverableCanvasTask(task: RecoveryTask, now = Date.now()) {
|
||||
const created = Date.parse(task.createdAt);
|
||||
return (
|
||||
Boolean(getEnhanceCheckpoint(task) || getVideoCheckpoint(task)) &&
|
||||
Boolean(getEnhanceCheckpoint(task) || getVideoCheckpoint(task) || getAudioCheckpoint(task)) &&
|
||||
Number.isFinite(created) &&
|
||||
now >= created &&
|
||||
now - created <= 86400000 &&
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { executeCanvasAudio } from "./audio-generation";
|
||||
import { ProviderTaskUncertainError } from "./video-task-lifecycle";
|
||||
import { getEnhanceCheckpoint, getVideoCheckpoint } from "./task-recovery";
|
||||
import { getEnhanceCheckpoint, getVideoCheckpoint, getAudioCheckpoint } from "./task-recovery";
|
||||
import { requireStoryPromptResult } from "./story-task-attachment";
|
||||
import {
|
||||
executeImageOperation,
|
||||
@ -37,9 +38,16 @@ async function execute(task: CanvasGenerationTask, userId: string) {
|
||||
await assertLease();
|
||||
const resume = getEnhanceCheckpoint(task);
|
||||
const videoResume = getVideoCheckpoint(task);
|
||||
if ((task.attempts || 1) > 1 && !resume && !videoResume)
|
||||
const audioResume = getAudioCheckpoint(task);
|
||||
if ((task.attempts || 1) > 1 && !resume && !videoResume && !audioResume)
|
||||
throw Error("缺少上游任务编号,禁止重新提交");
|
||||
const params = request.params;
|
||||
if (request.kind === "audio")
|
||||
return executeCanvasAudio(params, {
|
||||
resume: audioResume,
|
||||
assertActive: assertLease,
|
||||
checkpoint: (value) => checkpointCanvasGenerationTask(task.id, value, task.attempts),
|
||||
});
|
||||
if (request.kind === "image-operation")
|
||||
return executeImageOperation(
|
||||
params,
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { reconstructionPrompt, parseReconstructedScene } from "./director-reconstruction";
|
||||
import { normalizeVideoAnalysisRows } from "./video-story";
|
||||
import "server-only";
|
||||
import { normalizeStoryScriptRows } from "@/lib/video-canvas/story-script";
|
||||
@ -22,7 +23,13 @@ const VIDEO_ANALYSIS_MAX_SIDE = 960;
|
||||
const VIDEO_ANALYSIS_TIMEOUT_MS = 15000;
|
||||
|
||||
const requestSchema = z.object({
|
||||
mode: z.enum(["video-analysis", "character-scene-extraction", "story-script", "agent-plan"]),
|
||||
mode: z.enum([
|
||||
"video-analysis",
|
||||
"character-scene-extraction",
|
||||
"story-script",
|
||||
"agent-plan",
|
||||
"director-reconstruct",
|
||||
]),
|
||||
prompt: z.string().trim().optional(),
|
||||
imageUrls: z.array(z.string().trim()).optional(),
|
||||
videoUrls: z.array(z.string().trim()).optional(),
|
||||
@ -511,33 +518,35 @@ export async function executeWorkflowAnalysisResponse(body: unknown, signal?: Ab
|
||||
}
|
||||
|
||||
const promptText =
|
||||
parsed.data.mode === "video-analysis"
|
||||
? buildVideoAnalysisPrompt({
|
||||
frameCount: extractedVideoFrames.length,
|
||||
nodeTitle: normalizeText(parsed.data.nodeTitle),
|
||||
prompt,
|
||||
imageCount: imageUrls.length,
|
||||
videoCount: videoUrls.length,
|
||||
})
|
||||
: parsed.data.mode === "character-scene-extraction"
|
||||
? buildCharacterScenePrompt({
|
||||
parsed.data.mode === "director-reconstruct"
|
||||
? reconstructionPrompt(prompt)
|
||||
: parsed.data.mode === "video-analysis"
|
||||
? buildVideoAnalysisPrompt({
|
||||
frameCount: extractedVideoFrames.length,
|
||||
nodeTitle: normalizeText(parsed.data.nodeTitle),
|
||||
prompt,
|
||||
imageCount: imageUrls.length,
|
||||
videoCount: videoUrls.length,
|
||||
})
|
||||
: parsed.data.mode === "story-script"
|
||||
? buildStoryScriptPrompt({
|
||||
: parsed.data.mode === "character-scene-extraction"
|
||||
? buildCharacterScenePrompt({
|
||||
nodeTitle: normalizeText(parsed.data.nodeTitle),
|
||||
prompt,
|
||||
imageCount: imageUrls.length,
|
||||
videoCount: videoUrls.length,
|
||||
})
|
||||
: buildAgentPlanPrompt({
|
||||
nodeTitle: normalizeText(parsed.data.nodeTitle),
|
||||
prompt,
|
||||
imageCount: imageUrls.length,
|
||||
videoCount: videoUrls.length,
|
||||
});
|
||||
: parsed.data.mode === "story-script"
|
||||
? buildStoryScriptPrompt({
|
||||
nodeTitle: normalizeText(parsed.data.nodeTitle),
|
||||
prompt,
|
||||
imageCount: imageUrls.length,
|
||||
videoCount: videoUrls.length,
|
||||
})
|
||||
: buildAgentPlanPrompt({
|
||||
nodeTitle: normalizeText(parsed.data.nodeTitle),
|
||||
prompt,
|
||||
imageCount: imageUrls.length,
|
||||
videoCount: videoUrls.length,
|
||||
});
|
||||
|
||||
const inlinedImageReferences = useVision
|
||||
? (
|
||||
@ -554,6 +563,8 @@ export async function executeWorkflowAnalysisResponse(body: unknown, signal?: Ab
|
||||
? [...inlinedImageReferences, ...extractedVideoFrames].slice(0, 6)
|
||||
: [];
|
||||
|
||||
if (parsed.data.mode === "director-reconstruct" && !referenceImages.length)
|
||||
throw Error("识图重建需要一张可读取的图片");
|
||||
const { text, finishReason } = await generateText({
|
||||
model,
|
||||
messages: [
|
||||
@ -566,12 +577,11 @@ export async function executeWorkflowAnalysisResponse(body: unknown, signal?: Ab
|
||||
},
|
||||
],
|
||||
temperature: 0.2,
|
||||
maxOutputTokens:
|
||||
parsed.data.mode === "story-script"
|
||||
? 12000
|
||||
: parsed.data.mode === "video-analysis"
|
||||
? 2200
|
||||
: 1600,
|
||||
maxOutputTokens: ["story-script", "director-reconstruct"].includes(parsed.data.mode)
|
||||
? 12000
|
||||
: parsed.data.mode === "video-analysis"
|
||||
? 2200
|
||||
: 1600,
|
||||
maxRetries: 0,
|
||||
abortSignal: signal
|
||||
? AbortSignal.any([signal, AbortSignal.timeout(150_000)])
|
||||
@ -593,12 +603,25 @@ export async function executeWorkflowAnalysisResponse(body: unknown, signal?: Ab
|
||||
}
|
||||
const summary = normalizeText(parsedJson.summary) || normalizeText(text).slice(0, 160);
|
||||
|
||||
if (parsed.data.mode === "director-reconstruct")
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
...parseReconstructedScene(parsedJson),
|
||||
raw: text,
|
||||
model: selectedModel.model.displayName,
|
||||
},
|
||||
});
|
||||
if (parsed.data.mode === "video-analysis") {
|
||||
const analysisRows = normalizeVideoAnalysisRows(
|
||||
parsedJson.analysisRows,
|
||||
extractedVideoFrames,
|
||||
);
|
||||
if (!analysisRows.length) return NextResponse.json({ success: false, error: "视频解析没有返回有效镜头,请重新解析" }, { status: 502 });
|
||||
if (!analysisRows.length)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "视频解析没有返回有效镜头,请重新解析" },
|
||||
{ status: 502 },
|
||||
);
|
||||
const shotIdeas =
|
||||
normalizeShotIdeas(parsedJson.shotIdeas).length > 0
|
||||
? normalizeShotIdeas(parsedJson.shotIdeas)
|
||||
|
||||
91
scripts/configure-aliyun-canvas-audio.cjs
Normal file
91
scripts/configure-aliyun-canvas-audio.cjs
Normal file
@ -0,0 +1,91 @@
|
||||
// Run on the application host: APP_ROOT=/var/www/ai-chatbot node SCRIPT [--apply] [--music-enabled]
|
||||
// Only model rows are backed up; credentials never leave the existing provider.
|
||||
const fs = require("node:fs");
|
||||
const root = process.env.APP_ROOT || process.cwd();
|
||||
require(root + "/node_modules/dotenv").config({ path: root + "/.env.local", quiet: true });
|
||||
const sql = require(root + "/node_modules/postgres")(process.env.POSTGRES_URL, { max: 1 });
|
||||
const providerId = "7bce5924-bfd2-4e31-9f1d-825bd9d9f8b1";
|
||||
const apply = process.argv.includes("--apply"),
|
||||
musicEnabled = process.argv.includes("--music-enabled");
|
||||
const models = [
|
||||
{
|
||||
modelId: "qwen3-tts-instruct-flash",
|
||||
displayName: "千问3 TTS 指令版(阿里云官方)",
|
||||
kind: "tts",
|
||||
enabled: true,
|
||||
defaults: { voice: "Cherry", language: "Chinese", format: "wav" },
|
||||
},
|
||||
{
|
||||
modelId: "qwen3-tts-flash",
|
||||
displayName: "千问3 TTS(阿里云官方)",
|
||||
kind: "tts",
|
||||
enabled: true,
|
||||
defaults: { voice: "Cherry", language: "Chinese", format: "wav" },
|
||||
},
|
||||
{
|
||||
modelId: "fun-music-v1",
|
||||
displayName: "Fun-Music(阿里云官方·需邀测)",
|
||||
kind: "music",
|
||||
enabled: musicEnabled,
|
||||
defaults: { instrumental: true, format: "mp3" },
|
||||
},
|
||||
];
|
||||
(async () => {
|
||||
const [provider] =
|
||||
await sql`select id,type,"baseUrl","isEnabled" from "AiProvider" where id=${providerId}`;
|
||||
if (
|
||||
!provider ||
|
||||
provider.type !== "alibaba" ||
|
||||
!provider.isEnabled ||
|
||||
new URL(provider.baseUrl).origin !== "https://dashscope.aliyuncs.com"
|
||||
)
|
||||
throw Error("Expected enabled official Alibaba Beijing provider");
|
||||
await sql.begin(async (tx) => {
|
||||
await tx`select id from "AiProvider" where id=${providerId} for update`;
|
||||
const before =
|
||||
await tx`select * from "AiModel" where "providerId"=${providerId} and "modelId" in ${tx(models.map((m) => m.modelId))}`;
|
||||
if (apply) {
|
||||
const dir = root + "/.model-config-backups";
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(
|
||||
dir + "/canvas-audio-" + Date.now() + ".json",
|
||||
JSON.stringify({ providerId, models: before }, null, 2),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
}
|
||||
for (const model of models) {
|
||||
const existing = before.filter((row) => row.modelId === model.modelId);
|
||||
if (existing.length > 1) throw Error("Duplicate model rows: " + model.modelId);
|
||||
const row = existing[0];
|
||||
// Preserve an already enabled music row on subsequent idempotent runs.
|
||||
const enabled = model.enabled || !!row?.isEnabled;
|
||||
const capabilities = {
|
||||
...(row?.capabilities || {}),
|
||||
kind: "audio",
|
||||
audioType: model.kind,
|
||||
official: true,
|
||||
activationRequired: model.kind === "music" && !enabled,
|
||||
};
|
||||
const defaults = { ...(row?.defaultParams || {}), ...model.defaults };
|
||||
if (apply) {
|
||||
if (row)
|
||||
await tx`update "AiModel" set "displayName"=${model.displayName},"isEnabled"=${enabled},"isHidden"=true,capabilities=${tx.json(capabilities)},"defaultParams"=${tx.json(defaults)},"updatedAt"=now() where id=${row.id}`;
|
||||
else
|
||||
await tx`insert into "AiModel" ("providerId","modelId","displayName","isEnabled","isHidden",capabilities,"defaultParams","sortOrder") values (${providerId},${model.modelId},${model.displayName},${enabled},true,${tx.json(capabilities)},${tx.json(defaults)},${model.kind === "music" ? 902 : model.modelId.includes("instruct") ? 900 : 901})`;
|
||||
}
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
mode: apply ? "applied" : "dry-run",
|
||||
modelId: model.modelId,
|
||||
enabled,
|
||||
activationRequired: capabilities.activationRequired,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
})()
|
||||
.catch((e) => {
|
||||
console.error(e.message?.replace(/postgres:\/\/[^ ]+/g, "[redacted]") || e.name);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => sql.end());
|
||||
126
scripts/qa/video-canvas-audio.cjs
Normal file
126
scripts/qa/video-canvas-audio.cjs
Normal file
@ -0,0 +1,126 @@
|
||||
// Exercises the actual audio adapter with local provider/storage doubles; no paid calls.
|
||||
const fs = require("node:fs"),
|
||||
path = require("node:path"),
|
||||
assert = require("node:assert/strict");
|
||||
(async () => {
|
||||
const esbuild = require(
|
||||
path.resolve(
|
||||
"node_modules/.pnpm",
|
||||
fs.readdirSync("node_modules/.pnpm").find((n) => n.startsWith("esbuild@0.25.")),
|
||||
"node_modules/esbuild",
|
||||
),
|
||||
);
|
||||
fs.mkdirSync(".qa/audio-expansion", { recursive: true });
|
||||
const modules = {
|
||||
"server-only": "",
|
||||
"@/lib/db/queries":
|
||||
"export async function getEnabledAiModelsWithProvider(){return globalThis.__audioModels}",
|
||||
"@/lib/storage/service":
|
||||
"export async function uploadFile(name,bytes){return globalThis.__audioUpload(name,bytes)}",
|
||||
"./download-media":
|
||||
"export async function downloadCanvasMedia(url,file){return globalThis.__audioDownload(url,file)}",
|
||||
};
|
||||
await esbuild.build({
|
||||
entryPoints: ["lib/video-canvas/audio-generation.ts"],
|
||||
outfile: ".qa/audio-expansion/adapter.cjs",
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "cjs",
|
||||
packages: "external",
|
||||
alias: { "@": process.cwd() },
|
||||
plugins: [
|
||||
{
|
||||
name: "qa",
|
||||
setup(b) {
|
||||
b.onResolve({ filter: /.*/ }, (args) =>
|
||||
Object.hasOwn(modules, args.path) ? { path: args.path, namespace: "qa" } : undefined,
|
||||
);
|
||||
b.onLoad({ filter: /.*/, namespace: "qa" }, (args) => ({ contents: modules[args.path] }));
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const { executeCanvasAudio } = require(path.resolve(".qa/audio-expansion/adapter.cjs"));
|
||||
global.__audioModels = [
|
||||
{
|
||||
model: { id: "official", modelId: "qwen3-tts-instruct-flash" },
|
||||
provider: {
|
||||
type: "alibaba",
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey: "local-fixture",
|
||||
},
|
||||
},
|
||||
];
|
||||
let posts = 0,
|
||||
downloads = 0,
|
||||
uploads = 0,
|
||||
checkpoint;
|
||||
global.fetch = async (url, init) => {
|
||||
posts++;
|
||||
assert.equal(init.method, "POST");
|
||||
assert.equal(JSON.parse(init.body).input.voice, "Cherry");
|
||||
return Response.json({
|
||||
request_id: "fixture",
|
||||
output: {
|
||||
audio: {
|
||||
url: "https://example.com/test.wav",
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
global.__audioDownload = async (url, file) => {
|
||||
downloads++;
|
||||
assert.ok(checkpoint);
|
||||
fs.writeFileSync(file, Buffer.from("RIFF1234WAVEfixtureaudio"));
|
||||
};
|
||||
global.__audioUpload = async (name, bytes) => {
|
||||
uploads++;
|
||||
if (uploads === 1) throw Error("storage unavailable");
|
||||
assert.ok(bytes.byteLength);
|
||||
return { url: "https://storage.example.com/archived.wav" };
|
||||
};
|
||||
const input = { mode: "tts", modelRef: "official", text: "测试", voice: "Cherry" };
|
||||
const lifecycle = {
|
||||
assertActive: async () => {},
|
||||
checkpoint: async (value) => {
|
||||
checkpoint = value.upstreamAudio;
|
||||
},
|
||||
};
|
||||
await assert.rejects(() => executeCanvasAudio(input, lifecycle), /转存暂未完成/);
|
||||
assert.equal(posts, 1);
|
||||
assert.ok(checkpoint);
|
||||
const result = await executeCanvasAudio(input, { ...lifecycle, resume: checkpoint });
|
||||
assert.equal(result.url, "https://storage.example.com/archived.wav");
|
||||
assert.equal(posts, 1);
|
||||
assert.equal(downloads, 2);
|
||||
await assert.rejects(
|
||||
() => executeCanvasAudio(input, { ...lifecycle, resume: { ...checkpoint, expiresAt: 1 } }),
|
||||
/过期/,
|
||||
);
|
||||
assert.equal(posts, 1);
|
||||
global.fetch = async () => {
|
||||
posts++;
|
||||
throw Error("connection lost");
|
||||
};
|
||||
await assert.rejects(() => executeCanvasAudio(input, lifecycle), /不会自动重发/);
|
||||
assert.equal(posts, 2);
|
||||
global.fetch = async () => Response.json({ code: "Forbidden" }, { status: 403 });
|
||||
await assert.rejects(() => executeCanvasAudio(input, lifecycle), /403/);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
executeCanvasAudio(input, {
|
||||
...lifecycle,
|
||||
assertActive: async () => {
|
||||
throw Error("lost lease");
|
||||
},
|
||||
}),
|
||||
/lost lease/,
|
||||
);
|
||||
console.log(
|
||||
"PASS: actual adapter body, checkpoint-before-download, archival recovery without POST, expiry, transport uncertainty, provider failure and lease fencing",
|
||||
);
|
||||
})().catch((e) => {
|
||||
console.error(e);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@ -295,6 +295,7 @@ const fs = require("fs"),
|
||||
fs.writeFileSync(callsFile, "");
|
||||
const unexpected = `throw Error("unexpected provider path")`;
|
||||
const stubs = {
|
||||
"./audio-generation": `export const executeCanvasAudio=async()=>{${unexpected}};`,
|
||||
"@/lib/ai/tools/generate-video": `export const executeDirectVideoGeneration=async()=>{${unexpected}};`,
|
||||
"@/lib/canvas/generate-image-response": `export const executeCanvasImageResponse=async()=>{${unexpected}};`,
|
||||
"./workflow-analysis-response": `export const executeWorkflowAnalysisResponse=async()=>{${unexpected}};`,
|
||||
|
||||
@ -189,6 +189,8 @@ test("recording stops tracks and rejects partial exports on cancel or background
|
||||
"cancel",
|
||||
"hidden",
|
||||
"render-error",
|
||||
"warmup-cancel",
|
||||
"warmup-hidden",
|
||||
] as const) {
|
||||
const saved = new Map<string, PropertyDescriptor | undefined>();
|
||||
const install = (key: string, value: unknown) => {
|
||||
@ -251,17 +253,25 @@ test("recording stops tracks and rejects partial exports on cancel or background
|
||||
callbacks.clear();
|
||||
pending.forEach((fn) => fn(time));
|
||||
};
|
||||
const warming = outcome.startsWith("warmup-");
|
||||
if (!warming) {
|
||||
step(10);
|
||||
step(20);
|
||||
await Promise.resolve();
|
||||
}
|
||||
if (outcome === "complete" || outcome === "complete-webm") {
|
||||
step(1000);
|
||||
step(3000);
|
||||
step(3033);
|
||||
step(3066);
|
||||
expect((await result).type).toBe(
|
||||
outcome === "complete-webm" ? "video/webm;codecs=vp8" : "video/mp4",
|
||||
);
|
||||
expect(rendered.at(-1)).toBe(2);
|
||||
} else {
|
||||
const rejected = expect(result).rejects.toThrow();
|
||||
if (outcome === "cancel") controller.abort();
|
||||
else if (outcome === "hidden") {
|
||||
if (outcome === "cancel" || outcome === "warmup-cancel") controller.abort();
|
||||
else if (outcome === "hidden" || outcome === "warmup-hidden") {
|
||||
doc.hidden = true;
|
||||
doc.dispatchEvent(new Event("visibilitychange"));
|
||||
} else {
|
||||
@ -270,7 +280,7 @@ test("recording stops tracks and rejects partial exports on cancel or background
|
||||
}
|
||||
await rejected;
|
||||
}
|
||||
expect(stopped).toBe(1);
|
||||
expect(stopped).toBe(warming ? 0 : 1);
|
||||
expect(callbacks.size).toBe(0);
|
||||
} finally {
|
||||
for (const [key, descriptor] of saved) {
|
||||
@ -403,3 +413,116 @@ test("whole-path transforms change camera and target together while preserving d
|
||||
motion.keyframes[0].target[2] + 3,
|
||||
]);
|
||||
});
|
||||
|
||||
import {
|
||||
recordObjectFrames,
|
||||
sampleDirectorObjects,
|
||||
readObjectTracks,
|
||||
} from "@/lib/video-canvas/director-object-tracks";
|
||||
import {
|
||||
parseReconstructedScene,
|
||||
insertReconstructedScene,
|
||||
} from "@/lib/video-canvas/director-reconstruction";
|
||||
test("object tracks interpolate independently, persist, mute and preserve shortest rotation", () => {
|
||||
let scene = defaultDirectorScene();
|
||||
const id = scene.objects[0].id;
|
||||
scene = recordObjectFrames(scene, [id], 0, () => "key-0");
|
||||
scene.objects = scene.objects.map((o) =>
|
||||
o.id === id
|
||||
? {
|
||||
...o,
|
||||
position: [10, 2, 4],
|
||||
rotation: [0, Math.PI * 2 - 0.2, 0],
|
||||
scale: [2, 3, 4],
|
||||
visible: false,
|
||||
}
|
||||
: o,
|
||||
);
|
||||
scene = recordObjectFrames(scene, [id], scene.motion.duration, () => "key-1");
|
||||
const mid = sampleDirectorObjects(scene, scene.motion.duration / 2)[0];
|
||||
expect(mid.position[0]).toBeCloseTo((scene.objectTracks![id].keyframes[0].position[0] + 10) / 2);
|
||||
expect(mid.rotation[1]).toBeCloseTo(-0.1);
|
||||
expect(mid.visible).toBe(true);
|
||||
expect(sampleDirectorObjects(scene, scene.motion.duration)[0].visible).toBe(false);
|
||||
const restored = readDirectorScene(JSON.parse(JSON.stringify(scene)));
|
||||
expect(restored.objectTracks).toEqual(scene.objectTracks);
|
||||
scene.objectTracks![id].muted = true;
|
||||
expect(sampleDirectorObjects(scene, 0)[0]).toEqual(scene.objects[0]);
|
||||
});
|
||||
test("malformed/orphan object tracks are excluded and recording replaces exact frame", () => {
|
||||
let scene = defaultDirectorScene();
|
||||
const id = scene.objects[0].id;
|
||||
scene = recordObjectFrames(scene, [id], 0, () => "one");
|
||||
scene = recordObjectFrames(scene, [id], 0, () => "two");
|
||||
expect(scene.objectTracks![id].keyframes).toHaveLength(1);
|
||||
const valid = scene.objectTracks![id];
|
||||
expect(
|
||||
readObjectTracks(
|
||||
{
|
||||
ghost: valid,
|
||||
[id]: {
|
||||
...valid,
|
||||
keyframes: [...valid.keyframes, { ...valid.keyframes[0], id: "bad", time: Infinity }],
|
||||
},
|
||||
},
|
||||
scene.objects,
|
||||
scene.motion.duration,
|
||||
),
|
||||
).toEqual({ [id]: valid });
|
||||
expect(() => recordObjectFrames(scene, [id], -1, () => "bad")).toThrow();
|
||||
});
|
||||
test("reference reconstruction validates geometry and merges without clobbering original objects", () => {
|
||||
const generated = parseReconstructedScene({
|
||||
summary: "Approximate geometry",
|
||||
objects: [
|
||||
{
|
||||
name: "table",
|
||||
kind: "box",
|
||||
position: [0, 0.5, 0],
|
||||
rotation: [0, 0, 0],
|
||||
scale: [2, 1, 1],
|
||||
color: "#ffffff",
|
||||
},
|
||||
],
|
||||
camera: { position: [3, 3, 3], target: [0, 0.5, 0], fov: 45 },
|
||||
});
|
||||
const scene = defaultDirectorScene();
|
||||
let count = 0;
|
||||
const insert = insertReconstructedScene(
|
||||
scene,
|
||||
generated.directorScene,
|
||||
false,
|
||||
() => `added-${count++}`,
|
||||
);
|
||||
expect(insert.objects.length).toBe(scene.objects.length + 1);
|
||||
expect(insert.objects[0]).toEqual(scene.objects[0]);
|
||||
expect(new Set(insert.objects.map((o) => o.id)).size).toBe(insert.objects.length);
|
||||
const replaced = insertReconstructedScene(
|
||||
scene,
|
||||
generated.directorScene,
|
||||
true,
|
||||
() => `new-${count++}`,
|
||||
);
|
||||
expect(replaced.objects).toHaveLength(1);
|
||||
expect(replaced.objectTracks).toEqual({});
|
||||
expect(() =>
|
||||
parseReconstructedScene({
|
||||
summary: "bad",
|
||||
objects: [],
|
||||
camera: { position: [0, 0, 0], target: [0, 0, 0], fov: 45 },
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("global duration rescales object tracks and deleting an object deletes its track", () => {
|
||||
let scene = defaultDirectorScene();
|
||||
const id = scene.objects[0].id;
|
||||
scene = recordObjectFrames(scene, [id], scene.motion.duration, () => "last");
|
||||
const old = scene.motion.duration;
|
||||
const changed = commitDirectorScene(scene, {
|
||||
...scene,
|
||||
motion: { ...scene.motion, duration: old * 2 },
|
||||
});
|
||||
expect(changed.objectTracks![id].keyframes[0].time).toBe(old * 2);
|
||||
expect(commitDirectorScene(changed, { ...changed, objects: [] }).objectTracks).toEqual({});
|
||||
});
|
||||
|
||||
@ -656,3 +656,94 @@ test("video recovery requires an original server checkpoint and matching selecte
|
||||
}
|
||||
expect(canResumeCanvasTask({ ...task, attempts: 20 }, recoveryTime)).toBe(false);
|
||||
});
|
||||
|
||||
import { buildAudioRequest, officialAudioBase } from "@/lib/video-canvas/audio-contract";
|
||||
import { getAudioCheckpoint } from "@/lib/video-canvas/task-recovery";
|
||||
test("official Alibaba audio requests match each model capabilities", () => {
|
||||
const tts = buildAudioRequest(
|
||||
{ mode: "tts", modelRef: "a", text: "你好", voice: "Cherry", instructions: "温柔自然" },
|
||||
"qwen3-tts-instruct-flash",
|
||||
);
|
||||
expect(tts.path).toContain("multimodal-generation");
|
||||
expect(tts.body.input).toMatchObject({
|
||||
text: "你好",
|
||||
voice: "Cherry",
|
||||
instructions: "温柔自然",
|
||||
optimize_instructions: true,
|
||||
});
|
||||
expect(tts.body.input).not.toHaveProperty("rate");
|
||||
expect(() =>
|
||||
buildAudioRequest(
|
||||
{ mode: "tts", modelRef: "a", text: "你好", voice: "Cherry", rate: 2 },
|
||||
"qwen3-tts-flash",
|
||||
),
|
||||
).toThrow("不支持数值");
|
||||
expect(() =>
|
||||
buildAudioRequest(
|
||||
{ mode: "tts", modelRef: "a", text: "你好", voice: "unsupported" },
|
||||
"qwen3-tts-flash",
|
||||
),
|
||||
).toThrow("音色");
|
||||
const modern = buildAudioRequest(
|
||||
{ mode: "tts", modelRef: "a", text: "你好", voice: "longanlingxin", format: "mp3", rate: 1.5 },
|
||||
"qwen-audio-3.0-tts-plus",
|
||||
);
|
||||
expect(modern.body.input).toMatchObject({ format: "mp3", rate: 1.5, language_hints: ["zh"] });
|
||||
});
|
||||
test("music validates lyrics and excludes singing fields from instrumental output", () => {
|
||||
const music = buildAudioRequest(
|
||||
{
|
||||
mode: "music",
|
||||
modelRef: "a",
|
||||
text: "轻快吉他",
|
||||
lyrics: "不应该传的歌词",
|
||||
instrumental: true,
|
||||
format: "mp3",
|
||||
},
|
||||
"fun-music-v1",
|
||||
);
|
||||
expect(music.body.input).toEqual({ prompt: "轻快吉他", is_instrumental: true, format: "mp3" });
|
||||
expect(() =>
|
||||
buildAudioRequest(
|
||||
{ mode: "music", modelRef: "a", lyrics: "太短", instrumental: false },
|
||||
"fun-music-v1",
|
||||
),
|
||||
).toThrow("歌词");
|
||||
expect(() =>
|
||||
buildAudioRequest({ mode: "music", modelRef: "a", text: "音乐" }, "qwen3-tts-flash"),
|
||||
).toThrow("不匹配");
|
||||
});
|
||||
test("audio credentials can only target official domains and correct region", () => {
|
||||
expect(
|
||||
officialAudioBase("https://dashscope.aliyuncs.com/compatible-mode/v1", "fun-music-v1"),
|
||||
).toBe("https://dashscope.aliyuncs.com");
|
||||
expect(() => officialAudioBase("https://dashscope.aliyuncs.com.evil.test")).toThrow();
|
||||
expect(() => officialAudioBase("http://dashscope.aliyuncs.com")).toThrow();
|
||||
expect(() => officialAudioBase("https://dashscope-intl.aliyuncs.com", "fun-music-v1")).toThrow(
|
||||
"北京",
|
||||
);
|
||||
});
|
||||
test("only matching persisted audio result may resume archival", () => {
|
||||
const task = {
|
||||
request: { kind: "audio", params: { modelRef: "ref" } },
|
||||
result: {
|
||||
upstreamAudio: {
|
||||
operation: "audio",
|
||||
modelRef: "ref",
|
||||
modelId: "qwen3-tts-flash",
|
||||
url: "https://example.com/audio.wav",
|
||||
format: "wav",
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(getAudioCheckpoint(task)).toMatchObject({ modelRef: "ref" });
|
||||
expect(
|
||||
getAudioCheckpoint({ ...task, request: { kind: "audio", params: { modelRef: "another" } } }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
getAudioCheckpoint({
|
||||
...task,
|
||||
result: { upstreamAudio: { ...task.result.upstreamAudio, url: "file:///tmp/private" } },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user