Add official Alibaba audio generation and editable director object tracks

This commit is contained in:
134 2026-09-17 15:43:32 +08:00
parent 292eddc5a6
commit cb42f27ee9
27 changed files with 2188 additions and 125 deletions

View 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 });
}
}

View File

@ -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);

View 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>
);
}

View File

@ -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,

View File

@ -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();

View 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>
);
}

View 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>
);
}

View File

@ -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}

View File

@ -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;

View 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`。该部分为已完成研究和实施方案,尚未接入完整自动混剪服务。
部署状态:本次生产环境仅更新上述模型配置;应用代码在开发分支完成,未发布到生产应用。

View 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 请求内加载一份模型。此处是实施建议,本次没有启动新的常驻模型服务。

View 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(),
});

View 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 });
}
}

View File

@ -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];

View 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 })),
},
]),
);
}

View 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: {} } : {}),
};
}

View File

@ -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 {

View File

@ -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))

View File

@ -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,

View File

@ -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 &&

View File

@ -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,

View File

@ -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)

View 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());

View 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;
});

View File

@ -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}};`,

View File

@ -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({});
});

View File

@ -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();
});