fix(video-canvas): resume provider jobs and recover editing and 3d state
This commit is contained in:
parent
69dcf9b745
commit
292eddc5a6
@ -186,7 +186,7 @@ export async function PUT(
|
||||
tx,
|
||||
)
|
||||
: undefined;
|
||||
const updateData: any = { updatedAt: new Date() };
|
||||
const updateData: any = { updatedAt: new Date(Math.max(Date.now(), (existingProject?.updatedAt.getTime() || 0) + 1)) };
|
||||
if (title !== undefined) updateData.title = title;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
if (thumbnail !== undefined) updateData.thumbnail = thumbnail;
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { VideoCanvasConflictNotice } from "./VideoCanvasConflictNotice";
|
||||
import { normalizeVideoAnalysisRows } from "@/lib/video-canvas/video-story";
|
||||
import dynamic from "next/dynamic";
|
||||
import { signOut } from "next-auth/react";
|
||||
@ -1401,8 +1402,6 @@ function serializeNodes(nodes: CloneNode[]) {
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
}
|
||||
|
||||
@ -4802,14 +4801,33 @@ export function LiblibVideoCanvasReplica({
|
||||
.finally(() => { videoArchiveRunning.current = false; setVideoArchiveTick(value => value + 1); });
|
||||
}, [nodes, loadedProject, isProjectLoading, videoArchiveTick]);
|
||||
|
||||
const canvasSnapshot = useMemo(() => JSON.stringify({
|
||||
title: projectTitle,
|
||||
canvasData: {
|
||||
nodes: serializeNodes(nodes), edges: serializeEdges(edges),
|
||||
selectedNodeIds: selectedNodeId ? [selectedNodeId] : [], viewport, tool: "select",
|
||||
},
|
||||
}), [nodes, edges, selectedNodeId, viewport, projectTitle]);
|
||||
const persistence = useVideoCanvasPersistence(canvasId, loadedProject, canvasSnapshot);
|
||||
const canvasSnapshot = useMemo(
|
||||
() =>
|
||||
JSON.stringify({
|
||||
title: projectTitle,
|
||||
canvasData: {
|
||||
nodes: serializeNodes(nodes),
|
||||
edges: serializeEdges(edges),
|
||||
selectedNodeIds: selectedNodeId ? [selectedNodeId] : [],
|
||||
viewport,
|
||||
tool: "select",
|
||||
},
|
||||
}),
|
||||
[nodes, edges, selectedNodeId, viewport, projectTitle],
|
||||
);
|
||||
const applyMergedSnapshot = useCallback((value: string) => {
|
||||
const merged = JSON.parse(value);
|
||||
setNodes(restoreNodes(merged.canvasData));
|
||||
setEdges(restoreEdges(merged.canvasData));
|
||||
setProjectTitle(normalizeCanvasTitle(merged.title));
|
||||
setDraftTitle(normalizeCanvasTitle(merged.title));
|
||||
}, []);
|
||||
const persistence = useVideoCanvasPersistence(
|
||||
canvasId,
|
||||
loadedProject,
|
||||
canvasSnapshot,
|
||||
applyMergedSnapshot,
|
||||
);
|
||||
|
||||
const commitTitle = useCallback(() => {
|
||||
const nextTitle = draftTitle.trim() || "未命名";
|
||||
@ -10614,6 +10632,31 @@ export function LiblibVideoCanvasReplica({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<VideoCanvasConflictNotice
|
||||
fields={persistence.conflicts.map((path) => {
|
||||
const id = path.match(/nodes\[([^\]]+)\]/)?.[1],
|
||||
node = nodes.find((n) => n.id === id);
|
||||
const last = path.split(".").pop() || "内容";
|
||||
const names: Record<string, string> = {
|
||||
title: "名称",
|
||||
x: "横向位置",
|
||||
y: "纵向位置",
|
||||
position: "位置",
|
||||
prompt: "提示词",
|
||||
content: "文本",
|
||||
rows: "镜头",
|
||||
mediaUrl: "素材",
|
||||
url: "素材",
|
||||
motion: "运镜",
|
||||
directorScene: "3D 场景",
|
||||
width: "宽度",
|
||||
height: "高度",
|
||||
};
|
||||
return `${node?.title || "画布"} · ${names[last] || "内容或结构"}`;
|
||||
})}
|
||||
onResolve={persistence.resolveConflict}
|
||||
onDownload={persistence.downloadDraft}
|
||||
/>
|
||||
<TopBar
|
||||
saveStatus={persistence.ready ? persistence.status : "unavailable"}
|
||||
saveMessage={persistence.message}
|
||||
|
||||
58
components/canvas/VideoCanvasConflictNotice.tsx
Normal file
58
components/canvas/VideoCanvasConflictNotice.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import type { CanvasConflictChoice } from "@/lib/video-canvas/merge-snapshot";
|
||||
export function VideoCanvasConflictNotice({
|
||||
fields,
|
||||
onResolve,
|
||||
onDownload,
|
||||
}: {
|
||||
fields: string[];
|
||||
onResolve: (choice: CanvasConflictChoice) => Promise<void> | undefined;
|
||||
onDownload: () => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
if (!fields.length) return null;
|
||||
const resolve = async (choice: CanvasConflictChoice) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await onResolve(choice);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="absolute left-1/2 top-20 z-[200] w-[min(680px,90vw)] -translate-x-1/2 rounded-xl border border-amber-400/40 bg-[#29251b] p-4 text-sm text-white shadow-xl"
|
||||
>
|
||||
<p className="font-semibold">其他页面修改了相同内容,当前草稿已保留</p>
|
||||
<p className="mt-2 text-white/70">
|
||||
共有 {fields.length} 处冲突。不同位置的修改会自动合并;以下选择仅处理冲突部分。
|
||||
</p>
|
||||
<ul className="my-2 max-h-32 overflow-auto text-xs text-white/60">
|
||||
{fields.map((field, index) => (
|
||||
<li key={`${field}-${index}`}>{field}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
disabled={busy}
|
||||
className="rounded bg-white/10 px-3 py-2 disabled:opacity-40"
|
||||
onClick={() => void resolve("local")}
|
||||
>
|
||||
冲突处保留本页
|
||||
</button>
|
||||
<button
|
||||
disabled={busy}
|
||||
className="rounded bg-white/10 px-3 py-2 disabled:opacity-40"
|
||||
onClick={() => void resolve("remote")}
|
||||
>
|
||||
冲突处采用服务器版
|
||||
</button>
|
||||
<button className="rounded bg-white/10 px-3 py-2" onClick={onDownload}>
|
||||
下载当前草稿
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { transformDirectorMotion } from "@/lib/video-canvas/director-extensions";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@ -37,6 +38,8 @@ export function DirectorMotionPanel({
|
||||
onCancel: () => void;
|
||||
onOverview: () => void;
|
||||
}) {
|
||||
const [autoKeyframe, setAutoKeyframe] = useState(false);
|
||||
const previousPose = useRef(JSON.stringify(pose));
|
||||
const [time, setTime] = useState(0),
|
||||
[playing, setPlaying] = useState(false),
|
||||
[preview, setPreview] = useState(false);
|
||||
@ -127,6 +130,12 @@ export function DirectorMotionPanel({
|
||||
),
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
const signature = JSON.stringify(pose),
|
||||
changed = signature !== previousPose.current;
|
||||
previousPose.current = signature;
|
||||
if (autoKeyframe && changed && !preview && !disabled) add();
|
||||
}, [pose, autoKeyframe, preview, disabled]);
|
||||
const downloadJson = () => {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([JSON.stringify({ version: 1, motion }, null, 2)], {
|
||||
@ -279,6 +288,39 @@ export function DirectorMotionPanel({
|
||||
循环
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="自动记录关键帧"
|
||||
checked={autoKeyframe}
|
||||
onChange={(e) => setAutoKeyframe(e.target.checked)}
|
||||
/>
|
||||
编辑机位时自动记录当前帧
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={!motion.keyframes.length}
|
||||
onClick={() => {
|
||||
edit();
|
||||
onChange(transformDirectorMotion(motion, [1, 0, 0], 0));
|
||||
}}
|
||||
>
|
||||
整条轨迹右移 1 米
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
disabled={!motion.keyframes.length}
|
||||
onClick={() => {
|
||||
edit();
|
||||
onChange(transformDirectorMotion(motion, [0, 0, 0], 90));
|
||||
}}
|
||||
>
|
||||
轨迹与朝向旋转 90°
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className={button} disabled={preview} onClick={add}>
|
||||
记录/更新当前机位
|
||||
</button>
|
||||
|
||||
@ -1,4 +1,12 @@
|
||||
"use client";
|
||||
import {
|
||||
commitDirectorScene,
|
||||
resolveDirectorCamera,
|
||||
cameraAngles,
|
||||
rotateDirectorCamera,
|
||||
directorBodies,
|
||||
createDirectorCrowd,
|
||||
} from "@/lib/video-canvas/director-extensions";
|
||||
import { directorPoses, type DirectorPoseName } from "@/lib/video-canvas/director-poses";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Camera, Plus, Upload, X, Trash2 } from "lucide-react";
|
||||
@ -57,7 +65,9 @@ export function DirectorStudioDialog({
|
||||
const [scene, setScene] = useState(() => readDirectorScene(initialScene));
|
||||
const current = useRef(scene);
|
||||
current.current = scene;
|
||||
const commit = (next: DirectorScene) => {
|
||||
const commit = (input: DirectorScene) => {
|
||||
const next = commitDirectorScene(current.current, input);
|
||||
if (next.activeCameraId !== current.current.activeCameraId) setPreviewTime(null);
|
||||
current.current = next;
|
||||
setScene(next);
|
||||
onChange(next);
|
||||
@ -114,7 +124,15 @@ export function DirectorStudioDialog({
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const motion = current.current.motion;
|
||||
const motion = {
|
||||
...current.current.motion,
|
||||
keyframes: current.current.motion.keyframes.map((k) => ({
|
||||
...k,
|
||||
...resolveDirectorCamera(current.current, k),
|
||||
id: k.id,
|
||||
time: k.time,
|
||||
})),
|
||||
};
|
||||
const pose = sampleDirectorMotion(motion, 0);
|
||||
if (!pose || motion.keyframes.length < 2) throw Error("至少需要两个关键帧");
|
||||
const image = await viewport.current!.capture(pose);
|
||||
@ -143,8 +161,10 @@ export function DirectorStudioDialog({
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const backgroundInput = useRef<HTMLInputElement>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const camera = scene.cameras.find((c) => c.id === scene.activeCameraId) || scene.cameras[0];
|
||||
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({
|
||||
@ -155,7 +175,14 @@ export function DirectorStudioDialog({
|
||||
commit({
|
||||
...current.current,
|
||||
cameras: current.current.cameras.map((c) =>
|
||||
c.id === current.current.activeCameraId ? mergeDirectorCameraPose(c, patch) : c,
|
||||
c.id === current.current.activeCameraId
|
||||
? {
|
||||
...mergeDirectorCameraPose(c, patch),
|
||||
...(Object.hasOwn(patch, "followObjectId")
|
||||
? { followObjectId: patch.followObjectId }
|
||||
: {}),
|
||||
}
|
||||
: c,
|
||||
),
|
||||
});
|
||||
const addObject = (kind: DirectorObject["kind"], name: string, url?: string) => {
|
||||
@ -286,6 +313,31 @@ export function DirectorStudioDialog({
|
||||
<Upload size={14} className="mr-2 inline" />
|
||||
上传 GLB 模型
|
||||
</button>
|
||||
<button
|
||||
className={button}
|
||||
disabled={busy}
|
||||
onClick={() => backgroundInput.current?.click()}
|
||||
>
|
||||
上传 360° 全景背景
|
||||
</button>
|
||||
<label className="block text-xs">
|
||||
全景背景历史
|
||||
<select
|
||||
aria-label="全景背景历史"
|
||||
className={`${field} mt-1`}
|
||||
value={scene.backgroundUrl || ""}
|
||||
onChange={(e) =>
|
||||
commit({ ...current.current, backgroundUrl: e.target.value || undefined })
|
||||
}
|
||||
>
|
||||
<option value="">默认背景</option>
|
||||
{scene.backgroundHistory?.map((url, index) => (
|
||||
<option key={url} value={url}>
|
||||
全景背景 {index + 1}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button className={button} disabled title="尚未接入图片识别与场景重建">
|
||||
AI 识图导入(未接通)
|
||||
</button>
|
||||
@ -388,6 +440,55 @@ export function DirectorStudioDialog({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="block text-sm">
|
||||
跟随对象
|
||||
<select
|
||||
aria-label="相机跟随对象"
|
||||
className={`${field} mt-2`}
|
||||
value={camera.followObjectId || ""}
|
||||
onChange={(e) =>
|
||||
patchCamera({
|
||||
followObjectId: e.target.value || undefined,
|
||||
target: camera.target,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">不跟随</option>
|
||||
{scene.objects.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(["yaw", "pitch"] as const).map((axis) => (
|
||||
<label key={axis} className="text-xs">
|
||||
{axis === "yaw" ? "水平旋转(度)" : "俯仰旋转(度)"}
|
||||
<input
|
||||
aria-label={`相机${axis}`}
|
||||
className={field}
|
||||
type="number"
|
||||
step="1"
|
||||
min={axis === "yaw" ? -180 : -89}
|
||||
max={axis === "yaw" ? 180 : 89}
|
||||
value={Number(angles[axis].toFixed(1))}
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
if (Number.isFinite(n))
|
||||
patchCamera({
|
||||
target: rotateDirectorCamera(
|
||||
camera,
|
||||
axis === "yaw" ? n : angles.yaw,
|
||||
axis === "pitch" ? n : angles.pitch,
|
||||
),
|
||||
followObjectId: undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<label className="block text-sm">
|
||||
视角 FOV
|
||||
<input
|
||||
@ -492,6 +593,25 @@ export function DirectorStudioDialog({
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{object.kind === "actor" && (
|
||||
<label className="block text-xs">
|
||||
人偶体型
|
||||
<select
|
||||
aria-label="人偶体型"
|
||||
className={`${field} mt-1`}
|
||||
value={object.body || "标准"}
|
||||
onChange={(e) =>
|
||||
patchObject(object.id, {
|
||||
body: e.target.value as keyof typeof directorBodies,
|
||||
})
|
||||
}
|
||||
>
|
||||
{Object.keys(directorBodies).map((name) => (
|
||||
<option key={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{object.kind === "actor" && (
|
||||
<label className="block text-xs">
|
||||
人偶姿势
|
||||
@ -527,6 +647,26 @@ export function DirectorStudioDialog({
|
||||
>
|
||||
复制对象
|
||||
</button>
|
||||
<button
|
||||
className={button}
|
||||
onClick={() => {
|
||||
try {
|
||||
const crowd = createDirectorCrowd(
|
||||
object,
|
||||
current.current.objects.length,
|
||||
() => crypto.randomUUID(),
|
||||
);
|
||||
commit({
|
||||
...current.current,
|
||||
objects: [...current.current.objects, ...crowd],
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "阵列创建失败");
|
||||
}
|
||||
}}
|
||||
>
|
||||
创建 3×3 阵列
|
||||
</button>
|
||||
<button
|
||||
className={button}
|
||||
onClick={() =>
|
||||
@ -652,8 +792,11 @@ export function DirectorStudioDialog({
|
||||
open
|
||||
className="max-h-[32vh] shrink-0 overflow-auto border-t border-white/15 bg-[#1c1c1c] px-4 pb-3"
|
||||
>
|
||||
<summary className="cursor-pointer py-2 text-sm">相机时间轴与运镜</summary>
|
||||
<summary className="cursor-pointer py-2 text-sm">
|
||||
相机时间轴与运镜 · {camera.name}(独立轨道)
|
||||
</summary>
|
||||
<DirectorMotionPanel
|
||||
key={scene.activeCameraId}
|
||||
compact
|
||||
motion={scene.motion}
|
||||
pose={camera}
|
||||
@ -671,6 +814,40 @@ export function DirectorStudioDialog({
|
||||
}}
|
||||
/>
|
||||
</details>
|
||||
<input
|
||||
ref={backgroundInput}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
toast.error("全景背景需小于 20 MiB");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const ratio = bitmap.width / bitmap.height;
|
||||
bitmap.close();
|
||||
if (Math.abs(ratio - 2) > 0.02) throw Error("请上传 2:1 比例的等距柱状全景图");
|
||||
const url = await upload(file);
|
||||
commit({
|
||||
...current.current,
|
||||
backgroundUrl: url,
|
||||
backgroundHistory: [
|
||||
...new Set([...(current.current.backgroundHistory || []), url]),
|
||||
].slice(-20),
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "全景背景上传失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={fileInput}
|
||||
className="hidden"
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
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";
|
||||
import * as THREE from "three";
|
||||
@ -58,10 +59,15 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
resize: () => void;
|
||||
} | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [rendererRevision, setRendererRevision] = useState(0);
|
||||
const [resourceRevision, setResourceRevision] = useState(0);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [backgroundPending, setBackgroundPending] = useState(false);
|
||||
const [backgroundError, setBackgroundError] = useState("");
|
||||
useEffect(() => {
|
||||
if (!host.current) return;
|
||||
setError("");
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
@ -160,8 +166,19 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
if (orbit.enabled) orbit.update();
|
||||
renderer.render(world, camera);
|
||||
});
|
||||
const contextLost = (event: Event) => {
|
||||
event.preventDefault();
|
||||
renderer.setAnimationLoop(null);
|
||||
setReady(false);
|
||||
setError("3D 显示连接已中断,请点击重试恢复场景");
|
||||
};
|
||||
const contextRestored = () => setRendererRevision((value) => value + 1);
|
||||
renderer.domElement.addEventListener("webglcontextlost", contextLost);
|
||||
renderer.domElement.addEventListener("webglcontextrestored", contextRestored);
|
||||
setReady(true);
|
||||
return () => {
|
||||
renderer.domElement.removeEventListener("webglcontextlost", contextLost);
|
||||
renderer.domElement.removeEventListener("webglcontextrestored", contextRestored);
|
||||
runtime.current = null;
|
||||
observer.disconnect();
|
||||
renderer.setAnimationLoop(null);
|
||||
@ -171,11 +188,12 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
renderer.dispose();
|
||||
renderer.domElement.remove();
|
||||
};
|
||||
}, []);
|
||||
}, [rendererRevision]);
|
||||
useEffect(() => {
|
||||
const r = runtime.current;
|
||||
if (!r || !ready) return;
|
||||
let active = true;
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
r.transform.detach();
|
||||
const desired = new Set(props.scene.objects.map((o) => o.id));
|
||||
r.objects.children.slice().forEach((o) => {
|
||||
@ -198,6 +216,7 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
item.url,
|
||||
item.pose,
|
||||
item.color,
|
||||
item.body,
|
||||
]);
|
||||
object.position.fromArray(item.position);
|
||||
object.rotation.set(...item.rotation);
|
||||
@ -217,7 +236,7 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
if (
|
||||
existing &&
|
||||
existing.userData.directorSignature ===
|
||||
JSON.stringify([item.kind, item.url, item.pose, item.color])
|
||||
JSON.stringify([item.kind, item.url, item.pose, item.color, item.body])
|
||||
) {
|
||||
attach(existing, item);
|
||||
continue;
|
||||
@ -228,12 +247,35 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
}
|
||||
if (item.kind === "model" && item.url) {
|
||||
r.pending++;
|
||||
let settled = false;
|
||||
const finish = (message?: string) => {
|
||||
if (!active || settled) return false;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
r.pending--;
|
||||
setLoading(r.pending > 0);
|
||||
if (message) setError(`${item.name}:${message}`);
|
||||
return true;
|
||||
};
|
||||
const timeout = setTimeout(() => finish("模型加载超过 30 秒,请检查网络后重试"), 30000);
|
||||
timers.push(timeout);
|
||||
new GLTFLoader().load(
|
||||
buildThreeAssetUrl(item.url),
|
||||
(g) => {
|
||||
if (active) {
|
||||
if (!active || settled) {
|
||||
dispose(g.scene);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const box = new THREE.Box3().setFromObject(g.scene);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
if (
|
||||
box.isEmpty() ||
|
||||
!size.toArray().every(Number.isFinite) ||
|
||||
size.length() < 0.000001
|
||||
) {
|
||||
throw Error("模型没有可显示的有效几何内容");
|
||||
}
|
||||
const scale = 2 / Math.max(size.x, size.y, size.z, 0.001);
|
||||
g.scene.scale.setScalar(scale);
|
||||
const normalized = new THREE.Group();
|
||||
@ -242,18 +284,14 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
const center = fitted.getCenter(new THREE.Vector3());
|
||||
g.scene.position.set(-center.x, -fitted.min.y, -center.z);
|
||||
attach(normalized, item);
|
||||
r.pending--;
|
||||
setLoading(r.pending > 0);
|
||||
} else dispose(g.scene);
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
if (active) {
|
||||
r.pending--;
|
||||
setLoading(r.pending > 0);
|
||||
setError("模型加载失败,请检查 GLB 文件及资源访问权限");
|
||||
finish();
|
||||
} catch (failure) {
|
||||
dispose(g.scene);
|
||||
finish(failure instanceof Error ? failure.message : "模型内容读取失败");
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
() => finish("模型加载失败,请检查 GLB 文件及资源访问权限"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@ -322,7 +360,13 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
);
|
||||
limb(hips, [side * 0.15, 0, 0], pose.legs[i], pose.knees[i], 0.46, 0.1);
|
||||
}
|
||||
object = group;
|
||||
const [width, height, headScale] =
|
||||
directorBodies[item.body || "标准"] || directorBodies["标准"];
|
||||
group.scale.set(width, height, width);
|
||||
head.scale.setScalar(headScale);
|
||||
const root = new THREE.Group();
|
||||
root.add(group);
|
||||
object = root;
|
||||
} else {
|
||||
object = new THREE.Mesh(
|
||||
item.kind === "sphere"
|
||||
@ -336,8 +380,58 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
setLoading(r.pending > 0);
|
||||
return () => {
|
||||
active = false;
|
||||
timers.forEach(clearTimeout);
|
||||
};
|
||||
}, [props.scene.objects, ready]);
|
||||
}, [props.scene.objects, ready, resourceRevision]);
|
||||
useEffect(() => {
|
||||
const r = runtime.current;
|
||||
if (!r) return;
|
||||
let active = true;
|
||||
let texture: THREE.Texture | undefined;
|
||||
setBackgroundError("");
|
||||
r.world.background = new THREE.Color("#c5cbd3");
|
||||
if (!props.scene.backgroundUrl) {
|
||||
setBackgroundPending(false);
|
||||
return;
|
||||
}
|
||||
setBackgroundPending(true);
|
||||
const timeout = setTimeout(() => {
|
||||
if (active) {
|
||||
active = false;
|
||||
setBackgroundPending(false);
|
||||
setBackgroundError("全景背景加载超时,请重新选择背景");
|
||||
}
|
||||
}, 20000);
|
||||
new THREE.TextureLoader().load(
|
||||
buildThreeAssetUrl(props.scene.backgroundUrl),
|
||||
(loaded) => {
|
||||
clearTimeout(timeout);
|
||||
if (!active) {
|
||||
loaded.dispose();
|
||||
return;
|
||||
}
|
||||
texture = loaded;
|
||||
loaded.mapping = THREE.EquirectangularReflectionMapping;
|
||||
loaded.colorSpace = THREE.SRGBColorSpace;
|
||||
r.world.background = loaded;
|
||||
setBackgroundPending(false);
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
if (active) {
|
||||
setBackgroundPending(false);
|
||||
setBackgroundError("全景背景加载失败,请检查图片或切回默认背景");
|
||||
}
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
active = false;
|
||||
clearTimeout(timeout);
|
||||
if (r.world.background === texture) r.world.background = new THREE.Color("#c5cbd3");
|
||||
texture?.dispose();
|
||||
};
|
||||
}, [props.scene.backgroundUrl, ready, resourceRevision]);
|
||||
useEffect(() => {
|
||||
const r = runtime.current;
|
||||
if (!r) return;
|
||||
@ -346,9 +440,7 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
r.transform.detach();
|
||||
return;
|
||||
}
|
||||
const camera =
|
||||
props.scene.cameras.find((c) => c.id === props.scene.activeCameraId) ||
|
||||
props.scene.cameras[0];
|
||||
const camera = resolveDirectorCamera(props.scene);
|
||||
if (props.previewTime === null) {
|
||||
r.camera.position.fromArray(camera.position);
|
||||
r.camera.fov = camera.fov;
|
||||
@ -411,15 +503,24 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
if (!r) return;
|
||||
r.path.visible = !props.locked && !props.cameraView && props.previewTime === null;
|
||||
if (props.locked || props.previewTime === null) return;
|
||||
const pose = sampleDirectorMotion(props.scene.motion, props.previewTime);
|
||||
if (!pose) return;
|
||||
const sampled = sampleDirectorMotion(props.scene.motion, props.previewTime);
|
||||
if (!sampled) return;
|
||||
const pose = resolveDirectorCamera(props.scene, sampled);
|
||||
r.camera.position.fromArray(pose.position);
|
||||
r.orbit.target.fromArray(pose.target);
|
||||
r.camera.fov = pose.fov;
|
||||
r.camera.updateProjectionMatrix();
|
||||
r.camera.lookAt(r.orbit.target);
|
||||
r.transform.detach();
|
||||
}, [props.previewTime, props.scene.motion, props.cameraView, props.locked, ready]);
|
||||
}, [
|
||||
props.previewTime,
|
||||
props.scene.motion,
|
||||
props.scene.cameras,
|
||||
props.scene.objects,
|
||||
props.cameraView,
|
||||
props.locked,
|
||||
ready,
|
||||
]);
|
||||
useEffect(() => {
|
||||
runtime.current?.resize();
|
||||
}, [props.scene.ratio, ready]);
|
||||
@ -443,6 +544,8 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
latest.current.onCamera(position.toArray(), center.toArray());
|
||||
},
|
||||
record: async (signal, onProgress) => {
|
||||
if (backgroundPending || backgroundError)
|
||||
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("至少需要两个关键帧");
|
||||
@ -470,7 +573,12 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
signal,
|
||||
onProgress,
|
||||
render: (time) => {
|
||||
const pose = sampleDirectorMotion(motion, time)!;
|
||||
if (r.renderer.getContext().isContextLost())
|
||||
throw Error("3D 显示连接中断,已取消视频导出");
|
||||
const pose = resolveDirectorCamera(
|
||||
latest.current.scene,
|
||||
sampleDirectorMotion(motion, time)!,
|
||||
);
|
||||
r.camera.position.fromArray(pose.position);
|
||||
r.orbit.target.fromArray(pose.target);
|
||||
r.camera.fov = pose.fov;
|
||||
@ -495,6 +603,8 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
}
|
||||
},
|
||||
capture: async (pose) => {
|
||||
if (backgroundPending || backgroundError)
|
||||
throw Error(backgroundError || "全景背景仍在加载,请稍后截图");
|
||||
const r = runtime.current;
|
||||
if (!r || !ready || r.pending || error) throw Error(error || "3D 场景尚未准备好");
|
||||
const { width, height } = directorImageSize(latest.current.scene.ratio);
|
||||
@ -508,6 +618,7 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
const enabled = r.orbit.enabled;
|
||||
r.orbit.enabled = false;
|
||||
if (pose) {
|
||||
pose = resolveDirectorCamera(latest.current.scene, pose);
|
||||
r.camera.position.fromArray(pose.position);
|
||||
r.orbit.target.fromArray(pose.target);
|
||||
r.camera.fov = pose.fov;
|
||||
@ -544,21 +655,31 @@ export const DirectorViewport = forwardRef<DirectorViewportHandle, Props>(
|
||||
}
|
||||
},
|
||||
}),
|
||||
[ready, error],
|
||||
[ready, error, backgroundPending, backgroundError],
|
||||
);
|
||||
return (
|
||||
<div className="relative h-full w-full" data-testid="director-viewport">
|
||||
<div ref={host} className="h-full w-full" />
|
||||
{error ? (
|
||||
{error || backgroundError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="absolute inset-x-4 top-4 rounded bg-red-950/90 p-3 text-sm text-white"
|
||||
>
|
||||
{error}
|
||||
{error || backgroundError}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-3 rounded border border-white/30 px-2 py-1"
|
||||
onClick={() => {
|
||||
if (ready) setResourceRevision((value) => value + 1);
|
||||
else setRendererRevision((value) => value + 1);
|
||||
}}
|
||||
>
|
||||
重试 3D 加载
|
||||
</button>
|
||||
</div>
|
||||
) : loading ? (
|
||||
) : loading || backgroundPending ? (
|
||||
<div className="absolute left-4 top-4 rounded bg-black/60 p-2 text-white">
|
||||
正在加载模型...
|
||||
{backgroundPending ? "正在加载全景背景…" : "正在加载模型…"}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@ -96,3 +96,50 @@ node scripts/qa/video-canvas-task-db.cjs
|
||||
- TypeScript 无错误、diff 空白检查通过。没有新增付费模型调用,没有部署 48 服务器。
|
||||
|
||||
后续仍需完成:其他供应商执行中断恢复、多标签编辑冲突、生产部署回归,以及原矩阵列出的高级功能缺口。本次未声称参考站 231 项全部完成或像素级一致。
|
||||
|
||||
## 后续修复:五类视频续查、多页面保存与 3D 加载(承接 69dcf9b7)
|
||||
|
||||
本节更新前面仍列为待办的五类视频供应商续查、多页面冲突和部分导演台能力。以下均为分支修复与本地验收;没有部署 48 服务器,没有新增付费生成,也没有声称参考站 231 项全部完成。
|
||||
|
||||
### 视频提交与恢复
|
||||
|
||||
- 阿里云、VIP98K external task、火山、AtlasCloud、Agnes 五类异步视频适配器接入同一任务生命周期。上游受理后先保存 taskId、原模型和服务商身份,再查询;恢复只 GET 原编号,不重复准备参考素材或 POST 生视频。
|
||||
- 恢复沿用既有 24 小时窗口、最多 20 次领取、30 秒至 5 分钟退避及执行权校验。由画布任务查询触发,不是脱离画布的永久后台调度器。
|
||||
- 模型或服务线路发生变化时停止自动查询并保留原编号。未取得编号的网络中断显示待核实,不自动重新提交;VIP98K/火山的持久任务路径禁用不确定 POST 的自动重试。明确的图片超限拒绝仍允许火山压缩后重试。
|
||||
- 明确失败/取消结束任务;AtlasCloud 的终态错误即使含有 network 字样也不会误当临时网络故障继续等待。没有上游编号的旧任务不能凭空恢复。
|
||||
- 去掉领取恢复任务时旧的 240 条扫描截断,按恢复窗口和领取次数筛选,避免陈旧记录挡住可恢复任务。
|
||||
|
||||
### 多页面保存与草稿
|
||||
|
||||
- 遇到保存版本冲突,读取最新服务器内容,对基线、本页与服务器三个版本按节点/连线稳定 ID 和字段合并。不同位置的修改自动保留,同字段、删除与编辑、相互冲突的顺序修改必须显式选择。
|
||||
- 页面展示冲突位置,可选择冲突处保留本页或服务器版;无冲突的修改继续保留。支持下载当前 JSON 草稿,不再让用户只能刷新丢掉一版修改。
|
||||
- 浏览器 sessionStorage 按画布保存当前标签页的未保存草稿,隐藏、离开和定时保存前保留;重开当前标签页时与服务器版本合并。修正初始化时旧快照覆盖刚恢复草稿的问题。
|
||||
- 移除节点序列化时每次重造的 createdAt/updatedAt,避免产生虚假冲突;服务器版本时间至少递增 1 毫秒。
|
||||
- 远端连续保存导致超过 3 次自动合并重试时,暂停并保留草稿,用户仍可手动重试,不会进入永久阻塞。当前只在保存冲突时拉取其他页面版本,没有新增实时多人协同;sessionStorage 不是跨设备备份,存储满时仍需使用草稿下载。
|
||||
|
||||
### 3D 导演台逐项记录
|
||||
|
||||
| 原矩阵 ID | 当前实现与修复 | 验收及剩余边界 |
|
||||
|---|---|---|
|
||||
| 08-01、08-15 | GLB 30 秒超时、404/解析失败提示、空几何拒绝、重试加载;WebGL 上下文丢失后可重建场景;异常中止视频导出 | 实际浏览器:404→重试显示橙色方块;35 秒响应在 30 秒提示超时,晚到结果不冒充成功;空模型提示无有效几何;模拟 GPU 丢失→重试恢复原对象。未覆盖所有浏览器/显卡 |
|
||||
| 08-10 | 八个人偶体型比例预设 | 实际浏览器选择幼童、重开保存;回归验证持久化。这是简化人偶比例,不等同参考站八种精细角色资产 |
|
||||
| 08-14 | 复制对象与 3×3 群众阵列,限制总数 200 | 实际浏览器显示 9 个人偶;回归验证数量、间距、上限。未新增自定义行列阵列编辑器 |
|
||||
| 08-17、08-18 | 上传 2:1 全景图、20 MiB 上限、20 条背景历史、默认背景、失败/20 秒超时提示及重试 | 实际 TextureLoader 浏览器渲染本地全景测试图、404 错误与切回默认背景;上传调用现有服务,未做生产对象存储上传验收 |
|
||||
| 09-02、09-14 | 各机位保存独立相机轨迹,切换不串轨 | 浏览器:机位 1 两帧→新机位零帧→机位 1 恢复两帧;序列化回归通过。尚不是对象、相机、灯光同时运行的完整多轨系统 |
|
||||
| 09-04、09-05 | yaw/pitch 旋转、持续注视所选对象;预览、截图、真实录制和发送的运镜描述共用相机解析 | 浏览器移动对象后注视坐标跟随,手动旋转会退出跟随;方向/距离及解析一致性回归通过。未实现相机位置跟随对象平移或物体动画轨 |
|
||||
| 09-13 | 编辑机位时自动记录当前时间关键帧 | 浏览器启用后旋转相机更新当前帧;预览播放不自动写帧 |
|
||||
| 09-19 | 整条轨迹右移 1 米、轨迹与注视方向一起转 90° | 浏览器检查变换后的首帧坐标;回归验证距离不变。不是自由变换轴或铅笔/钢笔路径编辑器 |
|
||||
| 09-24 | MP4 不支持时回退 WebM VP9/VP8/默认编码,文件名与内容类型一致 | 录制测试覆盖 MP4 与 WebM 回退、切后台/取消/渲染失败清理;本轮未新增真实生产视频上传 |
|
||||
|
||||
### 最终本地验证
|
||||
|
||||
1. helpers/route **468 项通过,无跳过**,包括真实 FFmpeg 检查。新增模型恢复、合并冲突、独立轨道/跟随/体型/轨迹等回归;不是 468 个生产端到端测试。
|
||||
2. `scripts/qa/video-canvas-provider-recovery.cjs` 调用实际模型选择与五类供应商适配器,使用合成目录、凭证及 fetch 响应。逐一验证受理后先存编号、查询中断、GET-only 续查、原服务商身份、明确终态失败只查一次、提交网络中断只 POST 一次。脚本禁止访问测试域名以外的网络,没有付费生成。
|
||||
3. `scripts/qa/video-canvas-task-db.cjs` 的真实隔离 PostgreSQL 检查通过,新增视频编号持久化、重复节点去重、10 个并发调用仅一个接管;保留原高清独立进程中断恢复测试。视频本轮没有另做生产进程重启测试。
|
||||
4. 两个真实浏览器标签页使用实际持久化 Hook 和冲突组件:不同节点修改自动合并,同节点冲突可选择且保留另一节点修改;恢复预置的 sessionStorage 草稿后可保存。画布 CAS 接口为本地验收服务器,没有写生产项目。
|
||||
5. 实际导演台组件的 WebGL、独立机位、跟随、自动帧、群众阵列、全景和加载异常验收见上表。浏览器脚手架位于忽略目录 `.qa/video-concurrency`。
|
||||
6. TypeScript 无错误,Git diff 空白检查通过。复现供应商检查:`node scripts/qa/video-canvas-provider-recovery.cjs`;其余测试命令同前文。
|
||||
|
||||
### 仍未完成的范围
|
||||
|
||||
原矩阵是历史扫描基线,本节是增量结果,不能据此将原 79 项缺失全部改为已完成。仍缺:TTS/音乐/音色接口,多画布/故事板/Agent 与跨项目资产,部分专用视频后期和智能剪辑,精细角色/手工关节、AI 识图重建、AI 全景、对象多轨、时间轴单位缩放、自由绘制轨迹,以及参考站逐状态像素对照与上线验收。现有服务尚无通用 TTS/音乐实现,不能用仅有入口或普通模型目录代替这些能力。未接通入口继续明确标示状态。
|
||||
|
||||
@ -1,47 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { CanvasRevisionConflict, VideoCanvasSaveQueue, type VideoCanvasSaveStatus } from "@/lib/video-canvas/save-queue";
|
||||
import { mergeCanvasSnapshots, type CanvasConflictChoice } from "@/lib/video-canvas/merge-snapshot";
|
||||
import {
|
||||
CanvasRevisionConflict,
|
||||
VideoCanvasSaveQueue,
|
||||
type VideoCanvasSaveStatus,
|
||||
} from "@/lib/video-canvas/save-queue";
|
||||
|
||||
export function useVideoCanvasPersistence(
|
||||
projectId: string,
|
||||
loaded: { id: string; revision: string } | null,
|
||||
snapshot: string,
|
||||
applySnapshot?: (snapshot: string) => void,
|
||||
) {
|
||||
const queueRef = useRef<VideoCanvasSaveQueue | null>(null);
|
||||
const latestSnapshot = useRef(snapshot);
|
||||
const observedSnapshot = useRef(snapshot);
|
||||
latestSnapshot.current = snapshot;
|
||||
const [state, setState] = useState<{ status: VideoCanvasSaveStatus; message?: string }>({ status: "saved" });
|
||||
const [state, setState] = useState<{ status: VideoCanvasSaveStatus; message?: string }>({
|
||||
status: "saved",
|
||||
});
|
||||
const [conflicts, setConflicts] = useState<string[]>([]);
|
||||
const applyRef = useRef(applySnapshot);
|
||||
applyRef.current = applySnapshot;
|
||||
const ready = loaded?.id === projectId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || !loaded) return;
|
||||
let active = true;
|
||||
const queue = new VideoCanvasSaveQueue(latestSnapshot.current, loaded.revision, async (value, revision) => {
|
||||
const response = await fetch(`/api/canvas/${encodeURIComponent(projectId)}`, {
|
||||
method: "PUT",
|
||||
signal: AbortSignal.timeout(45_000),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...JSON.parse(value), workspaceType: "video", baseCanvasUpdatedAt: revision }),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (response.status === 409) {
|
||||
throw new CanvasRevisionConflict("此画布已在其他页面更新。请先保留当前内容,再刷新加载最新版本。");
|
||||
}
|
||||
if (!response.ok || !result.success || !result.data?.updatedAt) {
|
||||
throw new Error(result.error || "保存失败,请点击重试");
|
||||
}
|
||||
return String(result.data.updatedAt);
|
||||
}, (status, message) => { if (active) setState({ status, message }); });
|
||||
const queue = new VideoCanvasSaveQueue(
|
||||
latestSnapshot.current,
|
||||
loaded.revision,
|
||||
async (value, revision) => {
|
||||
const response = await fetch(`/api/canvas/${encodeURIComponent(projectId)}`, {
|
||||
method: "PUT",
|
||||
signal: AbortSignal.timeout(45_000),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...JSON.parse(value),
|
||||
workspaceType: "video",
|
||||
baseCanvasUpdatedAt: revision,
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (response.status === 409) {
|
||||
const latest = await fetch(`/api/canvas/${encodeURIComponent(projectId)}`, {
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const remote = await latest.json();
|
||||
if (!latest.ok || !remote.success || !remote.data?.updatedAt)
|
||||
throw Error("读取其他页面的修改失败,当前草稿已保留,请重试");
|
||||
throw new CanvasRevisionConflict(
|
||||
"其他页面修改了相同内容,请选择冲突部分保留哪一版",
|
||||
JSON.stringify({ title: remote.data.title, canvasData: remote.data.canvasData }),
|
||||
String(remote.data.updatedAt),
|
||||
);
|
||||
}
|
||||
if (!response.ok || !result.success || !result.data?.updatedAt) {
|
||||
throw new Error(result.error || "保存失败,请点击重试");
|
||||
}
|
||||
return String(result.data.updatedAt);
|
||||
},
|
||||
(status, message) => {
|
||||
if (active) setState({ status, message });
|
||||
},
|
||||
applyRef.current
|
||||
? {
|
||||
merge: mergeCanvasSnapshots,
|
||||
apply: (value) => {
|
||||
if (active) applyRef.current?.(value);
|
||||
},
|
||||
conflict: (fields) => {
|
||||
if (active) setConflicts(fields);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
queueRef.current = queue;
|
||||
observedSnapshot.current = latestSnapshot.current;
|
||||
setConflicts([]);
|
||||
setState({ status: "saved" });
|
||||
const backupKey = `video-canvas-draft:${projectId}`;
|
||||
try {
|
||||
const backup = JSON.parse(sessionStorage.getItem(backupKey) || "null");
|
||||
if (backup?.base && backup?.snapshot) queue.restoreBackup(backup.base, backup.snapshot);
|
||||
} catch {
|
||||
/* Ignore corrupt draft cache; server content is intact. */
|
||||
}
|
||||
const retainDraft = () => {
|
||||
try {
|
||||
if (queue.dirty) sessionStorage.setItem(backupKey, JSON.stringify(queue.backup));
|
||||
else sessionStorage.removeItem(backupKey);
|
||||
} catch {
|
||||
/* The unsaved-change warning and JSON export remain available when browser storage is full. */
|
||||
}
|
||||
};
|
||||
// Fixed cadence also saves while generation progress is continuously changing.
|
||||
const interval = window.setInterval(() => {
|
||||
if (queue.dirty && !queue.failed) void queue.flush();
|
||||
retainDraft();
|
||||
if (queue.dirty && !queue.failed) void queue.flush().then(retainDraft);
|
||||
}, 1500);
|
||||
const onHidden = () => { if (document.visibilityState === "hidden") void queue.flush(); };
|
||||
const onHidden = () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
retainDraft();
|
||||
void queue.flush().then(retainDraft);
|
||||
}
|
||||
};
|
||||
const onBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!queue.dirty) return;
|
||||
retainDraft();
|
||||
void queue.flush();
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
@ -53,12 +122,18 @@ export function useVideoCanvasPersistence(
|
||||
clearInterval(interval);
|
||||
document.removeEventListener("visibilitychange", onHidden);
|
||||
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
void queue.flush();
|
||||
retainDraft();
|
||||
void queue.flush().then(retainDraft);
|
||||
if (queueRef.current === queue) queueRef.current = null;
|
||||
};
|
||||
}, [projectId, ready, loaded]);
|
||||
|
||||
useEffect(() => { if (ready) queueRef.current?.update(snapshot); }, [snapshot, ready]);
|
||||
useEffect(() => {
|
||||
if (ready && observedSnapshot.current !== snapshot) {
|
||||
observedSnapshot.current = snapshot;
|
||||
queueRef.current?.update(snapshot);
|
||||
}
|
||||
}, [snapshot, ready]);
|
||||
|
||||
const save = useCallback(() => {
|
||||
if (!ready) return;
|
||||
@ -77,5 +152,18 @@ export function useVideoCanvasPersistence(
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [save]);
|
||||
|
||||
return { ...state, save, ready };
|
||||
const resolveConflict = useCallback(
|
||||
(choice: CanvasConflictChoice) => queueRef.current?.resolveConflict(choice),
|
||||
[],
|
||||
);
|
||||
const downloadDraft = useCallback(() => {
|
||||
const data = queueRef.current?.backup.snapshot || latestSnapshot.current;
|
||||
const url = URL.createObjectURL(new Blob([data], { type: "application/json" }));
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `video-canvas-${projectId}-draft.json`;
|
||||
anchor.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}, [projectId]);
|
||||
return { ...state, save, ready, conflicts, resolveConflict, downloadDraft };
|
||||
}
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
import {
|
||||
ProviderTaskFailedError,
|
||||
type ProviderTaskLifecycle,
|
||||
} from "@/lib/video-canvas/video-task-lifecycle";
|
||||
import type { ExecuteDirectVideoGenerationParams } from "@/lib/ai/tools/generate-video";
|
||||
|
||||
// This adapter deliberately exposes only the official model whose contract we support.
|
||||
@ -25,9 +29,18 @@ export function buildAliyunCanvasVideoRequest(params: ExecuteDirectVideoGenerati
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeAliyunCanvasVideo(params: ExecuteDirectVideoGenerationParams & {
|
||||
modelId: string; apiKey: string; baseURL: string;
|
||||
}, runtime = { fetch: globalThis.fetch, sleep: (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)) }) {
|
||||
export async function executeAliyunCanvasVideo(
|
||||
params: ExecuteDirectVideoGenerationParams & {
|
||||
modelId: string;
|
||||
apiKey: string;
|
||||
baseURL: string;
|
||||
lifecycle?: ProviderTaskLifecycle;
|
||||
},
|
||||
runtime = {
|
||||
fetch: globalThis.fetch,
|
||||
sleep: (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
|
||||
},
|
||||
) {
|
||||
const body = buildAliyunCanvasVideoRequest(params, params.modelId);
|
||||
const base = new URL(params.baseURL);
|
||||
base.pathname = "/api/v1";
|
||||
@ -35,7 +48,7 @@ export async function executeAliyunCanvasVideo(params: ExecuteDirectVideoGenerat
|
||||
base.hash = "";
|
||||
const headers = { Authorization: `Bearer ${params.apiKey}`, "Content-Type": "application/json", "X-DashScope-Async": "enable" };
|
||||
const deadline = Date.now() + 275_000;
|
||||
let taskId = "";
|
||||
let taskId = params.lifecycle?.resume?.taskId || "";
|
||||
const request = async (path: string, init: RequestInit = {}) => {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) throw new Error(`阿里云视频查询超时,任务 ${taskId},请勿重复生成`);
|
||||
@ -46,12 +59,20 @@ export async function executeAliyunCanvasVideo(params: ExecuteDirectVideoGenerat
|
||||
if (!response.ok) throw new Error(`阿里云视频请求失败(${response.status}):${data.code || data.error?.code || "UnknownError"}${taskId ? `,任务 ${taskId}` : ""}`);
|
||||
return data;
|
||||
};
|
||||
const submitted = await request("/services/aigc/video-generation/video-synthesis", { method: "POST", body: JSON.stringify(body) });
|
||||
taskId = submitted.output?.task_id;
|
||||
if (!taskId) throw new Error("阿里云未返回视频任务 ID");
|
||||
await params.lifecycle?.assertActive();
|
||||
if (!taskId) {
|
||||
const submitted = await request("/services/aigc/video-generation/video-synthesis", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
taskId = submitted.output?.task_id;
|
||||
if (!taskId) throw new Error("阿里云未返回视频任务 ID");
|
||||
await params.lifecycle?.accepted({ taskId });
|
||||
}
|
||||
await params.onProgress?.(`阿里云视频任务已提交:${taskId}`);
|
||||
while (Date.now() < deadline) {
|
||||
await runtime.sleep(Math.min(5_000, Math.max(0, deadline - Date.now())));
|
||||
await params.lifecycle?.assertActive();
|
||||
const result = await request(`/tasks/${encodeURIComponent(taskId)}`);
|
||||
const output = result.output || {};
|
||||
if (output.task_status === "SUCCEEDED") {
|
||||
@ -63,7 +84,9 @@ export async function executeAliyunCanvasVideo(params: ExecuteDirectVideoGenerat
|
||||
} };
|
||||
}
|
||||
if (["FAILED", "CANCELED", "UNKNOWN"].includes(output.task_status)) {
|
||||
throw new Error(`阿里云视频生成失败:${output.code || result.code || output.task_status},任务 ${taskId}`);
|
||||
throw new ProviderTaskFailedError(
|
||||
`阿里云视频生成失败:${output.code || result.code || output.task_status},任务 ${taskId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error(`阿里云视频查询超时,任务 ${taskId},请勿重复生成`);
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
import {
|
||||
ProviderTaskFailedError,
|
||||
type ProviderTaskLifecycle,
|
||||
} from "@/lib/video-canvas/video-task-lifecycle";
|
||||
import "server-only";
|
||||
|
||||
import sharp from "sharp";
|
||||
@ -280,11 +284,13 @@ async function waitForTask(params: {
|
||||
baseURL?: string;
|
||||
endpoint: string;
|
||||
taskId: string;
|
||||
lifecycle?: ProviderTaskLifecycle;
|
||||
}) {
|
||||
const startedAt = Date.now();
|
||||
let transientFailures = 0;
|
||||
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
|
||||
await delay(POLL_INTERVAL_MS);
|
||||
await params.lifecycle?.assertActive();
|
||||
try {
|
||||
const endpoint = params.endpoint.replace(
|
||||
/\{(?:request_id|id|task_id)\}/g,
|
||||
@ -313,9 +319,10 @@ async function waitForTask(params: {
|
||||
return outputs;
|
||||
}
|
||||
if (["failed", "error", "timeout", "canceled", "cancelled"].includes(status)) {
|
||||
throw new Error(extractErrorMessage(payload, "Atlas Cloud 图片生成失败"));
|
||||
throw new ProviderTaskFailedError(extractErrorMessage(payload, "Atlas Cloud 任务失败"));
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ProviderTaskFailedError) throw error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!/^HTTP (?:429|5\d\d)$/.test(message) && !/fetch|network|abort/i.test(message)) {
|
||||
throw error;
|
||||
@ -379,6 +386,7 @@ export async function executeAtlasCloudVideoGeneration(params: {
|
||||
modelId: string;
|
||||
modelConfig: AtlasCloudVideoModelConfig;
|
||||
options: VideoGenerationOptions;
|
||||
lifecycle?: ProviderTaskLifecycle;
|
||||
}) {
|
||||
const runtime = params.modelConfig.atlasCloud;
|
||||
if (!runtime) {
|
||||
@ -393,21 +401,30 @@ export async function executeAtlasCloudVideoGeneration(params: {
|
||||
0,
|
||||
Math.max(0, Math.min(14, runtime.maxReferenceImages || 0))
|
||||
);
|
||||
const referenceImages = await Promise.all(
|
||||
uniqueImages.map((url) => uploadReferenceMedia(params.baseURL, params.apiKey, url))
|
||||
);
|
||||
const { body } = createAtlasCloudVideoRequestBody({
|
||||
modelId: params.modelId,
|
||||
options: params.options,
|
||||
referenceImages,
|
||||
modelConfig: params.modelConfig,
|
||||
});
|
||||
const submitted = await submitTask({
|
||||
apiKey: params.apiKey,
|
||||
baseURL: params.baseURL,
|
||||
endpoint: runtime.submitEndpoint,
|
||||
body,
|
||||
});
|
||||
const referenceImages = params.lifecycle?.resume
|
||||
? []
|
||||
: await Promise.all(
|
||||
uniqueImages.map((url) => uploadReferenceMedia(params.baseURL, params.apiKey, url)),
|
||||
);
|
||||
const { body } = params.lifecycle?.resume
|
||||
? { body: {} }
|
||||
: createAtlasCloudVideoRequestBody({
|
||||
modelId: params.modelId,
|
||||
options: params.options,
|
||||
referenceImages,
|
||||
modelConfig: params.modelConfig,
|
||||
});
|
||||
await params.lifecycle?.assertActive();
|
||||
const submitted = params.lifecycle?.resume
|
||||
? { outputs: [] as string[], taskId: params.lifecycle.resume.taskId }
|
||||
: await submitTask({
|
||||
apiKey: params.apiKey,
|
||||
baseURL: params.baseURL,
|
||||
endpoint: runtime.submitEndpoint,
|
||||
body,
|
||||
});
|
||||
if (submitted.taskId && !params.lifecycle?.resume)
|
||||
await params.lifecycle?.accepted({ taskId: submitted.taskId });
|
||||
const outputUrls = submitted.outputs.length
|
||||
? submitted.outputs
|
||||
: await waitForTask({
|
||||
@ -415,6 +432,7 @@ export async function executeAtlasCloudVideoGeneration(params: {
|
||||
baseURL: params.baseURL,
|
||||
endpoint: runtime.pollEndpoint,
|
||||
taskId: submitted.taskId,
|
||||
lifecycle: params.lifecycle,
|
||||
});
|
||||
const videoUrl = outputUrls[0];
|
||||
if (!videoUrl) {
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
import {
|
||||
ProviderTaskFailedError,
|
||||
type ProviderTaskLifecycle,
|
||||
} from "@/lib/video-canvas/video-task-lifecycle";
|
||||
import "server-only";
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
@ -1211,6 +1215,7 @@ export type Vip98kExternalTaskStatus = {
|
||||
|
||||
export async function submitVip98kExternalTask(params: {
|
||||
apiKey: string;
|
||||
durable?: boolean;
|
||||
baseURL?: string | null;
|
||||
modelId: string;
|
||||
kind: string;
|
||||
@ -1283,7 +1288,7 @@ export async function submitVip98kExternalTask(params: {
|
||||
params.modelId,
|
||||
runtimeConfig,
|
||||
),
|
||||
maxSubmissionAttempts: runtimeConfig.maxSubmissionAttempts,
|
||||
maxSubmissionAttempts: params.durable ? 1 : runtimeConfig.maxSubmissionAttempts,
|
||||
bodyKeys: Object.keys(body).sort(),
|
||||
requestedSize: body.size,
|
||||
requestedAspectRatio: body.aspect_ratio || body.aspectRatio || body.ratio,
|
||||
@ -1302,7 +1307,7 @@ export async function submitVip98kExternalTask(params: {
|
||||
params.modelId,
|
||||
runtimeConfig,
|
||||
),
|
||||
maxSubmissionAttempts: runtimeConfig.maxSubmissionAttempts,
|
||||
maxSubmissionAttempts: params.durable ? 1 : runtimeConfig.maxSubmissionAttempts,
|
||||
submissionRetryBaseDelayMs: runtimeConfig.submissionRetryBaseDelayMs,
|
||||
});
|
||||
const resultPath = submitted.resultPath || invocation?.resultPath || "";
|
||||
@ -1352,6 +1357,7 @@ async function waitForTask(params: {
|
||||
apiKey: string;
|
||||
baseURL?: string | null;
|
||||
taskId: string;
|
||||
lifecycle?: ProviderTaskLifecycle;
|
||||
modelId: string;
|
||||
resultPath?: string;
|
||||
modelConfig: Vip98kModelRuntimeConfig;
|
||||
@ -1375,6 +1381,7 @@ async function waitForTask(params: {
|
||||
? params.resultPath
|
||||
: `${baseURL}${params.resultPath.startsWith("/") ? "" : "/"}${params.resultPath}`
|
||||
: `${baseURL}/api/external/task/result?taskId=${encodeURIComponent(params.taskId)}`;
|
||||
await params.lifecycle?.assertActive();
|
||||
const { payload, status, outputUrls: outputs } = await fetchTaskResult({
|
||||
apiKey: params.apiKey,
|
||||
baseURL,
|
||||
@ -1405,7 +1412,7 @@ async function waitForTask(params: {
|
||||
...extractPayloadShape(payload),
|
||||
});
|
||||
const detail = providerMessage ? `: ${providerMessage}` : "";
|
||||
throw new Error(
|
||||
throw new ProviderTaskFailedError(
|
||||
`VIP98K task failed${detail} (task_id: ${params.taskId}, model: ${params.modelId})`,
|
||||
);
|
||||
}
|
||||
@ -1613,10 +1620,22 @@ export async function executeVip98kExternalTask(params: {
|
||||
videoParams?: Record<string, unknown>;
|
||||
imageParams?: Record<string, unknown>;
|
||||
modelConfig?: Vip98kModelRuntimeConfig;
|
||||
lifecycle?: ProviderTaskLifecycle;
|
||||
}): Promise<Vip98kExternalTaskResult> {
|
||||
const submitted = await submitVip98kExternalTask({
|
||||
...params,
|
||||
});
|
||||
await params.lifecycle?.assertActive();
|
||||
const submitted = params.lifecycle?.resume
|
||||
? {
|
||||
...params.lifecycle.resume,
|
||||
outputUrls: [] as string[],
|
||||
payload: null,
|
||||
resultPath: params.lifecycle.resume.resultPath || "",
|
||||
}
|
||||
: await submitVip98kExternalTask({ ...params, durable: Boolean(params.lifecycle) });
|
||||
if (submitted.taskId && !params.lifecycle?.resume)
|
||||
await params.lifecycle?.accepted({
|
||||
taskId: submitted.taskId,
|
||||
resultPath: submitted.resultPath,
|
||||
});
|
||||
|
||||
if (submitted.outputUrls.length > 0) {
|
||||
return {
|
||||
@ -1636,6 +1655,7 @@ export async function executeVip98kExternalTask(params: {
|
||||
modelId: params.modelId,
|
||||
resultPath: submitted.resultPath,
|
||||
modelConfig: params.modelConfig || {},
|
||||
lifecycle: params.lifecycle,
|
||||
});
|
||||
return {
|
||||
taskId: submitted.taskId,
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
import {
|
||||
ProviderTaskFailedError,
|
||||
type ProviderTaskLifecycle,
|
||||
} from "@/lib/video-canvas/video-task-lifecycle";
|
||||
/**
|
||||
* 火山引擎视频生成 Provider(豆包 Seedance 模型)
|
||||
* 支持:文生视频、图生视频(首帧/首尾帧/参考图)
|
||||
@ -420,6 +424,7 @@ async function sendCreateVideoTaskRequest(params: {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
requestBody: Record<string, any>;
|
||||
durable?: boolean;
|
||||
}) {
|
||||
return fetchWithRetry(
|
||||
params.url,
|
||||
@ -434,7 +439,7 @@ async function sendCreateVideoTaskRequest(params: {
|
||||
{
|
||||
label: "create-task",
|
||||
timeoutMs: 90000,
|
||||
retries: 2,
|
||||
retries: params.durable ? 0 : 2,
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -530,7 +535,8 @@ function buildFullPrompt(options: VideoGenerationOptions): string {
|
||||
async function createVideoTask(
|
||||
baseURL: string,
|
||||
apiKey: string,
|
||||
options: VideoGenerationOptions
|
||||
options: VideoGenerationOptions,
|
||||
durable = false,
|
||||
): Promise<{ taskId: string; inputImageUrls: any }> {
|
||||
// 火山引擎官方文档:/contents/generations/tasks(复数形式)
|
||||
// https://www.volcengine.com/docs/82379/1520758
|
||||
@ -564,6 +570,7 @@ async function createVideoTask(
|
||||
url,
|
||||
apiKey,
|
||||
requestBody: payload.requestBody,
|
||||
durable,
|
||||
});
|
||||
|
||||
console.log("[Volcano Video] Response status:", response.status);
|
||||
@ -589,6 +596,7 @@ async function createVideoTask(
|
||||
url,
|
||||
apiKey,
|
||||
requestBody: payload.requestBody,
|
||||
durable,
|
||||
});
|
||||
console.log("[Volcano Video] Retry response status:", response.status);
|
||||
if (!response.ok) {
|
||||
@ -789,17 +797,23 @@ export async function doGenerate(
|
||||
export async function* doGenerateStream(
|
||||
apiKey: string,
|
||||
baseURL: string,
|
||||
options: VideoGenerationOptions
|
||||
options: VideoGenerationOptions,
|
||||
lifecycle?: ProviderTaskLifecycle,
|
||||
): AsyncGenerator<string, void, unknown> {
|
||||
try {
|
||||
// 1. 创建任务(不输出进度文字)
|
||||
const { taskId, inputImageUrls } = await createVideoTask(baseURL, apiKey, options);
|
||||
await lifecycle?.assertActive();
|
||||
const { taskId, inputImageUrls } = lifecycle?.resume
|
||||
? { taskId: lifecycle.resume.taskId, inputImageUrls: {} }
|
||||
: await createVideoTask(baseURL, apiKey, options, Boolean(lifecycle));
|
||||
if (!lifecycle?.resume) await lifecycle?.accepted({ taskId });
|
||||
|
||||
// 2. 轮询任务状态(静默等待)
|
||||
const maxRetries = 60;
|
||||
const pollInterval = 10000;
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
await lifecycle?.assertActive();
|
||||
let status: any;
|
||||
try {
|
||||
status = await fetchTaskStatus(baseURL, apiKey, taskId);
|
||||
@ -852,11 +866,11 @@ export async function* doGenerateStream(
|
||||
|
||||
if (status.status === "failed") {
|
||||
const errorMsg = status.error?.message || status.message || "未知错误";
|
||||
throw new Error(`视频生成失败: ${errorMsg}`);
|
||||
throw new ProviderTaskFailedError(`视频生成失败: ${errorMsg}`);
|
||||
}
|
||||
|
||||
if (status.status === "cancelled") {
|
||||
throw new Error("视频生成任务已取消");
|
||||
throw new ProviderTaskFailedError("视频生成任务已取消");
|
||||
}
|
||||
|
||||
// 静默等待,不输出进度文字
|
||||
|
||||
@ -1,3 +1,11 @@
|
||||
import {
|
||||
bindVideoTaskLifecycle,
|
||||
ProviderTaskFailedError,
|
||||
ProviderTaskUncertainError,
|
||||
type ProviderTaskLifecycle,
|
||||
type VideoTaskLifecycle,
|
||||
} from "@/lib/video-canvas/video-task-lifecycle";
|
||||
import { executeVip98kExternalTask } from "@/lib/ai/providers/vip98k";
|
||||
/**
|
||||
* 视频生成工具
|
||||
* 支持火山引擎视频生成的多种模式
|
||||
@ -37,6 +45,7 @@ export interface ExecuteDirectVideoGenerationParams {
|
||||
returnLastFrame?: boolean;
|
||||
preferredModelRef?: string;
|
||||
onProgress?: (chunk: string) => void | Promise<void>;
|
||||
taskLifecycle?: VideoTaskLifecycle;
|
||||
}
|
||||
|
||||
export interface ExecuteDirectVideoGenerationResult {
|
||||
@ -473,6 +482,7 @@ async function executeAgnesVideoGeneration(params: {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
options: VideoGenerationOptions;
|
||||
lifecycle?: ProviderTaskLifecycle;
|
||||
}) {
|
||||
const baseUrl = String(params.baseUrl || "https://apihub.agnes-ai.com/v1").replace(/\/+$/, "");
|
||||
const createUrl = `${baseUrl}/videos`;
|
||||
@ -501,14 +511,20 @@ async function executeAgnesVideoGeneration(params: {
|
||||
body.mode = "ti2vid";
|
||||
}
|
||||
|
||||
const createResponse = await fetch(createUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
await params.lifecycle?.assertActive();
|
||||
const createResponse = params.lifecycle?.resume
|
||||
? Response.json({
|
||||
video_id: params.lifecycle.resume.videoId,
|
||||
task_id: params.lifecycle.resume.taskId,
|
||||
})
|
||||
: await fetch(createUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const createText = await createResponse.text();
|
||||
let createData: Record<string, any> = {};
|
||||
try {
|
||||
@ -528,16 +544,20 @@ async function executeAgnesVideoGeneration(params: {
|
||||
throw new Error("Agnes 视频任务创建成功但没有返回 video_id/task_id");
|
||||
}
|
||||
|
||||
if (!params.lifecycle?.resume)
|
||||
await params.lifecycle?.accepted({ taskId: taskId || videoId, videoId });
|
||||
const startedAt = Date.now();
|
||||
const timeoutMs = 285_000;
|
||||
let lastData = createData;
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
await params.lifecycle?.assertActive();
|
||||
const resultUrl = videoId
|
||||
? buildAgnesResultUrl(baseUrl, videoId, params.options.model)
|
||||
: `${baseUrl}/videos/${encodeURIComponent(taskId)}`;
|
||||
const resultResponse = await fetch(resultUrl, {
|
||||
headers: { Authorization: `Bearer ${params.apiKey}` },
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
const resultText = await resultResponse.text();
|
||||
let resultData: Record<string, any> = {};
|
||||
@ -563,7 +583,9 @@ async function executeAgnesVideoGeneration(params: {
|
||||
};
|
||||
}
|
||||
if (status === "failed") {
|
||||
throw new Error(`Agnes 视频生成失败: ${resultData?.error?.message || resultData?.error || "任务失败"}`);
|
||||
throw new ProviderTaskFailedError(
|
||||
`Agnes 视频生成失败: ${resultData?.error?.message || resultData?.error || "任务失败"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -615,7 +637,39 @@ async function executeVip98kVideoGeneration(params: {
|
||||
generationParams: Partial<VideoGenerationOptions>;
|
||||
}): Promise<ExecuteDirectVideoGenerationResult> {
|
||||
const selected = params.selected.model;
|
||||
params.generationParams.duration ??= params.selected.capabilities.durationOptions?.[0] || params.selected.capabilities.durationRange?.min || 5;
|
||||
params.generationParams.duration ??=
|
||||
params.selected.capabilities.durationOptions?.[0] ||
|
||||
params.selected.capabilities.durationRange?.min ||
|
||||
5;
|
||||
const lifecycle = bindVideoTaskLifecycle(params.request.taskLifecycle, selected, "vip98k");
|
||||
if (lifecycle) {
|
||||
const result = await executeVip98kExternalTask({
|
||||
apiKey: selected.provider.apiKey,
|
||||
baseURL: selected.provider.baseUrl,
|
||||
modelId: selected.model.modelId,
|
||||
kind: "video",
|
||||
prompt: params.prompt,
|
||||
imageUrls: params.request.images,
|
||||
videoParams: params.generationParams as Record<string, unknown>,
|
||||
modelConfig: {
|
||||
imageGenConfig: selected.model.imageGenConfig || {},
|
||||
capabilities: selected.model.capabilities,
|
||||
},
|
||||
lifecycle,
|
||||
});
|
||||
const videoUrl =
|
||||
result.outputUrls.find((url) => /\.(mp4|webm|mov|m4v)(?:[?#].*)?$/i.test(url)) ||
|
||||
result.outputUrls[0];
|
||||
if (!videoUrl) throw new Error("VIP98K 视频任务完成但没有返回视频");
|
||||
return {
|
||||
success: true,
|
||||
videoUrl,
|
||||
metadata: { provider: "vip98k", taskId: result.taskId, temporaryVideoUrl: videoUrl },
|
||||
modelId: String(selected.model.modelId),
|
||||
modelRef: String(selected.model.id),
|
||||
modelDisplayName: String(selected.model.displayName || selected.model.modelId),
|
||||
};
|
||||
}
|
||||
const languageModel = await getLanguageModelById(selected.model.id);
|
||||
if (!languageModel || typeof (languageModel as any).doGenerate !== "function") {
|
||||
throw new Error("所选 VIP98K 视频模型初始化失败");
|
||||
@ -671,7 +725,7 @@ async function executeVip98kVideoGeneration(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeDirectVideoGeneration(
|
||||
async function executeDirectVideoGenerationInternal(
|
||||
params: ExecuteDirectVideoGenerationParams,
|
||||
): Promise<ExecuteDirectVideoGenerationResult> {
|
||||
const prompt = String(params.prompt || "").trim();
|
||||
@ -679,9 +733,9 @@ export async function executeDirectVideoGeneration(
|
||||
throw new Error("缺少视频生成提示词");
|
||||
}
|
||||
|
||||
const preferredParentModelId = await resolvePreferredVideoParentModelId(
|
||||
params.preferredModelRef,
|
||||
);
|
||||
if (params.taskLifecycle?.resume)
|
||||
params = { ...params, preferredModelRef: params.taskLifecycle.resume.modelRef };
|
||||
const preferredParentModelId = await resolvePreferredVideoParentModelId(params.preferredModelRef);
|
||||
validateCanvasVideoInputs(params.mode, params.images);
|
||||
const parentModelId = preferredParentModelId;
|
||||
|
||||
@ -737,8 +791,12 @@ export async function executeDirectVideoGeneration(
|
||||
let videoMetadata: Record<string, any> | null = null;
|
||||
if (model.provider.type === "qianwen-video") {
|
||||
const result = await executeAliyunCanvasVideo({
|
||||
...params, duration: generationParams.duration,
|
||||
modelId: model.model.modelId, apiKey: model.provider.apiKey, baseURL: model.provider.baseUrl,
|
||||
...params,
|
||||
duration: generationParams.duration,
|
||||
modelId: model.model.modelId,
|
||||
apiKey: model.provider.apiKey,
|
||||
baseURL: model.provider.baseUrl,
|
||||
lifecycle: bindVideoTaskLifecycle(params.taskLifecycle, model, "aliyun"),
|
||||
});
|
||||
videoMetadata = { ...result.metadata, temporaryVideoUrl: result.url };
|
||||
} else if (isAgnesVideoModel(model)) {
|
||||
@ -746,6 +804,7 @@ export async function executeDirectVideoGeneration(
|
||||
apiKey: model.provider.apiKey,
|
||||
baseUrl: model.provider.baseUrl,
|
||||
options,
|
||||
lifecycle: bindVideoTaskLifecycle(params.taskLifecycle, model, "agnes"),
|
||||
});
|
||||
videoMetadata = {
|
||||
...(agnesResult.metadata || {}),
|
||||
@ -758,13 +817,19 @@ export async function executeDirectVideoGeneration(
|
||||
modelId: model.model.modelId,
|
||||
modelConfig: model.model.imageGenConfig || {},
|
||||
options,
|
||||
lifecycle: bindVideoTaskLifecycle(params.taskLifecycle, model, "atlascloud"),
|
||||
});
|
||||
videoMetadata = {
|
||||
...(atlasResult.metadata || {}),
|
||||
temporaryVideoUrl: atlasResult.url,
|
||||
};
|
||||
} else {
|
||||
for await (const chunk of doGenerateStream(model.provider.apiKey, model.provider.baseUrl, options)) {
|
||||
for await (const chunk of doGenerateStream(
|
||||
model.provider.apiKey,
|
||||
model.provider.baseUrl,
|
||||
options,
|
||||
bindVideoTaskLifecycle(params.taskLifecycle, model, "volcano"),
|
||||
)) {
|
||||
await params.onProgress?.(chunk);
|
||||
const metadataMatch = chunk.match(/<!-- VIDEO_METADATA:(.*?) -->/);
|
||||
if (metadataMatch) {
|
||||
@ -787,6 +852,42 @@ export async function executeDirectVideoGeneration(
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeDirectVideoGeneration(
|
||||
params: ExecuteDirectVideoGenerationParams,
|
||||
): Promise<ExecuteDirectVideoGenerationResult> {
|
||||
let accepted = Boolean(params.taskLifecycle?.resume);
|
||||
const lifecycle = params.taskLifecycle;
|
||||
try {
|
||||
return await executeDirectVideoGenerationInternal({
|
||||
...params,
|
||||
taskLifecycle: lifecycle
|
||||
? {
|
||||
...lifecycle,
|
||||
save: async (checkpoint) => {
|
||||
accepted = true;
|
||||
await lifecycle.save(checkpoint);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const unknownSubmission =
|
||||
lifecycle &&
|
||||
/fetch failed|network|socket|connection|timeout|timed out|abort|超时|未返回.*任务\s*ID|neither a task ID/i.test(
|
||||
message,
|
||||
);
|
||||
if ((accepted || unknownSubmission) && !(error instanceof ProviderTaskFailedError)) {
|
||||
throw new ProviderTaskUncertainError(
|
||||
accepted
|
||||
? message
|
||||
: "视频提交连接中断,尚未取得任务编号,无法确认是否已受理;请先核对服务商记录,系统不会自动重新提交",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频生成工具
|
||||
*/
|
||||
|
||||
115
lib/video-canvas/director-extensions.ts
Normal file
115
lib/video-canvas/director-extensions.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { defaultDirectorMotion, type DirectorPose, type DirectorMotion } from "./director-motion";
|
||||
import type {
|
||||
DirectorScene,
|
||||
DirectorCamera,
|
||||
DirectorVector,
|
||||
DirectorObject,
|
||||
} from "./director-scene";
|
||||
export function commitDirectorScene(previous: DirectorScene, next: DirectorScene): DirectorScene {
|
||||
const motionTracks = {
|
||||
...previous.motionTracks,
|
||||
...next.motionTracks,
|
||||
[previous.activeCameraId]: previous.motion,
|
||||
};
|
||||
const motion =
|
||||
previous.activeCameraId === next.activeCameraId
|
||||
? next.motion
|
||||
: motionTracks[next.activeCameraId] || defaultDirectorMotion();
|
||||
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 };
|
||||
}
|
||||
export function resolveDirectorCamera(scene: DirectorScene, pose?: DirectorPose): DirectorCamera {
|
||||
const camera = scene.cameras.find((c) => c.id === scene.activeCameraId) || scene.cameras[0];
|
||||
const subject = scene.objects.find((o) => o.id === camera.followObjectId);
|
||||
const result = { ...camera, ...pose };
|
||||
if (subject) {
|
||||
const target: DirectorVector = [
|
||||
subject.position[0],
|
||||
subject.position[1] + subject.scale[1],
|
||||
subject.position[2],
|
||||
];
|
||||
if (Math.hypot(...target.map((v, i) => v - result.position[i])) > 0.001) result.target = target;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export function cameraAngles(camera: DirectorPose) {
|
||||
const [x, y, z] = camera.target.map((v, i) => v - camera.position[i]);
|
||||
return {
|
||||
yaw: (Math.atan2(x, -z) * 180) / Math.PI,
|
||||
pitch: (Math.atan2(y, Math.hypot(x, z)) * 180) / Math.PI,
|
||||
};
|
||||
}
|
||||
export function rotateDirectorCamera(
|
||||
camera: DirectorPose,
|
||||
yaw: number,
|
||||
pitch: number,
|
||||
): DirectorVector {
|
||||
const distance = Math.max(
|
||||
0.01,
|
||||
Math.hypot(...camera.target.map((v, i) => v - camera.position[i])),
|
||||
);
|
||||
const y = (yaw * Math.PI) / 180,
|
||||
p = (Math.max(-89, Math.min(89, pitch)) * Math.PI) / 180;
|
||||
return [
|
||||
camera.position[0] + distance * Math.sin(y) * Math.cos(p),
|
||||
camera.position[1] + distance * Math.sin(p),
|
||||
camera.position[2] - distance * Math.cos(y) * Math.cos(p),
|
||||
];
|
||||
}
|
||||
export const directorBodies = {
|
||||
标准: [1, 1, 1],
|
||||
高挑: [0.85, 1.15, 1],
|
||||
矮壮: [1.2, 0.85, 1.1],
|
||||
纤细: [0.7, 1, 0.95],
|
||||
健壮: [1.35, 1.08, 1],
|
||||
宽厚: [1.5, 1, 1.05],
|
||||
幼童: [0.7, 0.6, 1.4],
|
||||
大头: [1, 0.85, 1.5],
|
||||
} as const;
|
||||
export function createDirectorCrowd(
|
||||
object: DirectorObject,
|
||||
existingCount: number,
|
||||
id: () => string,
|
||||
) {
|
||||
if (existingCount + 8 > 200) throw Error("场景最多支持 200 个对象");
|
||||
return Array.from({ length: 8 }, (_, index) => {
|
||||
const cell = index + 1;
|
||||
return {
|
||||
...object,
|
||||
id: id(),
|
||||
name: `${object.name} 阵列 ${cell}`,
|
||||
position: [
|
||||
object.position[0] + (cell % 3) * 1.5,
|
||||
object.position[1],
|
||||
object.position[2] + Math.floor(cell / 3) * 1.5,
|
||||
] as DirectorVector,
|
||||
};
|
||||
});
|
||||
}
|
||||
export function transformDirectorMotion(
|
||||
motion: DirectorMotion,
|
||||
translation: DirectorVector,
|
||||
yawDegrees: number,
|
||||
): DirectorMotion {
|
||||
const origin = motion.keyframes[0]?.target || [0, 0, 0],
|
||||
angle = (yawDegrees * Math.PI) / 180;
|
||||
const transform = (v: DirectorVector): DirectorVector => {
|
||||
const x = v[0] - origin[0],
|
||||
z = v[2] - origin[2];
|
||||
return [
|
||||
origin[0] + x * Math.cos(angle) - z * Math.sin(angle) + translation[0],
|
||||
v[1] + translation[1],
|
||||
origin[2] + x * Math.sin(angle) + z * Math.cos(angle) + translation[2],
|
||||
];
|
||||
};
|
||||
return {
|
||||
...motion,
|
||||
keyframes: motion.keyframes.map((k) => ({
|
||||
...k,
|
||||
position: transform(k.position),
|
||||
target: transform(k.target),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@ -12,14 +12,15 @@ export async function recordDirectorVideo({
|
||||
signal: AbortSignal;
|
||||
onProgress?: (time: number) => void;
|
||||
}): Promise<Blob> {
|
||||
const mimeType = ["video/mp4"].find(
|
||||
(t) =>
|
||||
typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(t),
|
||||
);
|
||||
const mimeType = [
|
||||
"video/mp4",
|
||||
"video/webm;codecs=vp9",
|
||||
"video/webm;codecs=vp8",
|
||||
"video/webm",
|
||||
].find((t) => typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(t));
|
||||
if (!mimeType || !canvas.captureStream)
|
||||
throw Error("当前浏览器不支持 MP4 轨迹导出,请使用最新版 Chrome 或 Edge");
|
||||
if (signal.aborted || document.hidden)
|
||||
throw Error("请保持当前页面可见后导出");
|
||||
throw Error("当前浏览器不支持视频录制,请更换支持视频录制的浏览器");
|
||||
if (signal.aborted || document.hidden) throw Error("请保持当前页面可见后导出");
|
||||
const stream = canvas.captureStream(30);
|
||||
try {
|
||||
return await new Promise<Blob>((resolve, reject) => {
|
||||
@ -48,8 +49,7 @@ export async function recordDirectorVideo({
|
||||
};
|
||||
const cancel = () => stop(Error("已取消轨迹视频导出"));
|
||||
const visibility = () => {
|
||||
if (document.hidden)
|
||||
stop(Error("导出时切换了页面,请回到当前页面重新导出"));
|
||||
if (document.hidden) stop(Error("导出时切换了页面,请回到当前页面重新导出"));
|
||||
};
|
||||
const timeout = setTimeout(
|
||||
() => stop(Error("轨迹导出超时,请重试")),
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { directorBodies } from "./director-extensions";
|
||||
import { directorPoses, type DirectorPoseName } from "./director-poses";
|
||||
import {
|
||||
defaultDirectorMotion,
|
||||
@ -15,11 +16,13 @@ export type DirectorObject = {
|
||||
visible?: boolean;
|
||||
locked?: boolean;
|
||||
pose?: DirectorPoseName;
|
||||
body?: keyof typeof directorBodies;
|
||||
position: DirectorVector;
|
||||
rotation: DirectorVector;
|
||||
scale: DirectorVector;
|
||||
};
|
||||
export type DirectorCamera = {
|
||||
followObjectId?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
position: DirectorVector;
|
||||
@ -41,6 +44,9 @@ export type DirectorScene = {
|
||||
ratio: string;
|
||||
snapshots: DirectorSnapshot[];
|
||||
motion: DirectorMotion;
|
||||
motionTracks?: Record<string, DirectorMotion>;
|
||||
backgroundUrl?: string;
|
||||
backgroundHistory?: string[];
|
||||
};
|
||||
export const directorRatios = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"];
|
||||
export function directorImageSize(ratio: string) {
|
||||
@ -74,6 +80,7 @@ export function defaultDirectorScene(): DirectorScene {
|
||||
ratio: "16:9",
|
||||
snapshots: [],
|
||||
motion: defaultDirectorMotion(),
|
||||
motionTracks: {},
|
||||
};
|
||||
}
|
||||
export function readDirectorScene(value: unknown): DirectorScene {
|
||||
@ -135,14 +142,38 @@ export function readDirectorScene(value: unknown): DirectorScene {
|
||||
if (o.visible !== undefined) clean.visible = o.visible !== false;
|
||||
if (o.locked !== undefined) clean.locked = o.locked === true;
|
||||
if (o.pose !== undefined && !Object.hasOwn(directorPoses, o.pose)) delete clean.pose;
|
||||
if (o.body !== undefined && !Object.hasOwn(directorBodies, o.body)) delete clean.body;
|
||||
return clean;
|
||||
}),
|
||||
cameras: validCameras.map((c) => {
|
||||
const clean = { ...c };
|
||||
if (!objects.some((o) => o.id === c.followObjectId)) delete clean.followObjectId;
|
||||
return clean;
|
||||
}),
|
||||
cameras: validCameras,
|
||||
activeCameraId: validCameras.some((c) => c.id === v.activeCameraId)
|
||||
? v.activeCameraId
|
||||
: validCameras[0].id,
|
||||
ratio: directorRatios.includes(v.ratio) ? v.ratio : "16:9",
|
||||
snapshots,
|
||||
motion: readDirectorMotion(v.motion),
|
||||
...(typeof v.backgroundUrl === "string" && /^https?:\/\//i.test(v.backgroundUrl)
|
||||
? { backgroundUrl: v.backgroundUrl }
|
||||
: {}),
|
||||
...(Array.isArray(v.backgroundHistory)
|
||||
? {
|
||||
backgroundHistory: [
|
||||
...new Set(
|
||||
v.backgroundHistory.filter(
|
||||
(url) => typeof url === "string" && /^https?:\/\//i.test(url),
|
||||
),
|
||||
),
|
||||
].slice(-20),
|
||||
}
|
||||
: {}),
|
||||
motionTracks: Object.fromEntries(
|
||||
Object.entries(v.motionTracks || {})
|
||||
.filter(([id]) => validCameras.some((c) => c.id === id))
|
||||
.map(([id, motion]) => [id, readDirectorMotion(motion)]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
111
lib/video-canvas/merge-snapshot.ts
Normal file
111
lib/video-canvas/merge-snapshot.ts
Normal file
@ -0,0 +1,111 @@
|
||||
export type CanvasConflictChoice = "local" | "remote";
|
||||
const absent = Symbol("absent");
|
||||
const record = (v: any) => v !== absent && v !== null && typeof v === "object" && !Array.isArray(v);
|
||||
const same = (a: any, b: any): boolean => {
|
||||
if (a === b) return true;
|
||||
if (record(a) && record(b)) {
|
||||
const keys = Object.keys(a);
|
||||
return (
|
||||
keys.length === Object.keys(b).length &&
|
||||
keys.every((k) => Object.hasOwn(b, k) && same(a[k], b[k]))
|
||||
);
|
||||
}
|
||||
return (
|
||||
Array.isArray(a) &&
|
||||
Array.isArray(b) &&
|
||||
a.length === b.length &&
|
||||
a.every((v, i) => same(v, b[i]))
|
||||
);
|
||||
};
|
||||
/** Three-way merge. Never resolve edits to the same field implicitly. */
|
||||
export function mergeCanvasSnapshots(
|
||||
baseText: string,
|
||||
localText: string,
|
||||
remoteText: string,
|
||||
choice?: CanvasConflictChoice,
|
||||
) {
|
||||
const conflicts: string[] = [];
|
||||
function merge(base: any, local: any, remote: any, path: string): any {
|
||||
// View and selection belong to this tab, not shared document content.
|
||||
if (["canvasData.viewport", "canvasData.selectedNodeIds", "canvasData.tool"].includes(path))
|
||||
return local;
|
||||
if (same(local, remote) || same(base, remote)) return local;
|
||||
if (same(base, local)) return remote;
|
||||
if (record(base) && record(local) && record(remote)) {
|
||||
const output: Record<string, any> = {};
|
||||
for (const key of new Set([
|
||||
...Object.keys(base),
|
||||
...Object.keys(local),
|
||||
...Object.keys(remote),
|
||||
])) {
|
||||
if (["__proto__", "prototype", "constructor"].includes(key)) continue;
|
||||
const result = merge(
|
||||
Object.hasOwn(base, key) ? base[key] : absent,
|
||||
Object.hasOwn(local, key) ? local[key] : absent,
|
||||
Object.hasOwn(remote, key) ? remote[key] : absent,
|
||||
path ? `${path}.${key}` : key,
|
||||
);
|
||||
if (result !== absent) output[key] = result;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
const keyed = (v: any) =>
|
||||
Array.isArray(v) &&
|
||||
v.every((item) => record(item) && typeof item.id === "string") &&
|
||||
new Set(v.map((item) => item.id)).size === v.length;
|
||||
if (keyed(base) && keyed(local) && keyed(remote)) {
|
||||
const b = new Map(base.map((n: any) => [n.id, n])),
|
||||
l = new Map(local.map((n: any) => [n.id, n])),
|
||||
r = new Map(remote.map((n: any) => [n.id, n]));
|
||||
const order = (items: any[]) =>
|
||||
items.filter((n) => b.has(n.id) && l.has(n.id) && r.has(n.id)).map((n) => n.id);
|
||||
const bo = order(base),
|
||||
lo = order(local),
|
||||
ro = order(remote);
|
||||
let primary = local;
|
||||
if (same(lo, bo)) primary = remote;
|
||||
else if (!same(ro, bo) && !same(lo, ro)) {
|
||||
conflicts.push(`${path}.顺序`);
|
||||
primary = choice === "remote" ? remote : local;
|
||||
}
|
||||
const ids = new Set([
|
||||
...primary.map((n: any) => n.id),
|
||||
...remote.map((n: any) => n.id),
|
||||
...local.map((n: any) => n.id),
|
||||
...base.map((n: any) => n.id),
|
||||
]);
|
||||
return [...ids]
|
||||
.map((id) =>
|
||||
merge(
|
||||
b.has(id) ? b.get(id) : absent,
|
||||
l.has(id) ? l.get(id) : absent,
|
||||
r.has(id) ? r.get(id) : absent,
|
||||
`${path}[${id}]`,
|
||||
),
|
||||
)
|
||||
.filter((v) => v !== absent);
|
||||
}
|
||||
conflicts.push(path || "画布");
|
||||
return choice === "remote" ? remote : local;
|
||||
}
|
||||
const parse = (text: string) => {
|
||||
const value = JSON.parse(text);
|
||||
for (const node of value.canvasData?.nodes || []) {
|
||||
delete node.createdAt;
|
||||
delete node.updatedAt;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const local = parse(localText);
|
||||
const snapshot = merge(parse(baseText), local, parse(remoteText), "");
|
||||
if (snapshot.canvasData && local.canvasData)
|
||||
for (const key of ["viewport", "selectedNodeIds", "tool"])
|
||||
if (Object.hasOwn(local.canvasData, key)) snapshot.canvasData[key] = local.canvasData[key];
|
||||
if (Array.isArray(snapshot.canvasData?.nodes) && Array.isArray(snapshot.canvasData?.edges)) {
|
||||
const ids = new Set(snapshot.canvasData.nodes.map((n: any) => n.id));
|
||||
snapshot.canvasData.edges = snapshot.canvasData.edges.filter(
|
||||
(e: any) => ids.has(e.source) && ids.has(e.target),
|
||||
);
|
||||
}
|
||||
return { snapshot: JSON.stringify(snapshot), conflicts };
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import type { CanvasConflictChoice } from "./merge-snapshot";
|
||||
export type VideoCanvasSaveStatus = "saved" | "pending" | "saving" | "error";
|
||||
|
||||
/** Keep edits made during a request; never retry a stale revision as an overwrite. */
|
||||
@ -6,6 +7,7 @@ export class VideoCanvasSaveQueue {
|
||||
private saved: string;
|
||||
private running: Promise<void> | null = null;
|
||||
private blocked = false;
|
||||
private conflict: CanvasRevisionConflict | null = null;
|
||||
private status: VideoCanvasSaveStatus = "saved";
|
||||
|
||||
constructor(
|
||||
@ -13,14 +15,53 @@ export class VideoCanvasSaveQueue {
|
||||
private revision: string,
|
||||
private readonly write: (snapshot: string, revision: string) => Promise<string>,
|
||||
private readonly changed: (status: VideoCanvasSaveStatus, message?: string) => void,
|
||||
private readonly reconciliation?: {
|
||||
merge: (
|
||||
base: string,
|
||||
local: string,
|
||||
remote: string,
|
||||
choice?: CanvasConflictChoice,
|
||||
) => { snapshot: string; conflicts: string[] };
|
||||
apply: (snapshot: string) => void;
|
||||
conflict: (fields: string[]) => void;
|
||||
},
|
||||
) {
|
||||
this.latest = baseline;
|
||||
this.saved = baseline;
|
||||
}
|
||||
|
||||
get failed() { return this.status === "error"; }
|
||||
get backup() {
|
||||
return { base: this.saved, snapshot: this.latest, revision: this.revision };
|
||||
}
|
||||
restoreBackup(base: string, snapshot: string) {
|
||||
if (!this.reconciliation || snapshot === base) return;
|
||||
const remote = new CanvasRevisionConflict(
|
||||
"检测到未保存草稿与服务器有冲突,请选择保留哪一处修改",
|
||||
this.saved,
|
||||
this.revision,
|
||||
);
|
||||
const merged = this.reconciliation.merge(base, snapshot, this.saved);
|
||||
if (merged.conflicts.length) {
|
||||
this.saved = base;
|
||||
this.latest = snapshot;
|
||||
this.conflict = remote;
|
||||
this.blocked = true;
|
||||
this.reconciliation.apply(snapshot);
|
||||
this.reconciliation.conflict(merged.conflicts);
|
||||
this.setStatus("error", remote.message);
|
||||
} else {
|
||||
this.rebase(merged.snapshot, remote);
|
||||
this.setStatus(this.latest === this.saved ? "saved" : "pending");
|
||||
}
|
||||
}
|
||||
|
||||
get dirty() { return this.latest !== this.saved || this.running !== null; }
|
||||
get failed() {
|
||||
return this.status === "error";
|
||||
}
|
||||
|
||||
get dirty() {
|
||||
return this.latest !== this.saved || this.running !== null;
|
||||
}
|
||||
|
||||
update(snapshot: string) {
|
||||
this.latest = snapshot;
|
||||
@ -37,17 +78,64 @@ export class VideoCanvasSaveQueue {
|
||||
flush(): Promise<void> {
|
||||
if (this.running) return this.running;
|
||||
if (this.blocked || !this.dirty) return Promise.resolve();
|
||||
this.running = this.drain().finally(() => { this.running = null; });
|
||||
this.running = this.drain().finally(() => {
|
||||
this.running = null;
|
||||
});
|
||||
return this.running;
|
||||
}
|
||||
|
||||
private rebase(snapshot: string, conflict: CanvasRevisionConflict) {
|
||||
this.saved = conflict.remoteSnapshot!;
|
||||
this.revision = conflict.remoteRevision!;
|
||||
this.latest = snapshot;
|
||||
this.conflict = null;
|
||||
this.blocked = false;
|
||||
this.reconciliation?.conflict([]);
|
||||
this.reconciliation?.apply(snapshot);
|
||||
}
|
||||
async resolveConflict(choice: CanvasConflictChoice) {
|
||||
await this.running;
|
||||
if (!this.conflict?.remoteSnapshot || !this.reconciliation) return;
|
||||
const merged = this.reconciliation.merge(
|
||||
this.saved,
|
||||
this.latest,
|
||||
this.conflict.remoteSnapshot,
|
||||
choice,
|
||||
);
|
||||
this.rebase(merged.snapshot, this.conflict);
|
||||
await this.flush();
|
||||
}
|
||||
|
||||
private async drain() {
|
||||
this.setStatus("saving");
|
||||
try {
|
||||
let merges = 0;
|
||||
while (this.latest !== this.saved) {
|
||||
const snapshot = this.latest;
|
||||
this.revision = await this.write(snapshot, this.revision);
|
||||
this.saved = snapshot;
|
||||
try {
|
||||
this.revision = await this.write(snapshot, this.revision);
|
||||
this.saved = snapshot;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof CanvasRevisionConflict &&
|
||||
error.remoteSnapshot &&
|
||||
error.remoteRevision &&
|
||||
this.reconciliation
|
||||
) {
|
||||
this.conflict = error;
|
||||
const merged = this.reconciliation.merge(this.saved, this.latest, error.remoteSnapshot);
|
||||
if (merged.conflicts.length) {
|
||||
this.reconciliation.conflict(merged.conflicts);
|
||||
throw error;
|
||||
}
|
||||
this.rebase(merged.snapshot, error);
|
||||
if (merges++ >= 3) {
|
||||
throw new Error("其他页面正在频繁保存,当前修改已保留,请稍后重试保存");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
this.setStatus("saved");
|
||||
} catch (error) {
|
||||
@ -57,4 +145,12 @@ export class VideoCanvasSaveQueue {
|
||||
}
|
||||
}
|
||||
|
||||
export class CanvasRevisionConflict extends Error {}
|
||||
export class CanvasRevisionConflict extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public remoteSnapshot?: string,
|
||||
public remoteRevision?: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
@ -173,7 +173,7 @@ export function reconcileCanvasTasks<
|
||||
(task.status === "cancelled" ? "已取消排队,未提交供应商" : "任务失败,请查看任务状态"),
|
||||
});
|
||||
if (task.status === "interrupted" && hasRecoverableCanvasTask(task))
|
||||
nextDraft.generationError = "高清任务查询中断,稍后会自动查询原任务,请勿重复生成。";
|
||||
nextDraft.generationError = "任务查询中断,稍后会自动查询原任务,请勿重复生成。";
|
||||
subtitle =
|
||||
task.status === "cancelled"
|
||||
? "已取消"
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
// Resume only server-recorded enhancement jobs. A lost request without an upstream ID is never resubmitted.
|
||||
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 = {
|
||||
status: string;
|
||||
createdAt: string;
|
||||
@ -24,10 +25,24 @@ export function getEnhanceCheckpoint(task: Pick<RecoveryTask, "request" | "resul
|
||||
sourceImageUrl: String(upstream.sourceImageUrl),
|
||||
};
|
||||
}
|
||||
export function getVideoCheckpoint(task: Pick<RecoveryTask, "request" | "result">) {
|
||||
if (task.request.kind !== "video") return null;
|
||||
const parsed = videoCheckpointSchema.safeParse(task.result?.upstreamTask);
|
||||
if (!parsed.success) return null;
|
||||
const preferred = task.request.params.preferredModelRef;
|
||||
if (
|
||||
preferred &&
|
||||
preferred !== "auto" &&
|
||||
preferred !== parsed.data.modelRef &&
|
||||
preferred !== parsed.data.modelId
|
||||
)
|
||||
return null;
|
||||
return parsed.data;
|
||||
}
|
||||
export function hasRecoverableCanvasTask(task: RecoveryTask, now = Date.now()) {
|
||||
const created = Date.parse(task.createdAt);
|
||||
return (
|
||||
Boolean(getEnhanceCheckpoint(task)) &&
|
||||
Boolean(getEnhanceCheckpoint(task) || getVideoCheckpoint(task)) &&
|
||||
Number.isFinite(created) &&
|
||||
now >= created &&
|
||||
now - created <= 86400000 &&
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { canResumeCanvasTask, hasRecoverableCanvasTask } from "./task-recovery";
|
||||
import "server-only";
|
||||
import { and, asc, desc, eq, lt, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, gte, lt, sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { canvasCapabilityTask as table, canvasProject } from "@/lib/db/schema";
|
||||
import { type CanvasTaskRequest, type CanvasGenerationTask } from "./task-contract";
|
||||
@ -161,9 +161,15 @@ export async function claimCanvasGenerationTask(userId: string, projectId: strin
|
||||
const interrupted = await tx
|
||||
.select()
|
||||
.from(table)
|
||||
.where(and(scope(userId, projectId), eq(table.status, "interrupted")))
|
||||
.orderBy(asc(table.updatedAt))
|
||||
.limit(240);
|
||||
.where(
|
||||
and(
|
||||
scope(userId, projectId),
|
||||
eq(table.status, "interrupted"),
|
||||
gte(table.createdAt, new Date(Date.now() - 86400000)),
|
||||
lt(table.attempts, 20),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(table.updatedAt));
|
||||
next = interrupted.find((row) => canResumeCanvasTask(serialize(row)))!;
|
||||
}
|
||||
if (!next) return null;
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { getEnhanceCheckpoint } from "./task-recovery";
|
||||
import { ProviderTaskUncertainError } from "./video-task-lifecycle";
|
||||
import { getEnhanceCheckpoint, getVideoCheckpoint } from "./task-recovery";
|
||||
import { requireStoryPromptResult } from "./story-task-attachment";
|
||||
import {
|
||||
executeImageOperation,
|
||||
@ -35,7 +36,9 @@ async function execute(task: CanvasGenerationTask, userId: string) {
|
||||
};
|
||||
await assertLease();
|
||||
const resume = getEnhanceCheckpoint(task);
|
||||
if ((task.attempts || 1) > 1 && !resume) throw Error("缺少上游任务编号,禁止重新提交");
|
||||
const videoResume = getVideoCheckpoint(task);
|
||||
if ((task.attempts || 1) > 1 && !resume && !videoResume)
|
||||
throw Error("缺少上游任务编号,禁止重新提交");
|
||||
const params = request.params;
|
||||
if (request.kind === "image-operation")
|
||||
return executeImageOperation(
|
||||
@ -55,15 +58,23 @@ async function execute(task: CanvasGenerationTask, userId: string) {
|
||||
},
|
||||
);
|
||||
if (request.kind === "video") {
|
||||
const checked = await validateVideoReferences(params.images, (url) =>
|
||||
probeRemoteUrl(url, { timeoutMs: 8000 }),
|
||||
);
|
||||
const checked = videoResume
|
||||
? { images: params.images as string[] | undefined, error: undefined }
|
||||
: await validateVideoReferences(params.images, (url) =>
|
||||
probeRemoteUrl(url, { timeoutMs: 8000 }),
|
||||
);
|
||||
if (checked.error) throw Error(checked.error);
|
||||
const result = await executeDirectVideoGeneration({
|
||||
...params,
|
||||
prompt: String(params.prompt || ""),
|
||||
mode: params.mode as any,
|
||||
images: checked.images,
|
||||
taskLifecycle: {
|
||||
resume: videoResume,
|
||||
assertActive: assertLease,
|
||||
save: (checkpoint) =>
|
||||
checkpointCanvasGenerationTask(task.id, { upstreamTask: checkpoint }, task.attempts),
|
||||
},
|
||||
});
|
||||
if (!result.success || !result.videoUrl) throw Error("视频生成未返回结果");
|
||||
return {
|
||||
@ -114,7 +125,10 @@ export async function runCanvasGenerationQueue(userId: string, projectId: string
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof CanvasTaskLeaseLostError) return;
|
||||
if (error instanceof ImageOperationUncertainError) {
|
||||
if (
|
||||
error instanceof ImageOperationUncertainError ||
|
||||
error instanceof ProviderTaskUncertainError
|
||||
) {
|
||||
await interruptCanvasGenerationTask(task.id, error.message, task.attempts);
|
||||
continue;
|
||||
}
|
||||
|
||||
60
lib/video-canvas/video-task-lifecycle.ts
Normal file
60
lib/video-canvas/video-task-lifecycle.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const videoCheckpointSchema = z.object({
|
||||
operation: z.literal("video"),
|
||||
provider: z.enum(["aliyun", "vip98k", "volcano", "atlascloud", "agnes"]),
|
||||
taskId: z.string().trim().min(1).max(500),
|
||||
modelRef: z.string().min(1),
|
||||
modelId: z.string().min(1),
|
||||
providerId: z.string().min(1),
|
||||
baseUrl: z.string(),
|
||||
resultPath: z.string().optional(),
|
||||
videoId: z.string().optional(),
|
||||
});
|
||||
export type VideoTaskCheckpoint = z.infer<typeof videoCheckpointSchema>;
|
||||
export type ProviderTaskReference = Pick<VideoTaskCheckpoint, "taskId" | "resultPath" | "videoId">;
|
||||
export type ProviderTaskLifecycle = {
|
||||
resume?: ProviderTaskReference;
|
||||
accepted: (reference: ProviderTaskReference) => Promise<void>;
|
||||
assertActive: () => Promise<void>;
|
||||
};
|
||||
export type VideoTaskLifecycle = {
|
||||
resume?: VideoTaskCheckpoint | null;
|
||||
save: (checkpoint: VideoTaskCheckpoint) => Promise<void>;
|
||||
assertActive: () => Promise<void>;
|
||||
};
|
||||
export class ProviderTaskFailedError extends Error {}
|
||||
export class ProviderTaskUncertainError extends Error {}
|
||||
export function bindVideoTaskLifecycle(
|
||||
lifecycle: VideoTaskLifecycle | undefined,
|
||||
selected: any,
|
||||
provider: VideoTaskCheckpoint["provider"],
|
||||
): ProviderTaskLifecycle | undefined {
|
||||
if (!lifecycle) return undefined;
|
||||
const identity = {
|
||||
operation: "video" as const,
|
||||
provider,
|
||||
modelRef: String(selected.model.id),
|
||||
modelId: String(selected.model.modelId),
|
||||
providerId: String(selected.provider.id),
|
||||
baseUrl: String(selected.provider.baseUrl || "").replace(/\/+$/, ""),
|
||||
};
|
||||
const resume = lifecycle.resume;
|
||||
if (
|
||||
resume &&
|
||||
Object.entries(identity).some(
|
||||
([key, value]) => resume[key as keyof VideoTaskCheckpoint] !== value,
|
||||
)
|
||||
)
|
||||
throw new ProviderTaskUncertainError(
|
||||
"原视频任务的模型或服务线路已变更,已停止自动查询,请恢复原配置后重试",
|
||||
);
|
||||
return {
|
||||
resume: resume || undefined,
|
||||
assertActive: lifecycle.assertActive,
|
||||
accepted: async (reference) => {
|
||||
await lifecycle.assertActive();
|
||||
await lifecycle.save({ ...identity, ...reference });
|
||||
},
|
||||
};
|
||||
}
|
||||
243
scripts/qa/video-canvas-provider-recovery.cjs
Normal file
243
scripts/qa/video-canvas-provider-recovery.cjs
Normal file
@ -0,0 +1,243 @@
|
||||
// Exercises actual provider adapters and selection with synthetic credentials and an in-process fetch stub.
|
||||
// No real network request or paid model execution is permitted.
|
||||
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",
|
||||
),
|
||||
);
|
||||
const out = path.resolve(".qa/video-provider-recovery");
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const stubs = {
|
||||
"server-only": "",
|
||||
"@/lib/db/queries": `export const getEnabledAiModelsWithProvider=async()=>globalThis.qaModels;export const getSubModelsByParentModelId=async()=>[];export const getSystemSetting=async()=>null;export const getDefaultStorageConfig=async()=>{throw Error('unexpected storage');};`,
|
||||
"@/lib/ai/dynamic-provider": `export const getLanguageModelById=async()=>{throw Error('durable video must use task adapter');};`,
|
||||
"@/lib/storage/service": `export const uploadFile=async()=>{throw Error('unexpected upload');};export const buildS3PublicUrl=()=>'';export const createS3Client=()=>{throw Error('unexpected S3');};export const toDbS3UploadConfig=x=>x;`,
|
||||
"@/lib/storage/image-archive": `export const createDeferredUploadedImage=async()=>null;`,
|
||||
};
|
||||
await esbuild.build({
|
||||
entryPoints: ["lib/ai/tools/generate-video.ts"],
|
||||
outfile: path.join(out, "video.cjs"),
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "cjs",
|
||||
packages: "external",
|
||||
alias: { "@": process.cwd() },
|
||||
plugins: [
|
||||
{
|
||||
name: "qa-only",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /.*/ }, (a) =>
|
||||
stubs[a.path] !== undefined ? { path: a.path, namespace: "qa" } : undefined,
|
||||
);
|
||||
build.onLoad({ filter: /.*/, namespace: "qa" }, (a) => ({
|
||||
contents: stubs[a.path],
|
||||
loader: "js",
|
||||
}));
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const { executeDirectVideoGeneration } = require(path.join(out, "video.cjs"));
|
||||
const originalFetch = globalThis.fetch,
|
||||
originalTimeout = globalThis.setTimeout;
|
||||
// Only provider sleep timers are shortened. AbortSignal's native timeout is unchanged.
|
||||
globalThis.setTimeout = (fn, ms, ...args) => originalTimeout(fn, Math.min(ms, 1), ...args);
|
||||
try {
|
||||
for (const [kind, type, modelId] of [
|
||||
["aliyun", "qianwen-video", "wan2.6-i2v-flash"],
|
||||
["vip98k", "vip98k", "veo-qa"],
|
||||
["volcano", "volcano-video", "doubao-seedance-1-0-pro-250528"],
|
||||
["atlascloud", "atlascloud", "qa-video"],
|
||||
["agnes", "custom", "agnes-video-qa"],
|
||||
]) {
|
||||
const capabilities = {
|
||||
kind: "video",
|
||||
mediaType: "video",
|
||||
provider: kind === "agnes" ? "agnes-ai" : kind,
|
||||
supportedModes: ["text-to-video", "first-frame"],
|
||||
supportedResolutions: ["720p"],
|
||||
supportedRatios: ["16:9"],
|
||||
durationRange: { min: 2, max: 15 },
|
||||
durationOptions: [5],
|
||||
maxReferenceImages: 4,
|
||||
};
|
||||
const fixture = {
|
||||
model: {
|
||||
id: "model-" + kind,
|
||||
modelId,
|
||||
displayName: kind,
|
||||
isEnabled: true,
|
||||
capabilities,
|
||||
imageGenConfig:
|
||||
kind === "atlascloud"
|
||||
? {
|
||||
atlasCloud: {
|
||||
submitEndpoint: "/api/v1/model/generateVideo",
|
||||
pollEndpoint: "/api/v1/model/prediction/{id}",
|
||||
propertyNames: ["model", "prompt", "duration", "aspect_ratio", "resolution"],
|
||||
requiredFields: ["model", "prompt"],
|
||||
maxReferenceImages: 0,
|
||||
defaultValues: {},
|
||||
},
|
||||
}
|
||||
: {},
|
||||
},
|
||||
provider: {
|
||||
id: "provider-" + kind,
|
||||
type,
|
||||
apiKey: "synthetic-qa-key",
|
||||
baseUrl: "https://provider.invalid",
|
||||
},
|
||||
};
|
||||
globalThis.qaModels = [fixture];
|
||||
let checkpoint = null,
|
||||
calls = [],
|
||||
failQuery = true;
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
assert.equal(
|
||||
new URL(String(url)).hostname,
|
||||
"provider.invalid",
|
||||
"unexpected network destination",
|
||||
);
|
||||
const method = init.method || "GET";
|
||||
calls.push(method);
|
||||
if (method === "POST") {
|
||||
assert.equal(calls.filter((m) => m === "POST").length, 1, "duplicate submit");
|
||||
if (kind === "aliyun") return Response.json({ output: { task_id: "qa-original" } });
|
||||
if (kind === "vip98k") return Response.json({ task_id: "qa-original" });
|
||||
if (kind === "agnes")
|
||||
return Response.json({ video_id: "qa-original", task_id: "qa-original" });
|
||||
return Response.json({ id: "qa-original" });
|
||||
}
|
||||
assert(checkpoint, "provider GET happened before durable checkpoint");
|
||||
assert(String(url).includes("qa-original"), "query must use saved task");
|
||||
if (failQuery) throw Error("QA simulated connection lost");
|
||||
if (kind === "aliyun")
|
||||
return Response.json({
|
||||
output: { task_status: "SUCCEEDED", video_url: "https://output.invalid/video.mp4" },
|
||||
});
|
||||
if (kind === "volcano")
|
||||
return Response.json({
|
||||
status: "succeeded",
|
||||
content: { video_url: "https://output.invalid/video.mp4" },
|
||||
});
|
||||
if (kind === "agnes")
|
||||
return Response.json({ status: "completed", url: "https://output.invalid/video.mp4" });
|
||||
return Response.json({
|
||||
status: "completed",
|
||||
outputs: ["https://output.invalid/video.mp4"],
|
||||
});
|
||||
};
|
||||
const params = {
|
||||
prompt: "QA only",
|
||||
mode: kind === "aliyun" ? "first-frame" : "text-to-video",
|
||||
images: kind === "aliyun" ? ["https://input.invalid/ref.png"] : [],
|
||||
duration: 5,
|
||||
resolution: "720p",
|
||||
ratio: "16:9",
|
||||
preferredModelRef: fixture.model.id,
|
||||
};
|
||||
await assert.rejects(
|
||||
executeDirectVideoGeneration({
|
||||
...params,
|
||||
taskLifecycle: {
|
||||
save: async (cp) => {
|
||||
checkpoint = cp;
|
||||
},
|
||||
assertActive: async () => {},
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert(checkpoint, kind + " did not save accepted task");
|
||||
assert.equal(checkpoint.provider, kind);
|
||||
assert.equal(checkpoint.modelRef, fixture.model.id);
|
||||
failQuery = false;
|
||||
calls = [];
|
||||
const result = await executeDirectVideoGeneration({
|
||||
...params,
|
||||
taskLifecycle: {
|
||||
resume: checkpoint,
|
||||
save: async () => {
|
||||
throw Error("resume must not save new task");
|
||||
},
|
||||
assertActive: async () => {},
|
||||
},
|
||||
});
|
||||
assert.equal(result.videoUrl, "https://output.invalid/video.mp4");
|
||||
assert(calls.length > 0);
|
||||
assert(
|
||||
calls.every((method) => method === "GET"),
|
||||
kind + " resumed with a POST",
|
||||
);
|
||||
calls = [];
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
assert.equal(new URL(String(url)).hostname, "provider.invalid");
|
||||
calls.push(init.method || "GET");
|
||||
return Response.json(
|
||||
kind === "aliyun"
|
||||
? { output: { task_status: "FAILED", code: "QA_FAILURE" } }
|
||||
: { status: "failed", error: { message: "QA terminal network failure" } },
|
||||
);
|
||||
};
|
||||
await assert.rejects(
|
||||
executeDirectVideoGeneration({
|
||||
...params,
|
||||
taskLifecycle: { resume: checkpoint, save: async () => {}, assertActive: async () => {} },
|
||||
}),
|
||||
(error) => error.constructor.name === "ProviderTaskFailedError",
|
||||
);
|
||||
assert.deepEqual(calls, ["GET"], kind + " retried a terminal failure");
|
||||
fixture.provider.baseUrl = "https://changed.invalid";
|
||||
calls = [];
|
||||
await assert.rejects(
|
||||
executeDirectVideoGeneration({
|
||||
...params,
|
||||
taskLifecycle: { resume: checkpoint, save: async () => {}, assertActive: async () => {} },
|
||||
}),
|
||||
/线路已变更/,
|
||||
);
|
||||
assert.equal(calls.length, 0);
|
||||
fixture.provider.baseUrl = "https://provider.invalid";
|
||||
calls = [];
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
calls.push(init.method || "GET");
|
||||
throw Error("fetch failed: connection reset during submit");
|
||||
};
|
||||
await assert.rejects(
|
||||
executeDirectVideoGeneration({
|
||||
...params,
|
||||
taskLifecycle: {
|
||||
save: async () => {
|
||||
throw Error("no accepted ID");
|
||||
},
|
||||
assertActive: async () => {},
|
||||
},
|
||||
}),
|
||||
/不会自动重新提交/,
|
||||
);
|
||||
assert.equal(
|
||||
calls.filter((m) => m === "POST").length,
|
||||
1,
|
||||
kind + " retried an uncertain POST",
|
||||
);
|
||||
console.log(
|
||||
`PASS ${kind}: checkpoint before GET, interrupted query, GET-only resume, original provider identity enforced`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
globalThis.setTimeout = originalTimeout;
|
||||
}
|
||||
})().then(
|
||||
() => process.exit(0),
|
||||
(error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
},
|
||||
);
|
||||
@ -231,6 +231,65 @@ const fs = require("fs"),
|
||||
2,
|
||||
);
|
||||
|
||||
// Video checkpoints use the same durable lease and original provider identity.
|
||||
const videoRequest = {
|
||||
...request,
|
||||
requestId: crypto.randomUUID(),
|
||||
kind: "video",
|
||||
node: { ...request.node, id: "video-recoverable" },
|
||||
params: { prompt: "QA only", preferredModelRef: "video-model" },
|
||||
};
|
||||
const videoTask = await store.createCanvasGenerationTask(user, videoRequest);
|
||||
const videoLease = await store.claimCanvasGenerationTask(user, project);
|
||||
const videoCheckpoint = {
|
||||
operation: "video",
|
||||
provider: "aliyun",
|
||||
taskId: "original-video",
|
||||
modelRef: "video-model",
|
||||
modelId: "wan-qa",
|
||||
providerId: "provider-qa",
|
||||
baseUrl: "https://provider.invalid",
|
||||
};
|
||||
await store.checkpointCanvasGenerationTask(
|
||||
videoTask.id,
|
||||
{ upstreamTask: videoCheckpoint },
|
||||
videoLease.attempts,
|
||||
);
|
||||
await store.interruptCanvasGenerationTask(
|
||||
videoTask.id,
|
||||
"query disconnected",
|
||||
videoLease.attempts,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await store.createCanvasGenerationTask(user, {
|
||||
...videoRequest,
|
||||
requestId: crypto.randomUUID(),
|
||||
})
|
||||
).id,
|
||||
videoTask.id,
|
||||
);
|
||||
await client.query(
|
||||
`UPDATE "CanvasCapabilityTask" SET "updatedAt"=(now() at time zone 'UTC')-interval '6 minutes' WHERE id=$1`,
|
||||
[videoTask.id],
|
||||
);
|
||||
const videoReclaim = (
|
||||
await Promise.all(
|
||||
Array.from({ length: 10 }, () => store.claimCanvasGenerationTask(user, project)),
|
||||
)
|
||||
).filter(Boolean);
|
||||
assert.equal(videoReclaim.length, 1);
|
||||
assert.deepEqual(videoReclaim[0].result.upstreamTask, videoCheckpoint);
|
||||
await store.finishCanvasGenerationTask(
|
||||
videoTask.id,
|
||||
{ videoUrl: "https://output.invalid/video.mp4" },
|
||||
undefined,
|
||||
videoReclaim[0].attempts,
|
||||
);
|
||||
console.log(
|
||||
"PASS: video checkpoint recovery, ten concurrent reclaimers, original upstream identity and duplicate prevention",
|
||||
);
|
||||
|
||||
// A separate worker process exits after its first GET. Restart must read the persisted ID.
|
||||
const callsFile = path.resolve(".qa/video-task-db/provider-calls.jsonl");
|
||||
fs.writeFileSync(callsFile, "");
|
||||
|
||||
@ -1,34 +1,175 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { buildAliyunCanvasVideoRequest, executeAliyunCanvasVideo } from '@/lib/ai/providers/aliyun-canvas-video';
|
||||
import { filterCanvasModels } from '@/lib/video-canvas/model-matching';
|
||||
const input = { modelId:'wan2.6-i2v-flash', prompt:'red mug', mode:'first-frame' as const, images:['https://example.com/mug.png'], duration:5, resolution:'720p' as const, ratio:'adaptive' as const, apiKey:'test',baseURL:'https://dashscope.aliyuncs.com/api/v1' };
|
||||
test('maps Wan first frame to official synthesis contract, preserving audio false', () => {
|
||||
expect(buildAliyunCanvasVideoRequest({...input,generateAudio:false},input.modelId)).toEqual({model:input.modelId,input:{prompt:'red mug',img_url:input.images[0]},parameters:{resolution:'720P',duration:5,audio:false,prompt_extend:false,watermark:false}});
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
buildAliyunCanvasVideoRequest,
|
||||
executeAliyunCanvasVideo,
|
||||
} from "@/lib/ai/providers/aliyun-canvas-video";
|
||||
import { filterCanvasModels } from "@/lib/video-canvas/model-matching";
|
||||
const input = {
|
||||
modelId: "wan2.6-i2v-flash",
|
||||
prompt: "red mug",
|
||||
mode: "first-frame" as const,
|
||||
images: ["https://example.com/mug.png"],
|
||||
duration: 5,
|
||||
resolution: "720p" as const,
|
||||
ratio: "adaptive" as const,
|
||||
apiKey: "test",
|
||||
baseURL: "https://dashscope.aliyuncs.com/api/v1",
|
||||
};
|
||||
test("maps Wan first frame to official synthesis contract, preserving audio false", () => {
|
||||
expect(buildAliyunCanvasVideoRequest({ ...input, generateAudio: false }, input.modelId)).toEqual({
|
||||
model: input.modelId,
|
||||
input: { prompt: "red mug", img_url: input.images[0] },
|
||||
parameters: {
|
||||
resolution: "720P",
|
||||
duration: 5,
|
||||
audio: false,
|
||||
prompt_extend: false,
|
||||
watermark: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
test('unsupported modes, models and parameter combinations fail before submission', () => {
|
||||
for(const patch of [{mode:'first-last-frame',images:[...input.images,...input.images]},{duration:1},{duration:5.5},{resolution:'480p'},{audioUrls:['https://example.com/audio.mp3']},{returnLastFrame:true}]) expect(()=>buildAliyunCanvasVideoRequest({...input,...patch} as any,input.modelId)).toThrow();
|
||||
expect(()=>buildAliyunCanvasVideoRequest(input,'wan3-unknown')).toThrow();
|
||||
test("unsupported modes, models and parameter combinations fail before submission", () => {
|
||||
for (const patch of [
|
||||
{ mode: "first-last-frame", images: [...input.images, ...input.images] },
|
||||
{ duration: 1 },
|
||||
{ duration: 5.5 },
|
||||
{ resolution: "480p" },
|
||||
{ audioUrls: ["https://example.com/audio.mp3"] },
|
||||
{ returnLastFrame: true },
|
||||
])
|
||||
expect(() =>
|
||||
buildAliyunCanvasVideoRequest({ ...input, ...patch } as any, input.modelId),
|
||||
).toThrow();
|
||||
expect(() => buildAliyunCanvasVideoRequest(input, "wan3-unknown")).toThrow();
|
||||
});
|
||||
test('only the implemented Wan model is selectable, only for first frame', () => {
|
||||
const model={modelId:input.modelId,providerType:'qianwen-video',capabilities:{kind:'video',supportedModes:['first-frame']}};
|
||||
expect(filterCanvasModels([model],'video','first-frame')).toEqual([model]);
|
||||
expect(filterCanvasModels([model],'video','first-last-frame')).toEqual([]);
|
||||
expect(filterCanvasModels([{...model,modelId:'wan3-unknown'}],'video')).toEqual([]);
|
||||
test("only the implemented Wan model is selectable, only for first frame", () => {
|
||||
const model = {
|
||||
modelId: input.modelId,
|
||||
providerType: "qianwen-video",
|
||||
capabilities: { kind: "video", supportedModes: ["first-frame"] },
|
||||
};
|
||||
expect(filterCanvasModels([model], "video", "first-frame")).toEqual([model]);
|
||||
expect(filterCanvasModels([model], "video", "first-last-frame")).toEqual([]);
|
||||
expect(filterCanvasModels([{ ...model, modelId: "wan3-unknown" }], "video")).toEqual([]);
|
||||
});
|
||||
test('polls accepted task without resubmitting, returns upstream artifact and metadata', async () => {
|
||||
const calls:Array<{url:string;init:any}>=[];let polls=0;
|
||||
const fetch:typeof globalThis.fetch=async(url,init)=>{calls.push({url:String(url),init});return Response.json(init?.method==='POST'?{output:{task_id:'task-1'}}:{output:++polls===1?{task_status:'RUNNING'}:{task_status:'SUCCEEDED',video_url:'https://example.com/video.mp4'},request_id:'req-1'});};
|
||||
const result=await executeAliyunCanvasVideo(input,{fetch,sleep:async()=>{}});
|
||||
expect(calls.map(c=>[c.url,c.init.method||'GET'])).toEqual([['https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis','POST'],['https://dashscope.aliyuncs.com/api/v1/tasks/task-1','GET'],['https://dashscope.aliyuncs.com/api/v1/tasks/task-1','GET']]);
|
||||
expect(result).toMatchObject({url:'https://example.com/video.mp4',metadata:{taskId:'task-1',duration:5,generateAudio:false}});
|
||||
test("polls accepted task without resubmitting, returns upstream artifact and metadata", async () => {
|
||||
const calls: Array<{ url: string; init: any }> = [];
|
||||
let polls = 0;
|
||||
const fetch: typeof globalThis.fetch = async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return Response.json(
|
||||
init?.method === "POST"
|
||||
? { output: { task_id: "task-1" } }
|
||||
: {
|
||||
output:
|
||||
++polls === 1
|
||||
? { task_status: "RUNNING" }
|
||||
: { task_status: "SUCCEEDED", video_url: "https://example.com/video.mp4" },
|
||||
request_id: "req-1",
|
||||
},
|
||||
);
|
||||
};
|
||||
const result = await executeAliyunCanvasVideo(input, { fetch, sleep: async () => {} });
|
||||
expect(calls.map((c) => [c.url, c.init.method || "GET"])).toEqual([
|
||||
[
|
||||
"https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis",
|
||||
"POST",
|
||||
],
|
||||
["https://dashscope.aliyuncs.com/api/v1/tasks/task-1", "GET"],
|
||||
["https://dashscope.aliyuncs.com/api/v1/tasks/task-1", "GET"],
|
||||
]);
|
||||
expect(result).toMatchObject({
|
||||
url: "https://example.com/video.mp4",
|
||||
metadata: { taskId: "task-1", duration: 5, generateAudio: false },
|
||||
});
|
||||
});
|
||||
test('task failure is not returned as a successful video', async()=>{
|
||||
const fetch:typeof globalThis.fetch=async(_url,init)=>Response.json(init?.method==='POST'?{output:{task_id:'task-failed'}}:{output:{task_status:'FAILED',code:'InvalidParameter'}});
|
||||
await expect(executeAliyunCanvasVideo(input,{fetch,sleep:async()=>{}})).rejects.toThrow('InvalidParameter,任务 task-failed');
|
||||
test("task failure is not returned as a successful video", async () => {
|
||||
const fetch: typeof globalThis.fetch = async (_url, init) =>
|
||||
Response.json(
|
||||
init?.method === "POST"
|
||||
? { output: { task_id: "task-failed" } }
|
||||
: { output: { task_status: "FAILED", code: "InvalidParameter" } },
|
||||
);
|
||||
await expect(executeAliyunCanvasVideo(input, { fetch, sleep: async () => {} })).rejects.toThrow(
|
||||
"InvalidParameter,任务 task-failed",
|
||||
);
|
||||
});
|
||||
test('completed task without output is rejected and API auth errors do not expose secrets', async()=>{
|
||||
const missing:typeof globalThis.fetch=async(_url,init)=>Response.json(init?.method==='POST'?{output:{task_id:'empty'}}:{output:{task_status:'SUCCEEDED'}});
|
||||
await expect(executeAliyunCanvasVideo(input,{fetch:missing,sleep:async()=>{}})).rejects.toThrow('没有返回视频');
|
||||
const denied:typeof globalThis.fetch=async()=>Response.json({code:'InvalidApiKey',message:'private upstream diagnostic'}, {status:401});
|
||||
await expect(executeAliyunCanvasVideo(input,{fetch:denied,sleep:async()=>{}})).rejects.toThrow('阿里云视频请求失败(401):InvalidApiKey');
|
||||
test("completed task without output is rejected and API auth errors do not expose secrets", async () => {
|
||||
const missing: typeof globalThis.fetch = async (_url, init) =>
|
||||
Response.json(
|
||||
init?.method === "POST"
|
||||
? { output: { task_id: "empty" } }
|
||||
: { output: { task_status: "SUCCEEDED" } },
|
||||
);
|
||||
await expect(
|
||||
executeAliyunCanvasVideo(input, { fetch: missing, sleep: async () => {} }),
|
||||
).rejects.toThrow("没有返回视频");
|
||||
const denied: typeof globalThis.fetch = async () =>
|
||||
Response.json(
|
||||
{ code: "InvalidApiKey", message: "private upstream diagnostic" },
|
||||
{ status: 401 },
|
||||
);
|
||||
await expect(
|
||||
executeAliyunCanvasVideo(input, { fetch: denied, sleep: async () => {} }),
|
||||
).rejects.toThrow("阿里云视频请求失败(401):InvalidApiKey");
|
||||
});
|
||||
|
||||
test("resumes a persisted Aliyun task without POST or saving a new task ID", async () => {
|
||||
const calls: string[] = [];
|
||||
const result = await executeAliyunCanvasVideo(
|
||||
{
|
||||
...input,
|
||||
lifecycle: {
|
||||
resume: { taskId: "existing" },
|
||||
accepted: async () => {
|
||||
throw Error("unexpected create");
|
||||
},
|
||||
assertActive: async () => {},
|
||||
},
|
||||
},
|
||||
{
|
||||
sleep: async () => {},
|
||||
fetch: async (url, init) => {
|
||||
calls.push(init?.method || "GET");
|
||||
expect(String(url)).toContain("/tasks/existing");
|
||||
return Response.json({
|
||||
output: { task_status: "SUCCEEDED", video_url: "https://example.com/old.mp4" },
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(calls).toEqual(["GET"]);
|
||||
expect(result.url).toContain("old.mp4");
|
||||
});
|
||||
test("saves accepted video ID before first GET and stops when execution ownership changes", async () => {
|
||||
let saved = false;
|
||||
let gets = 0;
|
||||
await expect(
|
||||
executeAliyunCanvasVideo(
|
||||
{
|
||||
...input,
|
||||
lifecycle: {
|
||||
accepted: async ({ taskId }) => {
|
||||
expect(taskId).toBe("accepted");
|
||||
saved = true;
|
||||
},
|
||||
assertActive: async () => {
|
||||
if (saved) throw Error("lease lost");
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
sleep: async () => {},
|
||||
fetch: async (_url, init) => {
|
||||
if (init?.method !== "POST") {
|
||||
gets++;
|
||||
expect(saved).toBe(true);
|
||||
}
|
||||
return Response.json({ output: { task_id: "accepted" } });
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("lease lost");
|
||||
expect(saved).toBe(true);
|
||||
expect(gets).toBe(0);
|
||||
});
|
||||
|
||||
@ -65,9 +65,7 @@ test("portrait and square screenshot dimensions preserve selected composition",
|
||||
import { validateDirectorGlb } from "@/lib/video-canvas/director-glb";
|
||||
function glb(document: unknown) {
|
||||
const json = JSON.stringify(document);
|
||||
const data = new TextEncoder().encode(
|
||||
json + " ".repeat((4 - (json.length % 4)) % 4),
|
||||
);
|
||||
const data = new TextEncoder().encode(json + " ".repeat((4 - (json.length % 4)) % 4));
|
||||
const buffer = new ArrayBuffer(20 + data.length);
|
||||
const view = new DataView(buffer);
|
||||
[0x46546c67, 2, buffer.byteLength, data.length, 0x4e4f534a].forEach((n, i) =>
|
||||
@ -100,9 +98,7 @@ test("GLB upload requires an intact self-contained 2.0 model", () => {
|
||||
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
|
||||
test("models stored on the active media CDN use the authenticated same-origin proxy", () => {
|
||||
const url = "https://scqny.jiansun.vip/uploads/director.glb";
|
||||
expect(buildThreeAssetUrl(url)).toBe(
|
||||
"/api/canvas/3d/model-proxy?url=" + encodeURIComponent(url),
|
||||
);
|
||||
expect(buildThreeAssetUrl(url)).toBe("/api/canvas/3d/model-proxy?url=" + encodeURIComponent(url));
|
||||
});
|
||||
|
||||
import {
|
||||
@ -134,31 +130,21 @@ test("motion survives serialization and duration changes preserve relative keyfr
|
||||
const resized = resizeDirectorMotion(scene.motion, 10);
|
||||
expect(resized.keyframes[4].time).toBe(5);
|
||||
expect(resized.keyframes.at(-1)?.time).toBe(10);
|
||||
expect(resized.keyframes[4].position).toEqual(
|
||||
scene.motion.keyframes[4].position,
|
||||
);
|
||||
expect(resized.keyframes[4].position).toEqual(scene.motion.keyframes[4].position);
|
||||
});
|
||||
test("timeline sampling clamps endpoints, holds outside keyframes and differentiates easing", () => {
|
||||
const motion = presetDirectorMotion(pose(), "push", 5);
|
||||
motion.easing = "linear";
|
||||
expect(sampleDirectorMotion(motion, -20)?.position).toEqual(
|
||||
motion.keyframes[0].position,
|
||||
);
|
||||
expect(sampleDirectorMotion(motion, 30)?.position).toEqual(
|
||||
motion.keyframes[1].position,
|
||||
);
|
||||
expect(sampleDirectorMotion(motion, -20)?.position).toEqual(motion.keyframes[0].position);
|
||||
expect(sampleDirectorMotion(motion, 30)?.position).toEqual(motion.keyframes[1].position);
|
||||
expect(sampleDirectorMotion(motion, 2.5)?.position[2]).toBe(6);
|
||||
const linear = sampleDirectorMotion(motion, 1)?.position[2]!;
|
||||
motion.easing = "smooth";
|
||||
expect(sampleDirectorMotion(motion, 1)?.position[2]).toBeGreaterThan(linear);
|
||||
motion.keyframes[0].time = 1;
|
||||
motion.keyframes[1].time = 4;
|
||||
expect(sampleDirectorMotion(motion, 0)?.position).toEqual(
|
||||
motion.keyframes[0].position,
|
||||
);
|
||||
expect(sampleDirectorMotion(motion, 5)?.position).toEqual(
|
||||
motion.keyframes[1].position,
|
||||
);
|
||||
expect(sampleDirectorMotion(motion, 0)?.position).toEqual(motion.keyframes[0].position);
|
||||
expect(sampleDirectorMotion(motion, 5)?.position).toEqual(motion.keyframes[1].position);
|
||||
expect(sampleDirectorMotion(defaultDirectorMotion(), 0)).toBeNull();
|
||||
});
|
||||
test("invalid, repeated, out of range or coincident keyframes are rejected", () => {
|
||||
@ -180,9 +166,7 @@ test("all motion presets remain finite and differ from their initial camera", ()
|
||||
const m = presetDirectorMotion(pose(), kind, 5);
|
||||
for (let t = 0; t <= 5; t += 0.025)
|
||||
expect(validDirectorPose(sampleDirectorMotion(m, t)!)).toBe(true);
|
||||
expect(sampleDirectorMotion(m, 5)?.position).not.toEqual(
|
||||
sampleDirectorMotion(m, 0)?.position,
|
||||
);
|
||||
expect(sampleDirectorMotion(m, 5)?.position).not.toEqual(sampleDirectorMotion(m, 0)?.position);
|
||||
expect(directorMotionPrompt(m)).toContain("5 秒");
|
||||
expect(directorMotionPrompt(m)).toContain("5.00秒");
|
||||
}
|
||||
@ -201,6 +185,7 @@ import { recordDirectorVideo } from "@/lib/video-canvas/director-recording";
|
||||
test("recording stops tracks and rejects partial exports on cancel or background switch", async () => {
|
||||
for (const outcome of [
|
||||
"complete",
|
||||
"complete-webm",
|
||||
"cancel",
|
||||
"hidden",
|
||||
"render-error",
|
||||
@ -221,10 +206,13 @@ test("recording stops tracks and rejects partial exports on cancel or background
|
||||
stopped = 0;
|
||||
class Recorder {
|
||||
static isTypeSupported(type: string) {
|
||||
return type === "video/mp4";
|
||||
return type === (outcome === "complete-webm" ? "video/webm;codecs=vp8" : "video/mp4");
|
||||
}
|
||||
state = "inactive";
|
||||
mimeType = "video/mp4";
|
||||
mimeType: string;
|
||||
constructor(_stream: unknown, options: { mimeType: string }) {
|
||||
this.mimeType = options.mimeType;
|
||||
}
|
||||
onstop?: () => void;
|
||||
ondataavailable?: (e: { data: Blob }) => void;
|
||||
start() {
|
||||
@ -263,10 +251,12 @@ test("recording stops tracks and rejects partial exports on cancel or background
|
||||
callbacks.clear();
|
||||
pending.forEach((fn) => fn(time));
|
||||
};
|
||||
if (outcome === "complete") {
|
||||
if (outcome === "complete" || outcome === "complete-webm") {
|
||||
step(1000);
|
||||
step(3000);
|
||||
expect((await result).type).toBe("video/mp4");
|
||||
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();
|
||||
@ -291,18 +281,10 @@ test("recording stops tracks and rejects partial exports on cancel or background
|
||||
}
|
||||
});
|
||||
test("AI motion prompt describes the movement and stays bounded for large timelines", () => {
|
||||
expect(directorMotionPrompt(presetDirectorMotion(pose(), "push"))).toContain(
|
||||
"推进",
|
||||
);
|
||||
expect(directorMotionPrompt(presetDirectorMotion(pose(), "pull"))).toContain(
|
||||
"拉远",
|
||||
);
|
||||
expect(directorMotionPrompt(presetDirectorMotion(pose(), "orbit"))).toContain(
|
||||
"围绕",
|
||||
);
|
||||
expect(directorMotionPrompt(presetDirectorMotion(pose(), "pan"))).toContain(
|
||||
"平移",
|
||||
);
|
||||
expect(directorMotionPrompt(presetDirectorMotion(pose(), "push"))).toContain("推进");
|
||||
expect(directorMotionPrompt(presetDirectorMotion(pose(), "pull"))).toContain("拉远");
|
||||
expect(directorMotionPrompt(presetDirectorMotion(pose(), "orbit"))).toContain("围绕");
|
||||
expect(directorMotionPrompt(presetDirectorMotion(pose(), "pan"))).toContain("平移");
|
||||
const m = presetDirectorMotion(pose(), "orbit", 15);
|
||||
m.keyframes = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: String(i),
|
||||
@ -323,12 +305,101 @@ test("editing a keyframe cannot replace the active camera identity", () => {
|
||||
expect(mergeDirectorCameraPose(edited, { fov: 65 }).fov).toBe(65);
|
||||
});
|
||||
|
||||
test('director appearance, pose, visibility and locking survive save/reload without changing transforms',()=>{
|
||||
const scene=defaultDirectorScene();
|
||||
Object.assign(scene.objects[0],{pose:'跑步',color:'#123abc',visible:false,locked:true});
|
||||
test("director appearance, pose, visibility and locking survive save/reload without changing transforms", () => {
|
||||
const scene = defaultDirectorScene();
|
||||
Object.assign(scene.objects[0], { pose: "跑步", color: "#123abc", visible: false, locked: true });
|
||||
expect(readDirectorScene(JSON.parse(JSON.stringify(scene)))).toEqual(scene);
|
||||
const invalid=JSON.parse(JSON.stringify(scene));invalid.objects[0].pose='unknown';invalid.objects[0].color='url(evil)';
|
||||
const loaded=readDirectorScene(invalid);
|
||||
expect(loaded.objects[0].pose).toBeUndefined();expect(loaded.objects[0].color).toBeUndefined();
|
||||
const invalid = JSON.parse(JSON.stringify(scene));
|
||||
invalid.objects[0].pose = "unknown";
|
||||
invalid.objects[0].color = "url(evil)";
|
||||
const loaded = readDirectorScene(invalid);
|
||||
expect(loaded.objects[0].pose).toBeUndefined();
|
||||
expect(loaded.objects[0].color).toBeUndefined();
|
||||
expect(loaded.objects[0].position).toEqual(scene.objects[0].position);
|
||||
});
|
||||
|
||||
import {
|
||||
commitDirectorScene,
|
||||
resolveDirectorCamera,
|
||||
cameraAngles,
|
||||
rotateDirectorCamera,
|
||||
createDirectorCrowd,
|
||||
transformDirectorMotion,
|
||||
directorBodies,
|
||||
} from "@/lib/video-canvas/director-extensions";
|
||||
test("each camera retains its own motion track across switching and reload", () => {
|
||||
let scene = defaultDirectorScene();
|
||||
scene.motion = presetDirectorMotion(scene.cameras[0], "push", 5);
|
||||
const original = scene.motion;
|
||||
scene = commitDirectorScene(scene, {
|
||||
...scene,
|
||||
cameras: [...scene.cameras, { ...scene.cameras[0], id: "camera-2", name: "second" }],
|
||||
activeCameraId: "camera-2",
|
||||
});
|
||||
expect(scene.motion.keyframes).toEqual([]);
|
||||
const second = presetDirectorMotion(scene.cameras[1], "orbit", 5);
|
||||
scene = commitDirectorScene(scene, { ...scene, motion: second });
|
||||
scene = commitDirectorScene(scene, { ...scene, activeCameraId: "camera-1" });
|
||||
expect(scene.motion).toEqual(original);
|
||||
expect(scene.motionTracks?.["camera-2"]).toEqual(second);
|
||||
const restored = readDirectorScene(JSON.parse(JSON.stringify(scene)));
|
||||
expect(restored.motionTracks?.["camera-2"]).toEqual(second);
|
||||
});
|
||||
test("following an object updates camera target and follows the same rule during motion export", () => {
|
||||
const scene = defaultDirectorScene();
|
||||
scene.cameras[0].followObjectId = scene.objects[0].id;
|
||||
scene.objects[0].position = [3, 2, 1];
|
||||
scene.objects[0].scale = [1, 2, 1];
|
||||
expect(resolveDirectorCamera(scene).target).toEqual([3, 4, 1]);
|
||||
const pose = {
|
||||
position: [10, 5, 4] as [number, number, number],
|
||||
target: [0, 0, 0] as [number, number, number],
|
||||
fov: 50,
|
||||
};
|
||||
expect(resolveDirectorCamera(scene, pose)).toMatchObject({
|
||||
position: [10, 5, 4],
|
||||
target: [3, 4, 1],
|
||||
});
|
||||
scene.objects = [];
|
||||
expect(readDirectorScene(scene).cameras[0].followObjectId).toBeUndefined();
|
||||
});
|
||||
test("camera yaw and pitch preserve distance and remain finite at vertical extremes", () => {
|
||||
const camera = defaultDirectorScene().cameras[0];
|
||||
const distance = Math.hypot(...camera.target.map((n, i) => n - camera.position[i]));
|
||||
const target = rotateDirectorCamera(camera, 90, 30);
|
||||
const angles = cameraAngles({ ...camera, target });
|
||||
expect(angles.yaw).toBeCloseTo(90);
|
||||
expect(angles.pitch).toBeCloseTo(30);
|
||||
expect(Math.hypot(...target.map((n, i) => n - camera.position[i]))).toBeCloseTo(distance);
|
||||
expect(
|
||||
cameraAngles({ ...camera, target: rotateDirectorCamera(camera, 0, 200) }).pitch,
|
||||
).toBeCloseTo(89);
|
||||
});
|
||||
test("crowd copies have independent IDs and transforms; body selection survives reload", () => {
|
||||
const scene = defaultDirectorScene();
|
||||
scene.objects[0].body = "幼童";
|
||||
let id = 0;
|
||||
const copies = createDirectorCrowd(scene.objects[0], 1, () => String(++id));
|
||||
expect(copies).toHaveLength(8);
|
||||
expect(new Set(copies.map((o) => o.id)).size).toBe(8);
|
||||
expect(copies[7].position).toEqual([3, 0, 3]);
|
||||
expect(() => createDirectorCrowd(scene.objects[0], 199, () => "")).toThrow("200");
|
||||
expect(Object.keys(directorBodies)).toHaveLength(8);
|
||||
expect(readDirectorScene(scene).objects[0].body).toBe("幼童");
|
||||
});
|
||||
test("whole-path transforms change camera and target together while preserving duration and distance", () => {
|
||||
const scene = defaultDirectorScene(),
|
||||
motion = presetDirectorMotion(scene.cameras[0], "push", 5);
|
||||
const moved = transformDirectorMotion(motion, [1, 2, 3], 90);
|
||||
expect(moved.duration).toBe(motion.duration);
|
||||
moved.keyframes.forEach((k, i) =>
|
||||
expect(Math.hypot(...k.position.map((n, j) => n - k.target[j]))).toBeCloseTo(
|
||||
Math.hypot(...motion.keyframes[i].position.map((n, j) => n - motion.keyframes[i].target[j])),
|
||||
),
|
||||
);
|
||||
expect(moved.keyframes[0].target).toEqual([
|
||||
motion.keyframes[0].target[0] + 1,
|
||||
motion.keyframes[0].target[1] + 2,
|
||||
motion.keyframes[0].target[2] + 3,
|
||||
]);
|
||||
});
|
||||
|
||||
@ -4,10 +4,19 @@ import { CanvasRevisionConflict, VideoCanvasSaveQueue } from "@/lib/video-canvas
|
||||
test("coalesces edits made during a save and uses the returned revision", async () => {
|
||||
let release!: (value: string) => void;
|
||||
const calls: string[][] = [];
|
||||
const queue = new VideoCanvasSaveQueue("initial", "r0", async (value, revision) => {
|
||||
calls.push([value, revision]);
|
||||
return calls.length === 1 ? new Promise<string>(resolve => { release = resolve; }) : "r2";
|
||||
}, () => {});
|
||||
const queue = new VideoCanvasSaveQueue(
|
||||
"initial",
|
||||
"r0",
|
||||
async (value, revision) => {
|
||||
calls.push([value, revision]);
|
||||
return calls.length === 1
|
||||
? new Promise<string>((resolve) => {
|
||||
release = resolve;
|
||||
})
|
||||
: "r2";
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
queue.update("first");
|
||||
const pending = queue.flush();
|
||||
queue.update("second");
|
||||
@ -15,33 +24,49 @@ test("coalesces edits made during a save and uses the returned revision", async
|
||||
expect(queue.flush()).toBe(pending);
|
||||
release("r1");
|
||||
await pending;
|
||||
expect(calls).toEqual([["first", "r0"], ["latest", "r1"]]);
|
||||
expect(calls).toEqual([
|
||||
["first", "r0"],
|
||||
["latest", "r1"],
|
||||
]);
|
||||
expect(queue.dirty).toBe(false);
|
||||
});
|
||||
|
||||
test("failed save retains edits and retries only the latest snapshot", async () => {
|
||||
const calls: string[][] = [];
|
||||
const queue = new VideoCanvasSaveQueue("initial", "r0", async (value, revision) => {
|
||||
calls.push([value, revision]);
|
||||
if (calls.length === 1) throw new Error("offline");
|
||||
return "r1";
|
||||
}, () => {});
|
||||
const queue = new VideoCanvasSaveQueue(
|
||||
"initial",
|
||||
"r0",
|
||||
async (value, revision) => {
|
||||
calls.push([value, revision]);
|
||||
if (calls.length === 1) throw new Error("offline");
|
||||
return "r1";
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
queue.update("first");
|
||||
await queue.flush();
|
||||
expect(queue.dirty).toBe(true);
|
||||
expect(queue.failed).toBe(true);
|
||||
queue.update("latest");
|
||||
await queue.flush();
|
||||
expect(calls).toEqual([["first", "r0"], ["latest", "r0"]]);
|
||||
expect(calls).toEqual([
|
||||
["first", "r0"],
|
||||
["latest", "r0"],
|
||||
]);
|
||||
expect(queue.dirty).toBe(false);
|
||||
});
|
||||
|
||||
test("revision conflict cannot be retried as an overwrite", async () => {
|
||||
let calls = 0;
|
||||
const queue = new VideoCanvasSaveQueue("initial", "r0", async () => {
|
||||
calls++;
|
||||
throw new CanvasRevisionConflict("changed elsewhere");
|
||||
}, () => {});
|
||||
const queue = new VideoCanvasSaveQueue(
|
||||
"initial",
|
||||
"r0",
|
||||
async () => {
|
||||
calls++;
|
||||
throw new CanvasRevisionConflict("changed elsewhere");
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
queue.update("first");
|
||||
await queue.flush();
|
||||
queue.update("latest");
|
||||
@ -52,8 +77,194 @@ test("revision conflict cannot be retried as an overwrite", async () => {
|
||||
|
||||
test("unchanged canvas does not write", async () => {
|
||||
let calls = 0;
|
||||
const queue = new VideoCanvasSaveQueue("initial", "r0", async () => { calls++; return "r1"; }, () => {});
|
||||
const queue = new VideoCanvasSaveQueue(
|
||||
"initial",
|
||||
"r0",
|
||||
async () => {
|
||||
calls++;
|
||||
return "r1";
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
queue.update("initial");
|
||||
await queue.flush();
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
import { mergeCanvasSnapshots } from "@/lib/video-canvas/merge-snapshot";
|
||||
const snapshot = (nodes: any[], edges: any[] = []) =>
|
||||
JSON.stringify({
|
||||
title: "QA",
|
||||
canvasData: {
|
||||
nodes,
|
||||
edges,
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
selectedNodeIds: [],
|
||||
tool: "select",
|
||||
},
|
||||
});
|
||||
const base = snapshot([
|
||||
{ id: "a", title: "A", x: 0, draftValues: { text: "original" } },
|
||||
{ id: "b", title: "B", x: 0 },
|
||||
]);
|
||||
test("merges independent node edits, additions and deletion without reviving removed edges", () => {
|
||||
const local = snapshot([
|
||||
{ id: "a", title: "A", x: 20, draftValues: { text: "original" } },
|
||||
{ id: "b", title: "B", x: 0 },
|
||||
{ id: "c", title: "C" },
|
||||
]);
|
||||
const remote = snapshot(
|
||||
[
|
||||
{ id: "a", title: "new A", x: 0, draftValues: { text: "original" } },
|
||||
{ id: "d", title: "D" },
|
||||
],
|
||||
[{ id: "e", source: "a", target: "b" }],
|
||||
);
|
||||
const merged = mergeCanvasSnapshots(base, local, remote);
|
||||
expect(merged.conflicts).toEqual([]);
|
||||
expect(JSON.parse(merged.snapshot).canvasData.nodes).toEqual([
|
||||
{ id: "a", title: "new A", x: 20, draftValues: { text: "original" } },
|
||||
{ id: "d", title: "D" },
|
||||
{ id: "c", title: "C" },
|
||||
]);
|
||||
expect(JSON.parse(merged.snapshot).canvasData.edges).toEqual([]);
|
||||
});
|
||||
test("same field and deletion versus modification require a decision, preserving other edits", () => {
|
||||
const local = snapshot([{ id: "a", title: "local", x: 10, draftValues: { text: "original" } }]);
|
||||
const remote = snapshot([
|
||||
{ id: "a", title: "remote", x: 0, draftValues: { text: "new" } },
|
||||
{ id: "b", title: "changed B", x: 0 },
|
||||
]);
|
||||
const merged = mergeCanvasSnapshots(base, local, remote);
|
||||
expect(merged.conflicts).toEqual(["canvasData.nodes[a].title", "canvasData.nodes[b]"]);
|
||||
const localChoice = JSON.parse(mergeCanvasSnapshots(base, local, remote, "local").snapshot)
|
||||
.canvasData.nodes;
|
||||
expect(localChoice).toEqual([{ id: "a", title: "local", x: 10, draftValues: { text: "new" } }]);
|
||||
const remoteChoice = JSON.parse(mergeCanvasSnapshots(base, local, remote, "remote").snapshot)
|
||||
.canvasData.nodes;
|
||||
expect(remoteChoice[0]).toMatchObject({ title: "remote", x: 10, draftValues: { text: "new" } });
|
||||
expect(remoteChoice).toHaveLength(2);
|
||||
});
|
||||
test("view state stays local, metadata timestamps do not create false content conflicts", () => {
|
||||
const local = JSON.parse(base),
|
||||
remote = JSON.parse(base);
|
||||
local.canvasData.nodes[0].updatedAt = "local";
|
||||
remote.canvasData.nodes[0].updatedAt = "remote";
|
||||
remote.canvasData.nodes[0].title = "remote";
|
||||
remote.canvasData.viewport.x = 50;
|
||||
const merged = mergeCanvasSnapshots(base, JSON.stringify(local), JSON.stringify(remote));
|
||||
expect(merged.conflicts).toEqual([]);
|
||||
expect(JSON.parse(merged.snapshot).canvasData.viewport.x).toBe(0);
|
||||
});
|
||||
test("queued edits made while reading a conflict survive the automatic merge and CAS retry", async () => {
|
||||
let release!: (e: Error) => void;
|
||||
let calls = 0;
|
||||
let applied = "";
|
||||
const local = snapshot([
|
||||
{ id: "a", title: "A", x: 10, draftValues: { text: "original" } },
|
||||
{ id: "b", title: "B", x: 0 },
|
||||
]);
|
||||
const newer = snapshot([
|
||||
{ id: "a", title: "A", x: 20, draftValues: { text: "original" } },
|
||||
{ id: "b", title: "B", x: 0 },
|
||||
]);
|
||||
const remote = snapshot([
|
||||
{ id: "a", title: "remote", x: 0, draftValues: { text: "original" } },
|
||||
{ id: "b", title: "B", x: 0 },
|
||||
]);
|
||||
const queue = new VideoCanvasSaveQueue(
|
||||
base,
|
||||
"r0",
|
||||
async (value, revision) => {
|
||||
calls++;
|
||||
if (calls === 1) return new Promise((_, reject) => (release = reject));
|
||||
expect(revision).toBe("r1");
|
||||
expect(JSON.parse(value).canvasData.nodes[0]).toMatchObject({ title: "remote", x: 20 });
|
||||
return "r2";
|
||||
},
|
||||
() => {},
|
||||
{ merge: mergeCanvasSnapshots, apply: (value) => (applied = value), conflict: () => {} },
|
||||
);
|
||||
queue.update(local);
|
||||
const saving = queue.flush();
|
||||
queue.update(newer);
|
||||
release(new CanvasRevisionConflict("conflict", remote, "r1"));
|
||||
await saving;
|
||||
expect(calls).toBe(2);
|
||||
expect(queue.dirty).toBe(false);
|
||||
expect(applied).toContain("remote");
|
||||
});
|
||||
test("conflict resolution and restored browser drafts never blindly overwrite the remote document", async () => {
|
||||
const local = snapshot([
|
||||
{ id: "a", title: "local", x: 0, draftValues: { text: "original" } },
|
||||
{ id: "b", title: "B", x: 0 },
|
||||
]);
|
||||
const remote = snapshot([
|
||||
{ id: "a", title: "remote", x: 0, draftValues: { text: "new" } },
|
||||
{ id: "b", title: "B", x: 0 },
|
||||
]);
|
||||
let conflict: string[] = [];
|
||||
let written = "";
|
||||
const queue = new VideoCanvasSaveQueue(
|
||||
remote,
|
||||
"r2",
|
||||
async (value, revision) => {
|
||||
expect(revision).toBe("r2");
|
||||
written = value;
|
||||
return "r3";
|
||||
},
|
||||
() => {},
|
||||
{ merge: mergeCanvasSnapshots, apply: () => {}, conflict: (fields) => (conflict = fields) },
|
||||
);
|
||||
queue.restoreBackup(base, local);
|
||||
await queue.flush();
|
||||
expect(written).toBe("");
|
||||
expect(conflict).toHaveLength(1);
|
||||
await queue.resolveConflict("local");
|
||||
expect(JSON.parse(written).canvasData.nodes[0]).toMatchObject({
|
||||
title: "local",
|
||||
draftValues: { text: "new" },
|
||||
});
|
||||
expect(conflict).toEqual([]);
|
||||
expect(queue.dirty).toBe(false);
|
||||
});
|
||||
|
||||
test("rapid remote saves pause automatic merging without permanently blocking manual retry", async () => {
|
||||
let calls = 0;
|
||||
let conflictFields: string[] = [];
|
||||
const local = JSON.parse(base);
|
||||
local.canvasData.nodes[0].title = "local";
|
||||
const queue = new VideoCanvasSaveQueue(
|
||||
base,
|
||||
"r0",
|
||||
async (value) => {
|
||||
calls++;
|
||||
if (calls <= 4) {
|
||||
const remote = JSON.parse(base);
|
||||
remote.canvasData.nodes[1].title = `remote-${calls}`;
|
||||
throw new CanvasRevisionConflict("conflict", JSON.stringify(remote), `r${calls}`);
|
||||
}
|
||||
const nodes = JSON.parse(value).canvasData.nodes;
|
||||
expect(nodes[0].title).toBe("local");
|
||||
expect(nodes[1].title).toBe("remote-4");
|
||||
return "r5";
|
||||
},
|
||||
() => {},
|
||||
{
|
||||
merge: mergeCanvasSnapshots,
|
||||
apply: () => {},
|
||||
conflict: (fields) => {
|
||||
conflictFields = fields;
|
||||
},
|
||||
},
|
||||
);
|
||||
queue.update(JSON.stringify(local));
|
||||
await queue.flush();
|
||||
expect(calls).toBe(4);
|
||||
expect(queue.failed).toBe(true);
|
||||
expect(queue.dirty).toBe(true);
|
||||
expect(conflictFields).toEqual([]);
|
||||
await queue.flush();
|
||||
expect(calls).toBe(5);
|
||||
expect(queue.dirty).toBe(false);
|
||||
});
|
||||
|
||||
@ -425,7 +425,11 @@ test("recovered task results populate a script while retaining the independent o
|
||||
});
|
||||
|
||||
// Recovery only queries an existing, server-recorded task; it never submits another job.
|
||||
import { canResumeCanvasTask, getEnhanceCheckpoint } from "@/lib/video-canvas/task-recovery";
|
||||
import {
|
||||
canResumeCanvasTask,
|
||||
getEnhanceCheckpoint,
|
||||
getVideoCheckpoint,
|
||||
} from "@/lib/video-canvas/task-recovery";
|
||||
import { CanvasTaskLeaseLostError } from "@/lib/video-canvas/image-operation";
|
||||
import { normalizeVideoAnalysisRows } from "@/lib/video-canvas/video-story";
|
||||
import { pollTaskResult, EnhanceTerminalError } from "@/lib/canvas/enhance-task-poll";
|
||||
@ -621,3 +625,34 @@ test("video story refresh restores real rows, pending state and errors without a
|
||||
lighting: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("video recovery requires an original server checkpoint and matching selected model", () => {
|
||||
const upstreamTask = {
|
||||
operation: "video",
|
||||
provider: "aliyun",
|
||||
taskId: "video-original",
|
||||
modelRef: "model-row",
|
||||
modelId: "wan-test",
|
||||
providerId: "provider-row",
|
||||
baseUrl: "https://provider.invalid",
|
||||
};
|
||||
const task = {
|
||||
...recoverable,
|
||||
request: { kind: "video", params: { preferredModelRef: "model-row" } },
|
||||
result: { upstreamTask },
|
||||
};
|
||||
expect(getVideoCheckpoint(task)).toEqual(upstreamTask);
|
||||
expect(canResumeCanvasTask(task, recoveryTime)).toBe(true);
|
||||
for (const value of [
|
||||
undefined,
|
||||
{ ...upstreamTask, taskId: "" },
|
||||
{ ...upstreamTask, provider: "unknown" },
|
||||
{ ...upstreamTask, modelRef: "other" },
|
||||
]) {
|
||||
expect(getVideoCheckpoint({ ...task, result: { upstreamTask: value } })).toBeNull();
|
||||
expect(canResumeCanvasTask({ ...task, result: { upstreamTask: value } }, recoveryTime)).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
expect(canResumeCanvasTask({ ...task, attempts: 20 }, recoveryTime)).toBe(false);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user