Add free full-body 3D live camera avatar examples

这个提交包含在:
cryptocommuniums-afk
2026-03-15 22:32:09 +08:00
父节点 fe5e539a47
当前提交 e3fe9a8e7b
修改 9 个文件,包含 326 行新增28 行删除

查看文件

@@ -1,8 +1,10 @@
import { describe, expect, it } from "vitest";
import {
ACTION_WINDOW_FRAMES,
AVATAR_PRESETS,
createStableActionState,
getAvatarAnchors,
getAvatarPreset,
resolveAvatarKeyFromPrompt,
stabilizeActionStream,
type FrameActionSample,
@@ -92,9 +94,19 @@ describe("live camera avatar helpers", () => {
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 };

查看文件

@@ -16,7 +16,25 @@ export type AvatarKey =
| "panda"
| "lion"
| "tiger"
| "rabbit";
| "rabbit"
| "beachKing"
| "jenny3d"
| "juanita3d"
| "sportTv";
export type AvatarCategory = "animal" | "full-body-3d";
export type AvatarPreset = {
key: AvatarKey;
label: string;
category: AvatarCategory;
keywords: string[];
description?: string;
collection?: string;
license?: string;
sourceUrl?: string;
modelUrl?: string;
};
export type AvatarRenderState = {
enabled: boolean;
@@ -84,6 +102,9 @@ type AvatarVisualSpec = {
bodyFill: string;
limbStroke: string;
glow: string;
renderMode: "badge" | "full-figure";
figureScale?: number;
figureOffsetY?: number;
};
const ACTIONS: LiveActionType[] = ["forehand", "backhand", "serve", "volley", "overhead", "slice", "lob", "unknown"];
@@ -96,17 +117,61 @@ const ACTION_UNKNOWN_MIN_MS = 900;
const ACTION_LOCK_IN_WINDOWS = 2;
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: "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", "兔", "兔子"] },
export const AVATAR_PRESETS: AvatarPreset[] = [
{ key: "gorilla", label: "猩猩", category: "animal", keywords: ["gorilla", "ape", "猩猩", "猩", "大猩猩"], description: "轻量动物替身,移动端负担最低。" },
{ key: "monkey", label: "猴子", category: "animal", keywords: ["monkey", "ape", "猴", "猴子"], description: "轻量动物替身,适合快速练习。" },
{ key: "dog", label: "狗", category: "animal", keywords: ["dog", "puppy", "犬", "狗", "小狗"], description: "轻量动物替身,覆盖速度快。" },
{ key: "pig", label: "猪", category: "animal", keywords: ["pig", "猪", "小猪"], description: "轻量动物替身,适合低端设备。" },
{ key: "cat", label: "猫", category: "animal", keywords: ["cat", "kitty", "猫", "小猫"], description: "轻量动物替身,适合低码率录制。" },
{ key: "fox", label: "狐狸", category: "animal", keywords: ["fox", "狐狸"], description: "轻量动物替身,动作切换反馈清晰。" },
{ key: "panda", label: "熊猫", category: "animal", keywords: ["panda", "熊猫"], description: "轻量动物替身,适合直播预览。" },
{ key: "lion", label: "狮子", category: "animal", keywords: ["lion", "狮子"], description: "轻量动物替身,轮廓感更强。" },
{ key: "tiger", label: "老虎", category: "animal", keywords: ["tiger", "虎", "老虎"], description: "轻量动物替身,适合训练 PK。" },
{ key: "rabbit", label: "兔子", category: "animal", keywords: ["rabbit", "bunny", "兔", "兔子"], description: "轻量动物替身,适合日常训练。" },
{
key: "beachKing",
label: "BeachKing",
category: "full-body-3d",
keywords: ["beachking", "beach king", "海滩王", "3d beach", "beach avatar"],
description: "CC0 全身 3D 示例,适合覆盖竖屏全身站姿。",
collection: "100Avatars R3",
license: "CC0",
sourceUrl: "https://github.com/ToxSam/open-source-avatars",
modelUrl: "https://arweave.net/uKhDMselhdUyeJKjelpuVsL8s-a9v_Wqq75TQfCfnos",
},
{
key: "jenny3d",
label: "Jenny",
category: "full-body-3d",
keywords: ["jenny", "frog coach", "青蛙教练", "3d jenny", "jenny avatar"],
description: "CC0 全身 3D 示例,适合想要更完整人物轮廓时使用。",
collection: "100Avatars R3",
license: "CC0",
sourceUrl: "https://github.com/ToxSam/open-source-avatars",
modelUrl: "https://arweave.net/kgTirc4OvUWbJhIKC2CB3_pYsYuB62KTj90IdE8s3sk",
},
{
key: "juanita3d",
label: "Juanita",
category: "full-body-3d",
keywords: ["juanita", "粉发学员", "pink avatar", "3d juanita", "juanita avatar"],
description: "CC0 全身 3D 示例,适合教学演示和移动端预览。",
collection: "100Avatars R3",
license: "CC0",
sourceUrl: "https://github.com/ToxSam/open-source-avatars",
modelUrl: "https://arweave.net/nyMyZZx5lN2DXsmBgbGQSnt3PuXYN7AAjz9QJrjitLo",
},
{
key: "sportTv",
label: "SportTV",
category: "full-body-3d",
keywords: ["sporttv", "sport tv", "屏幕街头", "tv avatar", "hoodie avatar"],
description: "CC0 全身 3D 示例,适合训练空间较宽的画面。",
collection: "100Avatars R3",
license: "CC0",
sourceUrl: "https://github.com/ToxSam/open-source-avatars",
modelUrl: "https://arweave.net/ISYr7xBXT_s4tLddbhFB3PpUhWg-H_BYs2UZhVLF1hA",
},
];
const AVATAR_VISUALS: Record<AvatarKey, AvatarVisualSpec> = {
@@ -115,65 +180,115 @@ const AVATAR_VISUALS: Record<AvatarKey, AvatarVisualSpec> = {
bodyFill: "rgba(39,39,42,0.95)",
limbStroke: "rgba(63,63,70,0.92)",
glow: "rgba(161,161,170,0.32)",
renderMode: "badge",
},
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)",
renderMode: "badge",
},
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)",
renderMode: "badge",
},
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)",
renderMode: "badge",
},
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)",
renderMode: "badge",
},
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)",
renderMode: "badge",
},
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)",
renderMode: "badge",
},
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)",
renderMode: "badge",
},
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)",
renderMode: "badge",
},
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)",
renderMode: "badge",
},
beachKing: {
src: "/avatars/opensource3d/beach-king.webp",
bodyFill: "rgba(15,23,42,0.16)",
limbStroke: "rgba(125,211,252,0.28)",
glow: "rgba(56,189,248,0.16)",
renderMode: "full-figure",
figureScale: 1.12,
figureOffsetY: 0.02,
},
jenny3d: {
src: "/avatars/opensource3d/jenny.webp",
bodyFill: "rgba(34,197,94,0.16)",
limbStroke: "rgba(16,185,129,0.24)",
glow: "rgba(34,197,94,0.18)",
renderMode: "full-figure",
figureScale: 1.08,
figureOffsetY: 0,
},
juanita3d: {
src: "/avatars/opensource3d/juanita.webp",
bodyFill: "rgba(244,114,182,0.14)",
limbStroke: "rgba(236,72,153,0.26)",
glow: "rgba(244,114,182,0.18)",
renderMode: "full-figure",
figureScale: 1.06,
figureOffsetY: 0,
},
sportTv: {
src: "/avatars/opensource3d/sport-tv.webp",
bodyFill: "rgba(59,130,246,0.14)",
limbStroke: "rgba(96,165,250,0.24)",
glow: "rgba(96,165,250,0.18)",
renderMode: "full-figure",
figureScale: 1.1,
figureOffsetY: 0.02,
},
};
const avatarImageCache = new Map<AvatarKey, HTMLImageElement | null>();
export function getAvatarPreset(key: AvatarKey) {
return AVATAR_PRESETS.find((preset) => preset.key === key) ?? AVATAR_PRESETS[0];
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
@@ -502,6 +617,51 @@ function drawAvatarBadge(
ctx.fill();
}
function drawFullFigureAvatar(
ctx: CanvasRenderingContext2D,
anchors: AvatarAnchors,
avatarKey: AvatarKey,
sprite: HTMLImageElement | null,
) {
const visual = AVATAR_VISUALS[avatarKey];
const topY = anchors.headY - anchors.headRadius * 1.55 + anchors.bodyHeight * (visual.figureOffsetY ?? 0);
const baseHeight = Math.max(anchors.footY - topY, anchors.bodyHeight * 2.35);
const figureHeight = baseHeight * (visual.figureScale ?? 1);
const aspectRatio = sprite?.naturalWidth && sprite?.naturalHeight
? sprite.naturalWidth / sprite.naturalHeight
: 0.72;
const figureWidth = figureHeight * aspectRatio;
const figureLeft = anchors.bodyX - figureWidth / 2;
ctx.save();
ctx.fillStyle = visual.glow;
ctx.beginPath();
ctx.ellipse(
anchors.bodyX,
anchors.footY - anchors.headRadius * 0.1,
Math.max(anchors.bodyWidth * 0.42, 34),
Math.max(anchors.headRadius * 0.22, 10),
0,
0,
Math.PI * 2,
);
ctx.fill();
ctx.restore();
if (sprite) {
ctx.save();
ctx.shadowColor = "rgba(15,23,42,0.28)";
ctx.shadowBlur = 16;
ctx.shadowOffsetY = 10;
ctx.drawImage(sprite, figureLeft, topY, figureWidth, figureHeight);
ctx.restore();
return;
}
drawRoundedBody(ctx, anchors, visual.bodyFill);
drawLimbs(ctx, anchors, visual.limbStroke);
}
export function drawLiveCameraOverlay(
canvas: HTMLCanvasElement | null,
landmarks: PosePoint[] | undefined,
@@ -516,20 +676,27 @@ export function drawLiveCameraOverlay(
const anchors = getAvatarAnchors(landmarks, canvas.width, canvas.height);
if (anchors) {
const sprite = getAvatarImage(avatarState.avatarKey);
const visual = AVATAR_VISUALS[avatarState.avatarKey];
ctx.save();
ctx.globalAlpha = 0.95;
drawAvatarBadge(ctx, anchors, avatarState.avatarKey, sprite);
if (visual.renderMode === "full-figure") {
drawFullFigureAvatar(ctx, anchors, avatarState.avatarKey, sprite);
} else {
drawAvatarBadge(ctx, anchors, avatarState.avatarKey, sprite);
}
ctx.restore();
ctx.save();
ctx.strokeStyle = "rgba(255,255,255,0.16)";
ctx.lineWidth = 2;
ctx.setLineDash([8, 10]);
ctx.beginPath();
ctx.moveTo(anchors.bodyX, anchors.shoulderY - anchors.headRadius * 1.25);
ctx.lineTo(anchors.bodyX, anchors.footY);
ctx.stroke();
ctx.restore();
if (visual.renderMode !== "full-figure") {
ctx.save();
ctx.strokeStyle = "rgba(255,255,255,0.16)";
ctx.lineWidth = 2;
ctx.setLineDash([8, 10]);
ctx.beginPath();
ctx.moveTo(anchors.bodyX, anchors.shoulderY - anchors.headRadius * 1.25);
ctx.lineTo(anchors.bodyX, anchors.footY);
ctx.stroke();
ctx.restore();
}
return;
}
}