211 lines
10 KiB
TypeScript
211 lines
10 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
||
|
||
import {
|
||
PREVIS_KEYFRAME_EPSILON,
|
||
createPrevisActor,
|
||
createPrevisCamera,
|
||
createPrevisScene,
|
||
interpolatePrevisTransform,
|
||
removePrevisBoneKeyframe,
|
||
removePrevisKeyframe,
|
||
removePrevisSceneKeyframe,
|
||
resolvePrevisKeyframeProgress,
|
||
setPrevisSceneKeyframeEasing,
|
||
upsertPrevisBoneKeyframe,
|
||
upsertPrevisKeyframe,
|
||
} from "../src/lib/canvas/previs/previs-scene";
|
||
import type { PrevisKeyframe, PrevisQuat, PrevisScene, PrevisTransform } from "../src/types/previs";
|
||
|
||
const transformAt = (x: number): PrevisTransform => ({ position: [x, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] });
|
||
|
||
function seededKeyframes(): PrevisKeyframe[] {
|
||
let keys: PrevisKeyframe[] = [];
|
||
keys = upsertPrevisKeyframe(keys, 0, transformAt(0));
|
||
keys = upsertPrevisKeyframe(keys, 1, transformAt(10));
|
||
keys = upsertPrevisKeyframe(keys, 2, transformAt(20));
|
||
return keys;
|
||
}
|
||
|
||
describe("删除 transform 关键帧", () => {
|
||
test("按 id 删除只移除目标帧,其余保持有序", () => {
|
||
const keys = seededKeyframes();
|
||
const next = removePrevisKeyframe(keys, keys[1].id);
|
||
|
||
expect(next).toHaveLength(2);
|
||
expect(next.map((k) => k.time)).toEqual([0, 2]);
|
||
// 纯函数:不得改写入参。
|
||
expect(keys).toHaveLength(3);
|
||
});
|
||
|
||
test("id 不存在时返回原引用,避免无意义重渲染", () => {
|
||
const keys = seededKeyframes();
|
||
expect(removePrevisKeyframe(keys, "missing")).toBe(keys);
|
||
});
|
||
|
||
test("删空后得到空数组,插值回落到 base", () => {
|
||
let keys = upsertPrevisKeyframe([], 1, transformAt(5));
|
||
keys = removePrevisKeyframe(keys, keys[0].id);
|
||
expect(keys).toEqual([]);
|
||
expect(interpolatePrevisTransform(transformAt(99), keys, 1).position[0]).toBe(99);
|
||
});
|
||
|
||
test("upsert 使用同一容差,记录下的帧一定能按 id 删除", () => {
|
||
// 非整帧时间点最容易出现「记录能覆盖但删不掉」,这里锁住判据一致。
|
||
let keys = upsertPrevisKeyframe([], 0.75, transformAt(3));
|
||
keys = upsertPrevisKeyframe(keys, 0.75 + PREVIS_KEYFRAME_EPSILON / 2, transformAt(4));
|
||
expect(keys).toHaveLength(1);
|
||
expect(removePrevisKeyframe(keys, keys[0].id)).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe("关键帧缓动", () => {
|
||
test("linear / step / smooth 产生不同且确定的区间进度", () => {
|
||
expect(resolvePrevisKeyframeProgress(0.25, "linear")).toBe(0.25);
|
||
expect(resolvePrevisKeyframeProgress(0.25, "step")).toBe(0);
|
||
expect(resolvePrevisKeyframeProgress(0.25, "smooth")).toBeCloseTo(0.15625, 8);
|
||
expect(resolvePrevisKeyframeProgress(-1, "linear")).toBe(0);
|
||
expect(resolvePrevisKeyframeProgress(2, "linear")).toBe(1);
|
||
});
|
||
|
||
test("插值读取前一枚关键帧的 easing,旧数据默认线性", () => {
|
||
const base = seededKeyframes().slice(0, 2);
|
||
expect(interpolatePrevisTransform(transformAt(0), base, 0.25).position[0]).toBeCloseTo(2.5, 8);
|
||
expect(interpolatePrevisTransform(transformAt(0), [{ ...base[0], easing: "step" }, base[1]], 0.25).position[0]).toBe(0);
|
||
expect(interpolatePrevisTransform(transformAt(0), [{ ...base[0], easing: "smooth" }, base[1]], 0.25).position[0]).toBeCloseTo(1.5625, 8);
|
||
expect(interpolatePrevisTransform(transformAt(0), [{ ...base[0], easing: "step" }, base[1]], 1).position[0]).toBe(10);
|
||
});
|
||
});
|
||
|
||
describe("删除骨骼关键帧", () => {
|
||
const rotation: PrevisQuat = [0, 0, 0, 1];
|
||
|
||
test("删除后轨道保留其余帧", () => {
|
||
let tracks = upsertPrevisBoneKeyframe([], "hips", 0, rotation);
|
||
tracks = upsertPrevisBoneKeyframe(tracks, "hips", 1, rotation);
|
||
const target = tracks[0].keyframes[0].id;
|
||
|
||
const next = removePrevisBoneKeyframe(tracks, "hips", target);
|
||
expect(next).toHaveLength(1);
|
||
expect(next[0].keyframes).toHaveLength(1);
|
||
expect(next[0].keyframes[0].time).toBe(1);
|
||
});
|
||
|
||
test("轨道被删空后整条移除,不留空子轨道", () => {
|
||
const tracks = upsertPrevisBoneKeyframe([], "head", 0, rotation);
|
||
const next = removePrevisBoneKeyframe(tracks, "head", tracks[0].keyframes[0].id);
|
||
expect(next).toEqual([]);
|
||
});
|
||
|
||
test("骨骼或 id 不存在时返回原引用", () => {
|
||
const tracks = upsertPrevisBoneKeyframe([], "head", 0, rotation);
|
||
expect(removePrevisBoneKeyframe(tracks, "hips", tracks[0].keyframes[0].id)).toBe(tracks);
|
||
expect(removePrevisBoneKeyframe(tracks, "head", "missing")).toBe(tracks);
|
||
});
|
||
|
||
test("只影响目标骨骼,其他轨道不动", () => {
|
||
let tracks = upsertPrevisBoneKeyframe([], "hips", 0, rotation);
|
||
tracks = upsertPrevisBoneKeyframe(tracks, "head", 0, rotation);
|
||
const next = removePrevisBoneKeyframe(tracks, "hips", tracks[0].keyframes[0].id);
|
||
expect(next.map((t) => t.bone)).toEqual(["head"]);
|
||
});
|
||
});
|
||
|
||
describe("removePrevisSceneKeyframe:时间轴删除的唯一分派入口", () => {
|
||
const rotation: PrevisQuat = [0, 0, 0, 1];
|
||
|
||
/** 一个带对象 transform 帧、骨骼帧和摄影机帧的完整场景。 */
|
||
function seededScene() {
|
||
const base = createPrevisScene("删除测试");
|
||
const actor = {
|
||
...createPrevisActor("演员 A"),
|
||
keyframes: upsertPrevisKeyframe(upsertPrevisKeyframe([], 0, transformAt(0)), 1, transformAt(10)),
|
||
boneTracks: upsertPrevisBoneKeyframe(upsertPrevisBoneKeyframe([], "hips", 0, rotation), "head", 0.5, rotation),
|
||
};
|
||
const camera = { ...createPrevisCamera("摄影机 A"), keyframes: upsertPrevisKeyframe([], 2, transformAt(5)) };
|
||
const scene: PrevisScene = { ...base, objects: [actor], cameras: [camera] };
|
||
return { scene, actor, camera };
|
||
}
|
||
|
||
test("删除对象 transform 帧,只动目标对象", () => {
|
||
const { scene, actor } = seededScene();
|
||
const next = removePrevisSceneKeyframe(scene, { track: "object-transform", objectId: actor.id, keyframeId: actor.keyframes[0].id });
|
||
|
||
expect(next).not.toBe(scene);
|
||
expect(next.objects[0].keyframes.map((key) => key.time)).toEqual([1]);
|
||
// 骨骼轨道和摄影机不受影响,保持原引用。
|
||
expect(next.objects[0].boneTracks).toBe(actor.boneTracks);
|
||
expect(next.cameras).toBe(scene.cameras);
|
||
});
|
||
|
||
test("删除骨骼帧,删空的子轨道整条移除", () => {
|
||
const { scene, actor } = seededScene();
|
||
const hips = actor.boneTracks.find((track) => track.bone === "hips");
|
||
const next = removePrevisSceneKeyframe(scene, { track: "object-bone", objectId: actor.id, bone: "hips", keyframeId: hips!.keyframes[0].id });
|
||
|
||
expect(next.objects[0].boneTracks?.map((track) => track.bone)).toEqual(["head"]);
|
||
expect(next.objects[0].keyframes).toBe(actor.keyframes);
|
||
});
|
||
|
||
test("删除摄影机帧,只动目标摄影机", () => {
|
||
const { scene, camera } = seededScene();
|
||
const next = removePrevisSceneKeyframe(scene, { track: "camera", cameraId: camera.id, keyframeId: camera.keyframes[0].id });
|
||
|
||
expect(next.cameras[0].keyframes).toEqual([]);
|
||
expect(next.objects).toBe(scene.objects);
|
||
});
|
||
|
||
test("未命中一律返回同一 scene 引用:调用方据此跳过历史与保存", () => {
|
||
const { scene, actor, camera } = seededScene();
|
||
// 对象存在但帧 id 不存在。
|
||
expect(removePrevisSceneKeyframe(scene, { track: "object-transform", objectId: actor.id, keyframeId: "missing" })).toBe(scene);
|
||
// 对象本身不存在。
|
||
expect(removePrevisSceneKeyframe(scene, { track: "object-transform", objectId: "missing", keyframeId: actor.keyframes[0].id })).toBe(scene);
|
||
// 骨骼轨道不存在。
|
||
expect(removePrevisSceneKeyframe(scene, { track: "object-bone", objectId: actor.id, bone: "leftHand", keyframeId: "missing" })).toBe(scene);
|
||
// 摄影机不存在。
|
||
expect(removePrevisSceneKeyframe(scene, { track: "camera", cameraId: "missing", keyframeId: camera.keyframes[0].id })).toBe(scene);
|
||
});
|
||
|
||
test("对象没有 boneTracks 时删除骨骼帧不虚构空数组", () => {
|
||
const base = createPrevisScene("无骨骼");
|
||
const object = { ...createPrevisActor("演员 B"), boneTracks: undefined };
|
||
const scene: PrevisScene = { ...base, objects: [object] };
|
||
expect(removePrevisSceneKeyframe(scene, { track: "object-bone", objectId: object.id, bone: "hips", keyframeId: "any" })).toBe(scene);
|
||
});
|
||
|
||
test("不改写入参:原 scene 与原数组保持完整", () => {
|
||
const { scene, actor } = seededScene();
|
||
removePrevisSceneKeyframe(scene, { track: "object-transform", objectId: actor.id, keyframeId: actor.keyframes[0].id });
|
||
expect(scene.objects[0].keyframes).toHaveLength(2);
|
||
});
|
||
});
|
||
|
||
describe("setPrevisSceneKeyframeEasing:按轨道更新且保持纯函数", () => {
|
||
test("对象、骨骼和摄影机三类目标都能独立更新", () => {
|
||
const base = createPrevisScene("缓动测试");
|
||
const actor = {
|
||
...createPrevisActor("演员"),
|
||
keyframes: upsertPrevisKeyframe([], 0, transformAt(0)),
|
||
boneTracks: upsertPrevisBoneKeyframe([], "hips", 0, [0, 0, 0, 1]),
|
||
};
|
||
const camera = { ...createPrevisCamera("摄影机"), keyframes: upsertPrevisKeyframe([], 0, transformAt(1)) };
|
||
const scene: PrevisScene = { ...base, objects: [actor], cameras: [camera] };
|
||
|
||
const objectNext = setPrevisSceneKeyframeEasing(scene, { track: "object-transform", objectId: actor.id, keyframeId: actor.keyframes[0].id }, "smooth");
|
||
expect(objectNext.objects[0].keyframes[0].easing).toBe("smooth");
|
||
expect(objectNext.cameras).toBe(scene.cameras);
|
||
|
||
const boneNext = setPrevisSceneKeyframeEasing(scene, { track: "object-bone", objectId: actor.id, bone: "hips", keyframeId: actor.boneTracks[0].keyframes[0].id }, "step");
|
||
expect(boneNext.objects[0].boneTracks?.[0].keyframes[0].easing).toBe("step");
|
||
|
||
const cameraNext = setPrevisSceneKeyframeEasing(scene, { track: "camera", cameraId: camera.id, keyframeId: camera.keyframes[0].id }, "linear");
|
||
expect(cameraNext.cameras[0].keyframes[0].easing).toBe("linear");
|
||
expect(scene.objects[0].keyframes[0].easing).toBeUndefined();
|
||
});
|
||
|
||
test("目标不存在时返回原 scene 引用", () => {
|
||
const scene = createPrevisScene("missing");
|
||
expect(setPrevisSceneKeyframeEasing(scene, { track: "camera", cameraId: "missing", keyframeId: "missing" }, "smooth")).toBe(scene);
|
||
});
|
||
});
|