fix(canvas): 3D 导演台 - 完成 Issue #305 P0 稳定性治理 (#324)

* fix(canvas): 3D 导演台 - 修复状态回弹与保存恢复生命周期

* style(canvas): 3D 导演台 - 对齐 CI 格式门禁
This commit is contained in:
echoD886 authored and GitHub committed 2026-08-27 10:32:45 +08:00
1 parent 2a28ababf1
commit c2766544d7
42 files changed
+8124 -188

No files matched your search

@@ -0,0 +1,164 @@
import { describe, expect, test } from "bun:test";
import { Euler, Vector3 } from "three";
import { applyDirectorTransformDelta, directorTransformDelta, resolveDirectorKeyframeRecord, resolveDirectorObjectTransformEdit, snapDirectorTime } from "../src/lib/canvas/director/director-animation-semantics";
import { interpolateDirectorTransform } from "../src/lib/canvas/director/director-scene";
import type { DirectorKeyframe, DirectorTransform } from "../src/types/director";
function transform(position: [number, number, number], rotation: [number, number, number] = [0, 0, 0], scale: [number, number, number] = [1, 1, 1]): DirectorTransform {
return { position, rotation, scale };
}
function keyframe(id: string, time: number, value: DirectorTransform): DirectorKeyframe {
return { id, time, transform: value };
}
describe("snapDirectorTime", () => {
test("按帧率吸附并夹到非负", () => {
expect(snapDirectorTime(1.031, 24)).toBeCloseTo(1.0416666, 6);
expect(snapDirectorTime(-0.4, 24)).toBe(0);
expect(snapDirectorTime(1.031, 0)).toBeCloseTo(1.0416666, 6);
});
});
describe("resolveDirectorObjectTransformEdit:Auto Key 打开", () => {
test("只在吸附播放头写单个关键帧,base 与其他关键帧不变", () => {
const base = transform([0, 0, 0]);
const keyframes = [keyframe("k0", 0, transform([0, 0, 0])), keyframe("k2", 2, transform([4, 0, 0]))];
const edit = resolveDirectorObjectTransformEdit({ base, keyframes, rendered: transform([2, 0, 0]), edited: transform([2, 5, 0]), autoKey: true, time: 1 });
expect(edit.transform).toEqual(base);
expect(edit.keyframes).toHaveLength(3);
expect(edit.keyframes.find((item) => item.time === 1)?.transform.position).toEqual([2, 5, 0]);
expect(edit.keyframes.find((item) => item.id === "k0")?.transform.position).toEqual([0, 0, 0]);
expect(edit.keyframes.find((item) => item.id === "k2")?.transform.position).toEqual([4, 0, 0]);
});
test("同一时间重复编辑复用同一关键帧而不是新增", () => {
const edit = resolveDirectorObjectTransformEdit({ base: transform([0, 0, 0]), keyframes: [keyframe("k1", 1, transform([1, 0, 0]))], rendered: transform([1, 0, 0]), edited: transform([9, 0, 0]), autoKey: true, time: 1 });
expect(edit.keyframes).toHaveLength(1);
expect(edit.keyframes[0].transform.position).toEqual([9, 0, 0]);
});
});
describe("resolveDirectorObjectTransformEdit:Auto Key 关闭", () => {
test("无关键帧时直接改写 base,保留用户输入的精确数值", () => {
const edited = transform([1.5, 2.25, -3], [0.1, 0.2, 0.3], [2, 2, 2]);
const edit = resolveDirectorObjectTransformEdit({ base: transform([0, 0, 0]), keyframes: [], rendered: transform([0, 0, 0]), edited, autoKey: false, time: 0.5 });
expect(edit.transform).toEqual(edited);
expect(edit.keyframes).toEqual([]);
});
test("有关键帧时把增量整体搬到 base 和所有关键帧,编辑立即可见", () => {
const base = transform([0, 0, 0]);
const keyframes = [keyframe("k0", 0, transform([0, 0, 0])), keyframe("k2", 2, transform([4, 0, 0]))];
const rendered = interpolateDirectorTransform(base, keyframes, 1);
expect(rendered.position).toEqual([2, 0, 0]);
const edit = resolveDirectorObjectTransformEdit({ base, keyframes, rendered, edited: transform([2, 3, 0]), autoKey: false, time: 1 });
expect(edit.keyframes).toHaveLength(2);
expect(edit.transform.position).toEqual([0, 3, 0]);
expect(edit.keyframes[0].transform.position).toEqual([0, 3, 0]);
expect(edit.keyframes[1].transform.position).toEqual([4, 3, 0]);
// 编辑后在同一时间点的渲染值等于用户所见的编辑值。
expect(interpolateDirectorTransform(edit.transform, edit.keyframes, 1).position).toEqual([2, 3, 0]);
});
});
describe("未被编辑场景的兼容性", () => {
test("单关键帧场景在编辑前后都由该关键帧决定渲染", () => {
const base = transform([0, 0, 0]);
const keyframes = [keyframe("only", 1, transform([7, 0, 0]))];
expect(interpolateDirectorTransform(base, keyframes, 0).position).toEqual([7, 0, 0]);
expect(interpolateDirectorTransform(base, keyframes, 5).position).toEqual([7, 0, 0]);
const edit = resolveDirectorObjectTransformEdit({ base, keyframes, rendered: transform([7, 0, 0]), edited: transform([7, 2, 0]), autoKey: false, time: 3 });
expect(edit.keyframes).toHaveLength(1);
expect(interpolateDirectorTransform(edit.transform, edit.keyframes, 3).position).toEqual([7, 2, 0]);
});
test("零增量编辑不改变任何已有关键帧的渲染结果", () => {
const base = transform([1, 1, 1], [0, 0.3, 0], [2, 2, 2]);
const keyframes = [keyframe("a", 0, transform([0, 0, 0])), keyframe("b", 4, transform([8, 2, 0]))];
const rendered = interpolateDirectorTransform(base, keyframes, 2);
const edit = resolveDirectorObjectTransformEdit({ base, keyframes, rendered, edited: rendered, autoKey: false, time: 2 });
[0, 1, 2, 3, 4].forEach((time) => {
const before = interpolateDirectorTransform(base, keyframes, time);
const after = interpolateDirectorTransform(edit.transform, edit.keyframes, time);
after.position.forEach((value, index) => expect(value).toBeCloseTo(before.position[index], 10));
after.scale.forEach((value, index) => expect(value).toBeCloseTo(before.scale[index], 10));
});
});
});
describe("缩放增量的零值与比例行为", () => {
test("源缩放为 0 时用绝对偏移,不产生 Infinity 或 NaN", () => {
const delta = directorTransformDelta(transform([0, 0, 0], [0, 0, 0], [0, 0, 0]), transform([0, 0, 0], [0, 0, 0], [3, 0, 1.5]));
expect(delta.scaleRatio).toEqual([1, 1, 1]);
expect(delta.scaleOffset).toEqual([3, 0, 1.5]);
const applied = applyDirectorTransformDelta(transform([0, 0, 0], [0, 0, 0], [0, 0, 0]), delta);
applied.scale.forEach((value) => expect(Number.isFinite(value)).toBe(true));
expect(applied.scale).toEqual([3, 0, 1.5]);
});
test("非零缩放走比例,等比套用到其他关键帧", () => {
const delta = directorTransformDelta(transform([0, 0, 0], [0, 0, 0], [2, 2, 2]), transform([0, 0, 0], [0, 0, 0], [3, 3, 3]));
expect(delta.scaleRatio).toEqual([1.5, 1.5, 1.5]);
expect(applyDirectorTransformDelta(transform([0, 0, 0], [0, 0, 0], [4, 4, 4]), delta).scale).toEqual([6, 6, 6]);
});
test("旋转增量在整条路径上保持确定性(按朝向而非欧拉三元组断言)", () => {
const delta = directorTransformDelta(transform([0, 0, 0], [0, 0, 0]), transform([0, 0, 0], [0, Math.PI / 2, 0]));
const applied = applyDirectorTransformDelta(transform([0, 0, 0], [0, 0, 0]), delta);
expect(applied.rotation[1]).toBeCloseTo(Math.PI / 2, 6);
// 连续两次同一增量等价于 yaw π;欧拉 XYZ 的规范分支会写成 (π, 0, π),因此比较朝向。
const twice = applyDirectorTransformDelta(applied, delta);
const forward = new Vector3(0, 0, 1).applyEuler(new Euler(...twice.rotation));
expect(forward.x).toBeCloseTo(0, 6);
expect(forward.z).toBeCloseTo(-1, 6);
});
});
describe("raw playhead 与 snapped 目的时间的分工(#2 回归)", () => {
// 24fps 下 1.02s 落在两个帧格之间:snapped = 1.0,raw = 1.02。
const fps = 24;
const rawTime = 1.02;
const snappedTime = snapDirectorTime(rawTime, fps);
const base = transform([0, 0, 0]);
const keyframes = [keyframe("k0", 0, transform([0, 0, 0])), keyframe("k2", 2, transform([20, 0, 0]))];
test("前置条件:raw 与 snapped 真的解算出不同的值", () => {
expect(snappedTime).toBeCloseTo(1, 6);
expect(interpolateDirectorTransform(base, keyframes, rawTime).position[0]).toBeCloseTo(10.2, 6);
expect(interpolateDirectorTransform(base, keyframes, snappedTime).position[0]).toBeCloseTo(10, 6);
});
test("记录关键帧:取值用 raw,写入时间用 snapped", () => {
const record = resolveDirectorKeyframeRecord({ base, keyframes, rawTime, snappedTime });
expect(record.time).toBeCloseTo(1, 6);
// 取错时间会写成 10(rendered-at-snapped),这里必须是 10.2(rendered-at-raw)。
expect(record.transform.position[0]).toBeCloseTo(10.2, 6);
const written = record.keyframes.find((item) => Math.abs(item.time - snappedTime) < 0.001);
expect(written?.transform.position[0]).toBeCloseTo(10.2, 6);
});
test("AutoKey OFF 的增量起点必须是 rendered-at-raw,否则编辑后在 raw 处漂移", () => {
const edited = transform([10.2, 4, 0]);
const fromRaw = interpolateDirectorTransform(base, keyframes, rawTime);
const correct = resolveDirectorObjectTransformEdit({ base, keyframes, rendered: fromRaw, edited, autoKey: false, time: snappedTime });
const afterCorrect = interpolateDirectorTransform(correct.transform, correct.keyframes, rawTime);
afterCorrect.position.forEach((value, index) => expect(value).toBeCloseTo(edited.position[index], 6));
// 用 snapped 求起点是原缺陷:编辑后在 raw 处不等于用户所见的编辑值。
const fromSnapped = interpolateDirectorTransform(base, keyframes, snappedTime);
const drifted = resolveDirectorObjectTransformEdit({ base, keyframes, rendered: fromSnapped, edited, autoKey: false, time: snappedTime });
const afterDrift = interpolateDirectorTransform(drifted.transform, drifted.keyframes, rawTime);
expect(Math.abs(afterDrift.position[0] - edited.position[0])).toBeGreaterThan(0.1);
});
test("AutoKey ON 在 raw 与 snapped 之间时只写一个吸附帧", () => {
const rendered = interpolateDirectorTransform(base, keyframes, rawTime);
const edit = resolveDirectorObjectTransformEdit({ base, keyframes, rendered, edited: transform([10.2, 7, 0]), autoKey: true, time: snappedTime });
expect(edit.keyframes).toHaveLength(3);
const written = edit.keyframes.find((item) => Math.abs(item.time - snappedTime) < 0.001);
expect(written?.transform.position[1]).toBeCloseTo(7, 6);
expect(edit.keyframes.some((item) => Math.abs(item.time - rawTime) < 0.001)).toBe(false);
});
});