fix(video-canvas): resume provider jobs and recover editing and 3d state

This commit is contained in:
134 2026-09-17 15:01:02 +08:00
parent 69dcf9b745
commit 292eddc5a6
29 changed files with 2199 additions and 239 deletions

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -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/音乐实现,不能用仅有入口或普通模型目录代替这些能力。未接通入口继续明确标示状态。

View File

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

View File

@ -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},请勿重复生成`);

View File

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

View File

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

View File

@ -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("视频生成任务已取消");
}
// 静默等待,不输出进度文字

View File

@ -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;
}
}
/**
* 视频生成工具
*/

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

View File

@ -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("轨迹导出超时,请重试")),

View File

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

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

View File

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

View File

@ -173,7 +173,7 @@ export function reconcileCanvasTasks<
(task.status === "cancelled" ? "已取消排队,未提交供应商" : "任务失败,请查看任务状态"),
});
if (task.status === "interrupted" && hasRecoverableCanvasTask(task))
nextDraft.generationError = "高清任务查询中断,稍后会自动查询原任务,请勿重复生成。";
nextDraft.generationError = "任务查询中断,稍后会自动查询原任务,请勿重复生成。";
subtitle =
task.status === "cancelled"
? "已取消"

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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