Add free full-body 3D live camera avatar examples

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

查看文件

@@ -0,0 +1,22 @@
3D full-body avatar preview cutouts in this folder are derived from the Open Source Avatars registry:
https://github.com/ToxSam/open-source-avatars
Registry summary:
- Registry metadata/docs license: CC0
- Individual avatars used here: CC0 from collection "100Avatars R3"
Integrated examples:
- BeachKing
Preview source: https://arweave.net/EGCdxkfTjjmNS4RGiAT_or17mG3717qnZ7R1EnZxLg8
Model source: https://arweave.net/uKhDMselhdUyeJKjelpuVsL8s-a9v_Wqq75TQfCfnos
- Jenny
Preview source: https://arweave.net/4a6_AfH-PHvFMXqja7V42pF9hCn9ceIj5z5NAsK2SSs
Model source: https://arweave.net/kgTirc4OvUWbJhIKC2CB3_pYsYuB62KTj90IdE8s3sk
- Juanita
Preview source: https://arweave.net/5RHeIXD9fezkpuFJS1TRtGkNIVfTKZP7Rkmh9pDmaTs
Model source: https://arweave.net/nyMyZZx5lN2DXsmBgbGQSnt3PuXYN7AAjz9QJrjitLo
- SportTV
Preview source: https://arweave.net/_Qic8KV5P5mo5wJ2N3lbqX0iGVxtVDn4CxCUiM5-Qcg
Model source: https://arweave.net/ISYr7xBXT_s4tLddbhFB3PpUhWg-H_BYs2UZhVLF1hA
Local files are optimized transparent WebP derivatives for faster in-browser overlay rendering.

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 18 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 26 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 20 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 19 KiB

查看文件

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

查看文件

