Add 10 free animal live camera avatars

这个提交包含在:
cryptocommuniums-afk
2026-03-15 22:02:18 +08:00
父节点 139dc61b61
当前提交 fe5e539a47
修改 15 个文件,包含 186 行新增104 行删除

查看文件

@@ -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>