130 行
4.8 KiB
TypeScript
130 行
4.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
ACTION_WINDOW_FRAMES,
|
|
AVATAR_PRESETS,
|
|
createStableActionState,
|
|
getAvatarAnchors,
|
|
getAvatarPreset,
|
|
resolveAvatarKeyFromPrompt,
|
|
stabilizeActionStream,
|
|
type FrameActionSample,
|
|
} from "./liveCamera";
|
|
|
|
function feedSamples(samples: Array<Omit<FrameActionSample, "timestamp">>, intervalMs = 33) {
|
|
const history: FrameActionSample[] = [];
|
|
const state = createStableActionState();
|
|
let lastResult = null as ReturnType<typeof stabilizeActionStream> | null;
|
|
|
|
samples.forEach((sample, index) => {
|
|
lastResult = stabilizeActionStream(
|
|
{
|
|
...sample,
|
|
timestamp: index * intervalMs,
|
|
},
|
|
history,
|
|
state,
|
|
);
|
|
});
|
|
|
|
return { history, state, lastResult };
|
|
}
|
|
|
|
describe("live camera action stabilizer", () => {
|
|
it("locks a dominant action after a full temporal window", () => {
|
|
const samples = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
|
|
action: "forehand" as const,
|
|
confidence: 0.84,
|
|
}));
|
|
const { lastResult } = feedSamples(samples);
|
|
|
|
expect(lastResult?.stableAction).toBe("forehand");
|
|
expect(lastResult?.windowAction).toBe("forehand");
|
|
expect(lastResult?.pending).toBe(false);
|
|
expect(lastResult?.windowShare).toBeGreaterThan(0.9);
|
|
});
|
|
|
|
it("ignores brief action spikes and keeps the stable action", () => {
|
|
const stableFrames = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
|
|
action: "forehand" as const,
|
|
confidence: 0.82,
|
|
}));
|
|
const noisyFrames = Array.from({ length: 5 }, () => ({
|
|
action: "backhand" as const,
|
|
confidence: 0.88,
|
|
}));
|
|
const { lastResult } = feedSamples([...stableFrames, ...noisyFrames]);
|
|
|
|
expect(lastResult?.stableAction).toBe("forehand");
|
|
expect(lastResult?.pending).toBe(false);
|
|
});
|
|
|
|
it("switches only after the next action persists long enough", () => {
|
|
const forehandFrames = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
|
|
action: "forehand" as const,
|
|
confidence: 0.8,
|
|
}));
|
|
const backhandFrames = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
|
|
action: "backhand" as const,
|
|
confidence: 0.85,
|
|
}));
|
|
const { lastResult, state } = feedSamples([...forehandFrames, ...backhandFrames]);
|
|
|
|
expect(lastResult?.stableAction).toBe("backhand");
|
|
expect(state.switchCount).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it("requires a longer delay before falling back to unknown", () => {
|
|
const forehandFrames = Array.from({ length: ACTION_WINDOW_FRAMES * 2 }, () => ({
|
|
action: "forehand" as const,
|
|
confidence: 0.83,
|
|
}));
|
|
const unknownFrames = Array.from({ length: 10 }, () => ({
|
|
action: "unknown" as const,
|
|
confidence: 0.4,
|
|
}));
|
|
const { lastResult } = feedSamples([...forehandFrames, ...unknownFrames]);
|
|
|
|
expect(lastResult?.stableAction).toBe("forehand");
|
|
});
|
|
});
|
|
|
|
describe("live camera avatar helpers", () => {
|
|
it("maps prompt keywords into avatar presets", () => {
|
|
expect(resolveAvatarKeyFromPrompt("切换成猩猩形象", "gorilla")).toBe("gorilla");
|
|
expect(resolveAvatarKeyFromPrompt("dog mascot", "gorilla")).toBe("dog");
|
|
expect(resolveAvatarKeyFromPrompt("狐狸风格", "gorilla")).toBe("fox");
|
|
expect(resolveAvatarKeyFromPrompt("兔子教练", "gorilla")).toBe("rabbit");
|
|
expect(resolveAvatarKeyFromPrompt("BeachKing 3D 替身", "gorilla")).toBe("beachKing");
|
|
expect(resolveAvatarKeyFromPrompt("Juanita avatar", "gorilla")).toBe("juanita3d");
|
|
expect(resolveAvatarKeyFromPrompt("", "pig")).toBe("pig");
|
|
});
|
|
|
|
it("exposes full-body 3d avatar examples with CC0 metadata", () => {
|
|
const presets = AVATAR_PRESETS.filter((preset) => preset.category === "full-body-3d");
|
|
|
|
expect(presets).toHaveLength(4);
|
|
expect(presets.every((preset) => preset.license === "CC0")).toBe(true);
|
|
expect(getAvatarPreset("sportTv")?.modelUrl).toContain("arweave.net");
|
|
});
|
|
|
|
it("builds avatar anchors from pose landmarks", () => {
|
|
const landmarks = Array.from({ length: 33 }, () => ({ x: 0.5, y: 0.5, visibility: 0.95 }));
|
|
landmarks[0] = { x: 0.5, y: 0.16, visibility: 0.99 };
|
|
landmarks[11] = { x: 0.4, y: 0.3, visibility: 0.99 };
|
|
landmarks[12] = { x: 0.6, y: 0.3, visibility: 0.99 };
|
|
landmarks[15] = { x: 0.28, y: 0.44, visibility: 0.99 };
|
|
landmarks[16] = { x: 0.72, y: 0.44, visibility: 0.99 };
|
|
landmarks[23] = { x: 0.44, y: 0.58, visibility: 0.99 };
|
|
landmarks[24] = { x: 0.56, y: 0.58, visibility: 0.99 };
|
|
landmarks[27] = { x: 0.43, y: 0.92, visibility: 0.99 };
|
|
landmarks[28] = { x: 0.57, y: 0.92, visibility: 0.99 };
|
|
|
|
const anchors = getAvatarAnchors(landmarks, 1280, 720);
|
|
|
|
expect(anchors).not.toBeNull();
|
|
expect(anchors?.headRadius).toBeGreaterThan(30);
|
|
expect(anchors?.bodyHeight).toBeGreaterThan(120);
|
|
expect(anchors?.rightHandX).toBeGreaterThan(anchors?.leftHandX || 0);
|
|
});
|
|
});
|