ai-chatbot/tests/routes/video-canvas-director.test.ts

541 lines
20 KiB
TypeScript

import { test, expect } from "@playwright/test";
import {
defaultDirectorScene,
directorImageSize,
readDirectorScene,
} from "@/lib/video-canvas/director-scene";
test("director preserves real model, camera and screenshot through JSON save/reload", () => {
const scene = defaultDirectorScene();
scene.objects.push({
id: "model-1",
name: "Cube",
kind: "model",
url: "https://example.com/cube.glb",
position: [2, 0.5, 0],
rotation: [0, 1, 0],
scale: [1, 2, 1],
});
scene.cameras.push({
id: "side",
name: "侧面",
position: [7, 2, 0],
target: [0, 1, 0],
fov: 35,
});
scene.activeCameraId = "side";
scene.ratio = "9:16";
scene.snapshots.push({
id: "shot",
title: "侧面截图",
url: "https://example.com/actual.png",
width: 720,
height: 1280,
cameraId: "side",
});
expect(readDirectorScene(JSON.parse(JSON.stringify(scene)))).toEqual(scene);
});
test("director scene appearance controls persist and reject malformed colors", () => {
const scene = defaultDirectorScene();
scene.skyColor = "#123456";
scene.gridVisible = false;
scene.groundVisible = false;
expect(readDirectorScene(JSON.parse(JSON.stringify(scene)))).toEqual(scene);
expect(readDirectorScene({ ...scene, skyColor: "red;url(x)", gridVisible: "false", groundVisible: null })).toMatchObject({
skyColor: "#080808",
gridVisible: true,
groundVisible: true,
});
});
test("corrupt persisted camera/transform data cannot enter the 3D renderer", () => {
const scene = readDirectorScene({
objects: [
{
id: "bad",
name: "bad",
kind: "box",
position: [Infinity, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
],
cameras: [],
activeCameraId: "missing",
ratio: "bad",
});
expect(scene.objects).toEqual([]);
expect(scene.cameras).toEqual(defaultDirectorScene().cameras);
expect(scene.activeCameraId).toBe("camera-1");
expect(directorImageSize(scene.ratio)).toEqual({ width: 1280, height: 720 });
});
test("portrait and square screenshot dimensions preserve selected composition", () => {
expect(directorImageSize("9:16")).toEqual({ width: 720, height: 1280 });
expect(directorImageSize("1:1")).toEqual({ width: 1280, height: 1280 });
expect(directorImageSize("3:4")).toEqual({ width: 960, height: 1280 });
});
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 buffer = new ArrayBuffer(20 + data.length);
const view = new DataView(buffer);
[0x46546c67, 2, buffer.byteLength, data.length, 0x4e4f534a].forEach((n, i) =>
view.setUint32(i * 4, n, true),
);
new Uint8Array(buffer, 20).set(data);
return buffer;
}
test("GLB upload requires an intact self-contained 2.0 model", () => {
const document = {
asset: { version: "2.0" },
meshes: [{}],
buffers: [{ byteLength: 8 }],
};
expect(validateDirectorGlb(glb(document))).toEqual(document);
expect(() => validateDirectorGlb(new ArrayBuffer(10))).toThrow();
expect(() =>
validateDirectorGlb(
glb({
...document,
images: [{ uri: "https://example.com/private-texture.png" }],
}),
),
).toThrow("嵌入");
const broken = glb(document);
new DataView(broken).setUint32(8, 25, true);
expect(() => validateDirectorGlb(broken)).toThrow("GLB 2.0");
});
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));
});
import {
defaultDirectorMotion,
readDirectorMotion,
sampleDirectorMotion,
resizeDirectorMotion,
presetDirectorMotion,
directorMotionPrompt,
validDirectorPose,
} from "@/lib/video-canvas/director-motion";
const camera = { position: [0, 2, 8], target: [0, 1, 0], fov: 50 } as const;
const pose = () =>
({
position: [...camera.position],
target: [...camera.target],
fov: 50,
}) as import("@/lib/video-canvas/director-motion").DirectorPose;
test("legacy scenes acquire an empty timeline without altering cameras", () => {
const { motion, ...legacy } = defaultDirectorScene();
expect(readDirectorScene(legacy).motion).toEqual(defaultDirectorMotion());
expect(readDirectorScene(legacy).cameras).toEqual(legacy.cameras);
});
test("motion survives serialization and duration changes preserve relative keyframe timing", () => {
const scene = defaultDirectorScene();
scene.motion = presetDirectorMotion(pose(), "orbit", 5);
scene.motion.loop = true;
expect(readDirectorScene(JSON.parse(JSON.stringify(scene)))).toEqual(scene);
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);
});
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, 2.5)?.position[2]).toBe(6);
const linear = sampleDirectorMotion(motion, 1)?.position[2] ?? 0;
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(defaultDirectorMotion(), 0)).toBeNull();
});
test("invalid, repeated, out of range or coincident keyframes are rejected", () => {
const m = presetDirectorMotion(pose(), "pull");
const first = m.keyframes[0];
m.keyframes.push(
{ ...first, id: "duplicate-time" },
{ ...first, id: "nan", time: NaN },
{ ...first, id: "out", time: 16 },
{ ...first, id: "broken", time: 2, position: [Infinity, 0, 1] },
{ ...first, id: "coincident", time: 3, position: [...first.target] },
);
const read = readDirectorMotion(m);
expect(read.keyframes).toHaveLength(2);
expect(read.keyframes[0].id).toBe(first.id);
});
test("all motion presets remain finite and differ from their initial camera", () => {
for (const kind of ["push", "pull", "orbit", "pan"] as const) {
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(directorMotionPrompt(m)).toContain("5 秒");
expect(directorMotionPrompt(m)).toContain("5.00秒");
}
});
test("look-at remains defined when opposing keyframes cross their target", () => {
const m = presetDirectorMotion(pose(), "push");
m.easing = "linear";
m.keyframes[0].position = [-1, 0, 0];
m.keyframes[0].target = [0, 0, 0];
m.keyframes[1].position = [1, 0, 0];
m.keyframes[1].target = [0, 0, 0];
expect(validDirectorPose(sampleDirectorMotion(m, 2.5)!)).toBe(true);
});
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",
"warmup-cancel",
"warmup-hidden",
] as const) {
const saved = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
saved.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value,
});
};
const doc = new EventTarget() as EventTarget & { hidden: boolean };
doc.hidden = false;
const callbacks = new Map<number, FrameRequestCallback>();
let id = 0,
stopped = 0;
class Recorder {
static isTypeSupported(type: string) {
return type === (outcome === "complete-webm" ? "video/webm;codecs=vp8" : "video/mp4");
}
state = "inactive";
mimeType: string;
constructor(_stream: unknown, options: { mimeType: string }) {
this.mimeType = options.mimeType;
}
onstop?: () => void;
ondataavailable?: (e: { data: Blob }) => void;
start() {
this.state = "recording";
}
stop() {
this.state = "inactive";
this.ondataavailable?.({ data: new Blob(["recorded"]) });
queueMicrotask(() => this.onstop?.());
}
}
install("document", doc);
install("MediaRecorder", Recorder);
install("requestAnimationFrame", (fn: FrameRequestCallback) => {
callbacks.set(++id, fn);
return id;
});
install("cancelAnimationFrame", (n: number) => callbacks.delete(n));
try {
const controller = new AbortController();
const rendered: number[] = [];
const canvas = {
captureStream: () => ({ getTracks: () => [{ stop: () => stopped++ }] }),
} as unknown as HTMLCanvasElement;
const result = recordDirectorVideo({
canvas,
duration: 2,
signal: controller.signal,
render: (t) => {
if (outcome === "render-error" && t > 0) throw new Error("render fault");
rendered.push(t);
},
});
const step = (time: number) => {
const pending = [...callbacks.values()];
callbacks.clear();
pending.forEach((fn) => fn(time));
};
const warming = outcome.startsWith("warmup-");
if (!warming) {
step(10);
step(20);
await Promise.resolve();
}
if (outcome === "complete" || outcome === "complete-webm") {
step(1000);
step(3000);
step(3033);
step(3066);
expect((await result).type).toBe(
outcome === "complete-webm" ? "video/webm;codecs=vp8" : "video/mp4",
);
expect(rendered.at(-1)).toBe(2);
} else {
const rejected = expect(result).rejects.toThrow();
if (outcome === "cancel" || outcome === "warmup-cancel") controller.abort();
else if (outcome === "hidden" || outcome === "warmup-hidden") {
doc.hidden = true;
doc.dispatchEvent(new Event("visibilitychange"));
} else {
step(1000);
step(3000);
}
await rejected;
}
expect(stopped).toBe(warming ? 0 : 1);
expect(callbacks.size).toBe(0);
} finally {
for (const [key, descriptor] of saved) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
}
});
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("平移");
const m = presetDirectorMotion(pose(), "orbit", 15);
m.keyframes = Array.from({ length: 100 }, (_, i) => ({
id: String(i),
time: (i / 99) * 15,
...sampleDirectorMotion(m, (i / 99) * 15)!,
}));
expect(directorMotionPrompt(m).length).toBeLessThan(1500);
});
import { mergeDirectorCameraPose } from "@/lib/video-canvas/director-motion";
test("editing a keyframe cannot replace the active camera identity", () => {
const c = defaultDirectorScene().cameras[0];
const key = presetDirectorMotion(pose(), "push").keyframes[1];
const edited = mergeDirectorCameraPose(c, key);
expect(edited.id).toBe(c.id);
expect(edited.name).toBe(c.name);
expect(edited.position).toEqual(key.position);
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 });
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();
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,
]);
});
import {
recordObjectFrames,
sampleDirectorObjects,
readObjectTracks,
} from "@/lib/video-canvas/director-object-tracks";
import {
parseReconstructedScene,
insertReconstructedScene,
} from "@/lib/video-canvas/director-reconstruction";
test("object tracks interpolate independently, persist, mute and preserve shortest rotation", () => {
let scene = defaultDirectorScene();
const id = scene.objects[0].id;
scene = recordObjectFrames(scene, [id], 0, () => "key-0");
scene.objects = scene.objects.map((o) =>
o.id === id
? {
...o,
position: [10, 2, 4],
rotation: [0, Math.PI * 2 - 0.2, 0],
scale: [2, 3, 4],
visible: false,
}
: o,
);
scene = recordObjectFrames(scene, [id], scene.motion.duration, () => "key-1");
const mid = sampleDirectorObjects(scene, scene.motion.duration / 2)[0];
expect(mid.position[0]).toBeCloseTo((scene.objectTracks![id].keyframes[0].position[0] + 10) / 2);
expect(mid.rotation[1]).toBeCloseTo(-0.1);
expect(mid.visible).toBe(true);
expect(sampleDirectorObjects(scene, scene.motion.duration)[0].visible).toBe(false);
const restored = readDirectorScene(JSON.parse(JSON.stringify(scene)));
expect(restored.objectTracks).toEqual(scene.objectTracks);
scene.objectTracks![id].muted = true;
expect(sampleDirectorObjects(scene, 0)[0]).toEqual(scene.objects[0]);
});
test("malformed/orphan object tracks are excluded and recording replaces exact frame", () => {
let scene = defaultDirectorScene();
const id = scene.objects[0].id;
scene = recordObjectFrames(scene, [id], 0, () => "one");
scene = recordObjectFrames(scene, [id], 0, () => "two");
expect(scene.objectTracks![id].keyframes).toHaveLength(1);
const valid = scene.objectTracks![id];
expect(
readObjectTracks(
{
ghost: valid,
[id]: {
...valid,
keyframes: [...valid.keyframes, { ...valid.keyframes[0], id: "bad", time: Infinity }],
},
},
scene.objects,
scene.motion.duration,
),
).toEqual({ [id]: valid });
expect(() => recordObjectFrames(scene, [id], -1, () => "bad")).toThrow();
});
test("reference reconstruction validates geometry and merges without clobbering original objects", () => {
const generated = parseReconstructedScene({
summary: "Approximate geometry",
objects: [
{
name: "table",
kind: "box",
position: [0, 0.5, 0],
rotation: [0, 0, 0],
scale: [2, 1, 1],
color: "#ffffff",
},
],
camera: { position: [3, 3, 3], target: [0, 0.5, 0], fov: 45 },
});
const scene = defaultDirectorScene();
let count = 0;
const insert = insertReconstructedScene(
scene,
generated.directorScene,
false,
() => `added-${count++}`,
);
expect(insert.objects.length).toBe(scene.objects.length + 1);
expect(insert.objects[0]).toEqual(scene.objects[0]);
expect(new Set(insert.objects.map((o) => o.id)).size).toBe(insert.objects.length);
const replaced = insertReconstructedScene(
scene,
generated.directorScene,
true,
() => `new-${count++}`,
);
expect(replaced.objects).toHaveLength(1);
expect(replaced.objectTracks).toEqual({});
expect(() =>
parseReconstructedScene({
summary: "bad",
objects: [],
camera: { position: [0, 0, 0], target: [0, 0, 0], fov: 45 },
}),
).toThrow();
});
test("global duration rescales object tracks and deleting an object deletes its track", () => {
let scene = defaultDirectorScene();
const id = scene.objects[0].id;
scene = recordObjectFrames(scene, [id], scene.motion.duration, () => "last");
const old = scene.motion.duration;
const changed = commitDirectorScene(scene, {
...scene,
motion: { ...scene.motion, duration: old * 2 },
});
expect(changed.objectTracks![id].keyframes[0].time).toBe(old * 2);
expect(commitDirectorScene(changed, { ...changed, objects: [] }).objectTracks).toEqual({});
});