Add 10 free animal live camera avatars
这个提交包含在:
@@ -90,6 +90,8 @@ 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("", "pig")).toBe("pig");
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,17 @@ export type PosePoint = {
|
||||
visibility?: number;
|
||||
};
|
||||
|
||||
export type AvatarKey = "gorilla" | "monkey" | "pig" | "dog";
|
||||
export type AvatarKey =
|
||||
| "gorilla"
|
||||
| "monkey"
|
||||
| "dog"
|
||||
| "pig"
|
||||
| "cat"
|
||||
| "fox"
|
||||
| "panda"
|
||||
| "lion"
|
||||
| "tiger"
|
||||
| "rabbit";
|
||||
|
||||
export type AvatarRenderState = {
|
||||
enabled: boolean;
|
||||
@@ -69,6 +79,13 @@ type AvatarAnchors = {
|
||||
rightHandY: number;
|
||||
};
|
||||
|
||||
type AvatarVisualSpec = {
|
||||
src: string;
|
||||
bodyFill: string;
|
||||
limbStroke: string;
|
||||
glow: string;
|
||||
};
|
||||
|
||||
const ACTIONS: LiveActionType[] = ["forehand", "backhand", "serve", "volley", "overhead", "slice", "lob", "unknown"];
|
||||
|
||||
export const ACTION_WINDOW_FRAMES = 24;
|
||||
@@ -82,10 +99,81 @@ const ACTION_SWITCH_DELTA = 0.12;
|
||||
export const AVATAR_PRESETS: Array<{ key: AvatarKey; label: string; keywords: string[] }> = [
|
||||
{ key: "gorilla", label: "猩猩", keywords: ["gorilla", "ape", "猩猩", "猩", "大猩猩"] },
|
||||
{ key: "monkey", label: "猴子", keywords: ["monkey", "ape", "猴", "猴子"] },
|
||||
{ key: "pig", label: "猪", keywords: ["pig", "猪", "小猪"] },
|
||||
{ key: "dog", label: "狗", keywords: ["dog", "puppy", "犬", "狗", "小狗"] },
|
||||
{ key: "pig", label: "猪", keywords: ["pig", "猪", "小猪"] },
|
||||
{ key: "cat", label: "猫", keywords: ["cat", "kitty", "猫", "小猫"] },
|
||||
{ key: "fox", label: "狐狸", keywords: ["fox", "狐狸"] },
|
||||
{ key: "panda", label: "熊猫", keywords: ["panda", "熊猫"] },
|
||||
{ key: "lion", label: "狮子", keywords: ["lion", "狮子"] },
|
||||
{ key: "tiger", label: "老虎", keywords: ["tiger", "虎", "老虎"] },
|
||||
{ key: "rabbit", label: "兔子", keywords: ["rabbit", "bunny", "兔", "兔子"] },
|
||||
];
|
||||
|
||||
const AVATAR_VISUALS: Record<AvatarKey, AvatarVisualSpec> = {
|
||||
gorilla: {
|
||||
src: "/avatars/twemoji/gorilla.svg",
|
||||
bodyFill: "rgba(39,39,42,0.95)",
|
||||
limbStroke: "rgba(63,63,70,0.92)",
|
||||
glow: "rgba(161,161,170,0.32)",
|
||||
},
|
||||
monkey: {
|
||||
src: "/avatars/twemoji/monkey.svg",
|
||||
bodyFill: "rgba(120,53,15,0.95)",
|
||||
limbStroke: "rgba(146,64,14,0.9)",
|
||||
glow: "rgba(180,83,9,0.3)",
|
||||
},
|
||||
dog: {
|
||||
src: "/avatars/twemoji/dog.svg",
|
||||
bodyFill: "rgba(180,83,9,0.93)",
|
||||
limbStroke: "rgba(180,83,9,0.88)",
|
||||
glow: "rgba(217,119,6,0.26)",
|
||||
},
|
||||
pig: {
|
||||
src: "/avatars/twemoji/pig.svg",
|
||||
bodyFill: "rgba(244,114,182,0.92)",
|
||||
limbStroke: "rgba(244,114,182,0.86)",
|
||||
glow: "rgba(244,114,182,0.28)",
|
||||
},
|
||||
cat: {
|
||||
src: "/avatars/twemoji/cat.svg",
|
||||
bodyFill: "rgba(245,158,11,0.92)",
|
||||
limbStroke: "rgba(217,119,6,0.88)",
|
||||
glow: "rgba(251,191,36,0.28)",
|
||||
},
|
||||
fox: {
|
||||
src: "/avatars/twemoji/fox.svg",
|
||||
bodyFill: "rgba(234,88,12,0.93)",
|
||||
limbStroke: "rgba(194,65,12,0.9)",
|
||||
glow: "rgba(251,146,60,0.3)",
|
||||
},
|
||||
panda: {
|
||||
src: "/avatars/twemoji/panda.svg",
|
||||
bodyFill: "rgba(82,82,91,0.92)",
|
||||
limbStroke: "rgba(39,39,42,0.9)",
|
||||
glow: "rgba(228,228,231,0.28)",
|
||||
},
|
||||
lion: {
|
||||
src: "/avatars/twemoji/lion.svg",
|
||||
bodyFill: "rgba(202,138,4,0.92)",
|
||||
limbStroke: "rgba(161,98,7,0.9)",
|
||||
glow: "rgba(250,204,21,0.28)",
|
||||
},
|
||||
tiger: {
|
||||
src: "/avatars/twemoji/tiger.svg",
|
||||
bodyFill: "rgba(249,115,22,0.94)",
|
||||
limbStroke: "rgba(194,65,12,0.9)",
|
||||
glow: "rgba(251,146,60,0.3)",
|
||||
},
|
||||
rabbit: {
|
||||
src: "/avatars/twemoji/rabbit.svg",
|
||||
bodyFill: "rgba(236,72,153,0.9)",
|
||||
limbStroke: "rgba(219,39,119,0.86)",
|
||||
glow: "rgba(244,114,182,0.28)",
|
||||
},
|
||||
};
|
||||
|
||||
const avatarImageCache = new Map<AvatarKey, HTMLImageElement | null>();
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
@@ -345,105 +433,73 @@ function drawLimbs(ctx: CanvasRenderingContext2D, anchors: AvatarAnchors, stroke
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawGorillaAvatar(ctx: CanvasRenderingContext2D, anchors: AvatarAnchors) {
|
||||
ctx.fillStyle = "#3f3f46";
|
||||
ctx.beginPath();
|
||||
ctx.arc(anchors.headX, anchors.headY, anchors.headRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
function getAvatarImage(key: AvatarKey) {
|
||||
if (typeof Image === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(anchors.headX - anchors.headRadius * 0.78, anchors.headY - anchors.headRadius * 0.1, anchors.headRadius * 0.28, 0, Math.PI * 2);
|
||||
ctx.arc(anchors.headX + anchors.headRadius * 0.78, anchors.headY - anchors.headRadius * 0.1, anchors.headRadius * 0.28, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
const cached = avatarImageCache.get(key);
|
||||
if (cached) {
|
||||
return cached.complete && cached.naturalWidth > 0 ? cached : null;
|
||||
}
|
||||
|
||||
ctx.fillStyle = "#d6d3d1";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(anchors.headX, anchors.headY + anchors.headRadius * 0.16, anchors.headRadius * 0.54, anchors.headRadius * 0.46, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = "#111827";
|
||||
ctx.beginPath();
|
||||
ctx.arc(anchors.headX - anchors.headRadius * 0.24, anchors.headY - anchors.headRadius * 0.12, anchors.headRadius * 0.08, 0, Math.PI * 2);
|
||||
ctx.arc(anchors.headX + anchors.headRadius * 0.24, anchors.headY - anchors.headRadius * 0.12, anchors.headRadius * 0.08, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(anchors.headX, anchors.headY + anchors.headRadius * 0.06, anchors.headRadius * 0.08, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
drawRoundedBody(ctx, anchors, "rgba(39,39,42,0.95)");
|
||||
drawLimbs(ctx, anchors, "rgba(63,63,70,0.92)");
|
||||
const image = new Image();
|
||||
image.decoding = "async";
|
||||
image.src = AVATAR_VISUALS[key].src;
|
||||
avatarImageCache.set(key, image);
|
||||
return null;
|
||||
}
|
||||
|
||||
function drawMonkeyAvatar(ctx: CanvasRenderingContext2D, anchors: AvatarAnchors) {
|
||||
ctx.fillStyle = "#8b5a3c";
|
||||
ctx.beginPath();
|
||||
ctx.arc(anchors.headX, anchors.headY, anchors.headRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(anchors.headX - anchors.headRadius * 0.82, anchors.headY - anchors.headRadius * 0.16, anchors.headRadius * 0.34, 0, Math.PI * 2);
|
||||
ctx.arc(anchors.headX + anchors.headRadius * 0.82, anchors.headY - anchors.headRadius * 0.16, anchors.headRadius * 0.34, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
function drawAvatarBadge(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
anchors: AvatarAnchors,
|
||||
avatarKey: AvatarKey,
|
||||
sprite: HTMLImageElement | null,
|
||||
) {
|
||||
const visual = AVATAR_VISUALS[avatarKey];
|
||||
const headSize = anchors.headRadius * 2.5;
|
||||
const torsoBadge = Math.max(anchors.headRadius * 0.95, 40);
|
||||
|
||||
ctx.fillStyle = "#f3d7bf";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(anchors.headX, anchors.headY + anchors.headRadius * 0.14, anchors.headRadius * 0.56, anchors.headRadius * 0.5, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
drawRoundedBody(ctx, anchors, visual.bodyFill);
|
||||
drawLimbs(ctx, anchors, visual.limbStroke);
|
||||
|
||||
drawRoundedBody(ctx, anchors, "rgba(120,53,15,0.95)");
|
||||
drawLimbs(ctx, anchors, "rgba(146,64,14,0.9)");
|
||||
}
|
||||
ctx.save();
|
||||
ctx.fillStyle = visual.glow;
|
||||
ctx.beginPath();
|
||||
ctx.arc(anchors.headX, anchors.headY, anchors.headRadius * 1.16, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
function drawPigAvatar(ctx: CanvasRenderingContext2D, anchors: AvatarAnchors) {
|
||||
ctx.fillStyle = "#f9a8d4";
|
||||
ctx.beginPath();
|
||||
ctx.arc(anchors.headX, anchors.headY, anchors.headRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(anchors.headX - anchors.headRadius * 0.62, anchors.headY - anchors.headRadius * 0.42);
|
||||
ctx.lineTo(anchors.headX - anchors.headRadius * 0.18, anchors.headY - anchors.headRadius * 1.06);
|
||||
ctx.lineTo(anchors.headX - anchors.headRadius * 0.02, anchors.headY - anchors.headRadius * 0.32);
|
||||
ctx.closePath();
|
||||
ctx.moveTo(anchors.headX + anchors.headRadius * 0.62, anchors.headY - anchors.headRadius * 0.42);
|
||||
ctx.lineTo(anchors.headX + anchors.headRadius * 0.18, anchors.headY - anchors.headRadius * 1.06);
|
||||
ctx.lineTo(anchors.headX + anchors.headRadius * 0.02, anchors.headY - anchors.headRadius * 0.32);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
if (sprite) {
|
||||
ctx.drawImage(
|
||||
sprite,
|
||||
anchors.headX - headSize / 2,
|
||||
anchors.headY - headSize / 2,
|
||||
headSize,
|
||||
headSize,
|
||||
);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.94;
|
||||
ctx.drawImage(
|
||||
sprite,
|
||||
anchors.bodyX - torsoBadge / 2,
|
||||
anchors.bodyY - torsoBadge / 2,
|
||||
torsoBadge,
|
||||
torsoBadge,
|
||||
);
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.fillStyle = "#fbcfe8";
|
||||
ctx.fillStyle = "rgba(255,255,255,0.92)";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(anchors.headX, anchors.headY + anchors.headRadius * 0.18, anchors.headRadius * 0.44, anchors.headRadius * 0.28, 0, 0, Math.PI * 2);
|
||||
ctx.arc(anchors.headX, anchors.headY, anchors.headRadius * 0.88, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "#be185d";
|
||||
ctx.fillStyle = "rgba(17,24,39,0.82)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(anchors.headX - anchors.headRadius * 0.14, anchors.headY + anchors.headRadius * 0.18, anchors.headRadius * 0.06, 0, Math.PI * 2);
|
||||
ctx.arc(anchors.headX + anchors.headRadius * 0.14, anchors.headY + anchors.headRadius * 0.18, anchors.headRadius * 0.06, 0, Math.PI * 2);
|
||||
ctx.arc(anchors.headX - anchors.headRadius * 0.22, anchors.headY - anchors.headRadius * 0.08, anchors.headRadius * 0.08, 0, Math.PI * 2);
|
||||
ctx.arc(anchors.headX + anchors.headRadius * 0.22, anchors.headY - anchors.headRadius * 0.08, anchors.headRadius * 0.08, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
drawRoundedBody(ctx, anchors, "rgba(244,114,182,0.92)");
|
||||
drawLimbs(ctx, anchors, "rgba(244,114,182,0.86)");
|
||||
}
|
||||
|
||||
function drawDogAvatar(ctx: CanvasRenderingContext2D, anchors: AvatarAnchors) {
|
||||
ctx.fillStyle = "#d4a373";
|
||||
ctx.beginPath();
|
||||
ctx.arc(anchors.headX, anchors.headY, anchors.headRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(anchors.headX - anchors.headRadius * 0.72, anchors.headY - anchors.headRadius * 0.28, anchors.headRadius * 0.22, anchors.headRadius * 0.46, Math.PI / 4, 0, Math.PI * 2);
|
||||
ctx.ellipse(anchors.headX + anchors.headRadius * 0.72, anchors.headY - anchors.headRadius * 0.28, anchors.headRadius * 0.22, anchors.headRadius * 0.46, -Math.PI / 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = "#f5e6d3";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(anchors.headX, anchors.headY + anchors.headRadius * 0.16, anchors.headRadius * 0.5, anchors.headRadius * 0.38, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "#111827";
|
||||
ctx.beginPath();
|
||||
ctx.arc(anchors.headX, anchors.headY + anchors.headRadius * 0.04, anchors.headRadius * 0.09, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
drawRoundedBody(ctx, anchors, "rgba(180,83,9,0.93)");
|
||||
drawLimbs(ctx, anchors, "rgba(180,83,9,0.88)");
|
||||
}
|
||||
|
||||
export function drawLiveCameraOverlay(
|
||||
@@ -459,17 +515,10 @@ export function drawLiveCameraOverlay(
|
||||
if (avatarState?.enabled) {
|
||||
const anchors = getAvatarAnchors(landmarks, canvas.width, canvas.height);
|
||||
if (anchors) {
|
||||
const sprite = getAvatarImage(avatarState.avatarKey);
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.95;
|
||||
if (avatarState.avatarKey === "monkey") {
|
||||
drawMonkeyAvatar(ctx, anchors);
|
||||
} else if (avatarState.avatarKey === "pig") {
|
||||
drawPigAvatar(ctx, anchors);
|
||||
} else if (avatarState.avatarKey === "dog") {
|
||||
drawDogAvatar(ctx, anchors);
|
||||
} else {
|
||||
drawGorillaAvatar(ctx, anchors);
|
||||
}
|
||||
drawAvatarBadge(ctx, anchors, avatarState.avatarKey, sprite);
|
||||
ctx.restore();
|
||||
|
||||
ctx.save();
|
||||
|
||||
@@ -1320,7 +1320,7 @@ export default function LiveCamera() {
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">实时分析中枢</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-white/70">
|
||||
摄像头启动后会持续识别正手、反手、发球、截击、高压、切削、挑高球与未知动作。系统会用 24 帧时间窗口统一动作,再把稳定动作写入片段、训练记录与评分;开启虚拟形象后,画面中的人体会被猩猩或其他卡通形象覆盖显示。
|
||||
摄像头启动后会持续识别正手、反手、发球、截击、高压、切削、挑高球与未知动作。系统会用 24 帧时间窗口统一动作,再把稳定动作写入片段、训练记录与评分;开启虚拟形象后,画面中的人体会被猩猩、猴子、狗、猪、猫、狐狸、熊猫、狮子、老虎或兔子形象覆盖显示。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1455,7 +1455,7 @@ export default function LiveCamera() {
|
||||
<div>
|
||||
<div className="text-sm font-medium">虚拟形象替换</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
开启后实时画面会用卡通形象覆盖主体,仅影响前端叠加显示,不改变动作识别与原视频归档。
|
||||
开启后实时画面会用 10 个免费动物虚拟形象之一覆盖主体,仅影响前端叠加显示,不改变动作识别与原视频归档。
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -1467,7 +1467,7 @@ export default function LiveCamera() {
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
当前映射:{resolvedAvatarLabel}
|
||||
{avatarPrompt.trim() ? ` · 输入 ${avatarPrompt.trim()}` : " · 可输入别名自动映射到内置形象"}
|
||||
{avatarPrompt.trim() ? ` · 输入 ${avatarPrompt.trim()}` : " · 可输入猩猩、狐狸、熊猫、兔子等别名自动映射"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -1488,7 +1488,7 @@ export default function LiveCamera() {
|
||||
<Input
|
||||
value={avatarPrompt}
|
||||
onChange={(event) => setAvatarPrompt(event.target.value)}
|
||||
placeholder="例如 猴子 / dog mascot"
|
||||
placeholder="例如 狐狸 / panda coach / dog mascot"
|
||||
className="h-12 rounded-2xl border-border/60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
在新工单中引用
屏蔽一个用户