Add free full-body 3D live camera avatar examples
这个提交包含在:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户