@@ -19,9 +19,11 @@ import {
createEmptyStabilizedActionMeta,
createStableActionState,
drawLiveCameraOverlay,
getAvatarPreset,
resolveAvatarKeyFromPrompt,
stabilizeActionStream,
type AvatarKey,
type AvatarPreset,
type AvatarRenderState,
type FrameActionSample,
type LiveActionType,
@@ -32,6 +34,7 @@ import {
Camera,
CameraOff,
CheckCircle2,
ExternalLink,
FlipHorizontal,
Maximize2,
Minus,
@@ -1114,7 +1117,10 @@ export default function LiveCamera() {
const heroAction = ACTION_META[currentAction];
const rawActionMeta = ACTION_META[rawAction];
const pendingActionMeta = stabilityMeta.pendingAction ? ACTION_META[stabilityMeta.pendingAction] : null;
const resolvedAvatarLabel = AVATAR_PRESETS.find((preset) => preset.key === resolvedAvatarKey)?.label || "猩猩";
const resolvedAvatarPreset = getAvatarPreset(resolvedAvatarKey);
const resolvedAvatarLabel = resolvedAvatarPreset?.label || "猩猩";
const animalAvatarPresets = AVATAR_PRESETS.filter((preset) => preset.category === "animal");
const fullBodyAvatarPresets = AVATAR_PRESETS.filter((preset) => preset.category === "full-body-3d");
const previewTitle = analyzing
? stabilityMeta.pending && pendingActionMeta
? `${pendingActionMeta.label} 切换确认中`
@@ -1204,6 +1210,78 @@ export default function LiveCamera() {
</div>
);
const renderAvatarShowcaseCard = (preset: AvatarPreset) => {
const active = resolvedAvatarKey === preset.key;
return (
<div
key={preset.key}
className={`overflow-hidden rounded-[22px] border transition ${
active
? "border-primary/50 bg-primary/5 shadow-lg shadow-primary/10"
: "border-border/60 bg-background hover:border-primary/30 hover:bg-muted/40"
}`}
>
<button
type="button"
onClick={() => {
setAvatarKey(preset.key);
setAvatarEnabled(true);
}}
className="group block w-full text-left"
>
<div className="relative aspect-[4/5] overflow-hidden bg-[radial-gradient(circle_at_top,_rgba(255,255,255,0.9),_rgba(226,232,240,0.18)_38%,_rgba(15,23,42,0.92))]">
<img
src={`/avatars/opensource3d/${
preset.key === "beachKing"
? "beach-king"
: preset.key === "sportTv"
? "sport-tv"
: preset.key === "juanita3d"
? "juanita"
: "jenny"
}.webp`}
alt={preset.label}
className="h-full w-full object-contain p-3 transition duration-300 group-hover:scale-[1.03]"
loading="lazy"
/>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-slate-950 via-slate-950/55 to-transparent" />
<div className="absolute left-3 top-3">
<Badge className="border-white/10 bg-black/60 text-white hover:bg-black/60">3D </Badge>
</div>
{active ? (
<div className="absolute right-3 top-3">
<Badge className="border-primary/20 bg-primary text-primary-foreground hover:bg-primary">使</Badge>
</div>
) : null}
</div>
<div className="space-y-3 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold">{preset.label}</div>
<div className="mt-1 text-xs text-muted-foreground">{preset.collection} · {preset.license}</div>
</div>
</div>
<p className="text-xs leading-5 text-muted-foreground">{preset.description}</p>
</div>
</button>
<div className="flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3">
<div className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">VRM </div>
{preset.modelUrl ? (
<a
href={preset.modelUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-primary"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
) : null}
</div>
</div>
);
};
return (
<div className="space-y-4 mobile-safe-bottom">
<Dialog open={showSetupGuide} onOpenChange={setShowSetupGuide}>
@@ -1320,7 +1398,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 10 4 3D Avatar
</p>
</div>
</div>
@@ -1455,7 +1533,7 @@ export default function LiveCamera() {
<div>
<div className="text-sm font-medium"></div>
<div className="mt-1 text-xs text-muted-foreground">
10
使 10 4 3D Avatar
</div>
</div>
<Switch
@@ -1467,7 +1545,7 @@ export default function LiveCamera() {
</div>
<div className="text-xs text-muted-foreground">
{resolvedAvatarLabel}
{avatarPrompt.trim() ? ` · 输入 ${avatarPrompt.trim()}` : " · 可输入猩猩、狐狸、熊猫、兔子等别名自动映射"}
{avatarPrompt.trim() ? ` · 输入 ${avatarPrompt.trim()}` : " · 可输入猩猩、狐狸、熊猫、兔子,或 BeachKing、Juanita 等别名自动映射"}
</div>
</div>
<div>
@@ -1477,9 +1555,12 @@ export default function LiveCamera() {
<SelectValue />
</SelectTrigger>
<SelectContent>
{AVATAR_PRESETS.map((preset) => (
{animalAvatarPresets.map((preset) => (
<SelectItem key={preset.key} value={preset.key}>{preset.label}</SelectItem>
))}
{fullBodyAvatarPresets.map((preset) => (
<SelectItem key={preset.key} value={preset.key}>{preset.label} · 3D</SelectItem>
))}
</SelectContent>
</Select>
</div>
@@ -1488,11 +1569,25 @@ export default function LiveCamera() {
<Input
value={avatarPrompt}
onChange={(event) => setAvatarPrompt(event.target.value)}
placeholder="例如 狐狸 / panda coach / dog mascot"
placeholder="例如 狐狸 / panda coach / BeachKing / Juanita"
className="h-12 rounded-2xl border-border/60"
/>
</div>
</div>
<div className="mt-4 rounded-[24px] border border-border/60 bg-background/80 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-sm font-medium"> 3D </div>
<div className="mt-1 text-xs text-muted-foreground">
4 Open Source Avatars CC0 VRM/three-vrm沿
</div>
</div>
<Badge variant="secondary" className="rounded-full px-3 py-1 text-xs">CC0 · Open Source Avatars</Badge>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{fullBodyAvatarPresets.map(renderAvatarShowcaseCard)}
</div>
</div>
</div>
</CardContent>
</Card>