541 lines
20 KiB
TypeScript
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({});
|
|
});
|