Add free full-body 3D live camera avatar examples
这个提交包含在:
@@ -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 { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
ACTION_WINDOW_FRAMES,
|
ACTION_WINDOW_FRAMES,
|
||||||
|
AVATAR_PRESETS,
|
||||||
createStableActionState,
|
createStableActionState,
|
||||||
getAvatarAnchors,
|
getAvatarAnchors,
|
||||||
|
getAvatarPreset,
|
||||||
resolveAvatarKeyFromPrompt,
|
resolveAvatarKeyFromPrompt,
|
||||||
stabilizeActionStream,
|
stabilizeActionStream,
|
||||||
type FrameActionSample,
|
type FrameActionSample,
|
||||||
@@ -92,9 +94,19 @@ describe("live camera avatar helpers", () => {
|
|||||||
expect(resolveAvatarKeyFromPrompt("dog mascot", "gorilla")).toBe("dog");
|
expect(resolveAvatarKeyFromPrompt("dog mascot", "gorilla")).toBe("dog");
|
||||||
expect(resolveAvatarKeyFromPrompt("狐狸风格", "gorilla")).toBe("fox");
|
expect(resolveAvatarKeyFromPrompt("狐狸风格", "gorilla")).toBe("fox");
|
||||||
expect(resolveAvatarKeyFromPrompt("兔子教练", "gorilla")).toBe("rabbit");
|
expect(resolveAvatarKeyFromPrompt("兔子教练", "gorilla")).toBe("rabbit");
|
||||||
|
expect(resolveAvatarKeyFromPrompt("BeachKing 3D 替身", "gorilla")).toBe("beachKing");
|
||||||
|
expect(resolveAvatarKeyFromPrompt("Juanita avatar", "gorilla")).toBe("juanita3d");
|
||||||
expect(resolveAvatarKeyFromPrompt("", "pig")).toBe("pig");
|
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", () => {
|
it("builds avatar anchors from pose landmarks", () => {
|
||||||
const landmarks = Array.from({ length: 33 }, () => ({ x: 0.5, y: 0.5, visibility: 0.95 }));
|
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 };
|
landmarks[0] = { x: 0.5, y: 0.16, visibility: 0.99 };
|
||||||
|
|||||||
@@ -16,7 +16,25 @@ export type AvatarKey =
|
|||||||
| "panda"
|
| "panda"
|
||||||
| "lion"
|
| "lion"
|
||||||
| "tiger"
|
| "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 = {
|
export type AvatarRenderState = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -84,6 +102,9 @@ type AvatarVisualSpec = {
|
|||||||
bodyFill: string;
|
bodyFill: string;
|
||||||
limbStroke: string;
|
limbStroke: string;
|
||||||
glow: string;
|
glow: string;
|
||||||
|
renderMode: "badge" | "full-figure";
|
||||||
|
figureScale?: number;
|
||||||
|
figureOffsetY?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTIONS: LiveActionType[] = ["forehand", "backhand", "serve", "volley", "overhead", "slice", "lob", "unknown"];
|
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_LOCK_IN_WINDOWS = 2;
|
||||||
const ACTION_SWITCH_DELTA = 0.12;
|
const ACTION_SWITCH_DELTA = 0.12;
|
||||||
|
|
||||||
export const AVATAR_PRESETS: Array<{ key: AvatarKey; label: string; keywords: string[] }> = [
|
export const AVATAR_PRESETS: AvatarPreset[] = [
|
||||||
{ key: "gorilla", label: "猩猩", keywords: ["gorilla", "ape", "猩猩", "猩", "大猩猩"] },
|
{ key: "gorilla", label: "猩猩", category: "animal", keywords: ["gorilla", "ape", "猩猩", "猩", "大猩猩"], description: "轻量动物替身,移动端负担最低。" },
|
||||||
{ key: "monkey", label: "猴子", keywords: ["monkey", "ape", "猴", "猴子"] },
|
{ key: "monkey", label: "猴子", category: "animal", keywords: ["monkey", "ape", "猴", "猴子"], description: "轻量动物替身,适合快速练习。" },
|
||||||
{ key: "dog", label: "狗", keywords: ["dog", "puppy", "犬", "狗", "小狗"] },
|
{ key: "dog", label: "狗", category: "animal", keywords: ["dog", "puppy", "犬", "狗", "小狗"], description: "轻量动物替身,覆盖速度快。" },
|
||||||
{ key: "pig", label: "猪", keywords: ["pig", "猪", "小猪"] },
|
{ key: "pig", label: "猪", category: "animal", keywords: ["pig", "猪", "小猪"], description: "轻量动物替身,适合低端设备。" },
|
||||||
{ key: "cat", label: "猫", keywords: ["cat", "kitty", "猫", "小猫"] },
|
{ key: "cat", label: "猫", category: "animal", keywords: ["cat", "kitty", "猫", "小猫"], description: "轻量动物替身,适合低码率录制。" },
|
||||||
{ key: "fox", label: "狐狸", keywords: ["fox", "狐狸"] },
|
{ key: "fox", label: "狐狸", category: "animal", keywords: ["fox", "狐狸"], description: "轻量动物替身,动作切换反馈清晰。" },
|
||||||
{ key: "panda", label: "熊猫", keywords: ["panda", "熊猫"] },
|
{ key: "panda", label: "熊猫", category: "animal", keywords: ["panda", "熊猫"], description: "轻量动物替身,适合直播预览。" },
|
||||||
{ key: "lion", label: "狮子", keywords: ["lion", "狮子"] },
|
{ key: "lion", label: "狮子", category: "animal", keywords: ["lion", "狮子"], description: "轻量动物替身,轮廓感更强。" },
|
||||||
{ key: "tiger", label: "老虎", keywords: ["tiger", "虎", "老虎"] },
|
{ key: "tiger", label: "老虎", category: "animal", keywords: ["tiger", "虎", "老虎"], description: "轻量动物替身,适合训练 PK。" },
|
||||||
{ key: "rabbit", label: "兔子", keywords: ["rabbit", "bunny", "兔", "兔子"] },
|
{ 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> = {
|
const AVATAR_VISUALS: Record<AvatarKey, AvatarVisualSpec> = {
|
||||||
@@ -115,65 +180,115 @@ const AVATAR_VISUALS: Record<AvatarKey, AvatarVisualSpec> = {
|
|||||||
bodyFill: "rgba(39,39,42,0.95)",
|
bodyFill: "rgba(39,39,42,0.95)",
|
||||||
limbStroke: "rgba(63,63,70,0.92)",
|
limbStroke: "rgba(63,63,70,0.92)",
|
||||||
glow: "rgba(161,161,170,0.32)",
|
glow: "rgba(161,161,170,0.32)",
|
||||||
|
renderMode: "badge",
|
||||||
},
|
},
|
||||||
monkey: {
|
monkey: {
|
||||||
src: "/avatars/twemoji/monkey.svg",
|
src: "/avatars/twemoji/monkey.svg",
|
||||||
bodyFill: "rgba(120,53,15,0.95)",
|
bodyFill: "rgba(120,53,15,0.95)",
|
||||||
limbStroke: "rgba(146,64,14,0.9)",
|
limbStroke: "rgba(146,64,14,0.9)",
|
||||||
glow: "rgba(180,83,9,0.3)",
|
glow: "rgba(180,83,9,0.3)",
|
||||||
|
renderMode: "badge",
|
||||||
},
|
},
|
||||||
dog: {
|
dog: {
|
||||||
src: "/avatars/twemoji/dog.svg",
|
src: "/avatars/twemoji/dog.svg",
|
||||||
bodyFill: "rgba(180,83,9,0.93)",
|
bodyFill: "rgba(180,83,9,0.93)",
|
||||||
limbStroke: "rgba(180,83,9,0.88)",
|
limbStroke: "rgba(180,83,9,0.88)",
|
||||||
glow: "rgba(217,119,6,0.26)",
|
glow: "rgba(217,119,6,0.26)",
|
||||||
|
renderMode: "badge",
|
||||||
},
|
},
|
||||||
pig: {
|
pig: {
|
||||||
src: "/avatars/twemoji/pig.svg",
|
src: "/avatars/twemoji/pig.svg",
|
||||||
bodyFill: "rgba(244,114,182,0.92)",
|
bodyFill: "rgba(244,114,182,0.92)",
|
||||||
limbStroke: "rgba(244,114,182,0.86)",
|
limbStroke: "rgba(244,114,182,0.86)",
|
||||||
glow: "rgba(244,114,182,0.28)",
|
glow: "rgba(244,114,182,0.28)",
|
||||||
|
renderMode: "badge",
|
||||||
},
|
},
|
||||||
cat: {
|
cat: {
|
||||||
src: "/avatars/twemoji/cat.svg",
|
src: "/avatars/twemoji/cat.svg",
|
||||||
bodyFill: "rgba(245,158,11,0.92)",
|
bodyFill: "rgba(245,158,11,0.92)",
|
||||||
limbStroke: "rgba(217,119,6,0.88)",
|
limbStroke: "rgba(217,119,6,0.88)",
|
||||||
glow: "rgba(251,191,36,0.28)",
|
glow: "rgba(251,191,36,0.28)",
|
||||||
|
renderMode: "badge",
|
||||||
},
|
},
|
||||||
fox: {
|
fox: {
|
||||||
src: "/avatars/twemoji/fox.svg",
|
src: "/avatars/twemoji/fox.svg",
|
||||||
bodyFill: "rgba(234,88,12,0.93)",
|
bodyFill: "rgba(234,88,12,0.93)",
|
||||||
limbStroke: "rgba(194,65,12,0.9)",
|
limbStroke: "rgba(194,65,12,0.9)",
|
||||||
glow: "rgba(251,146,60,0.3)",
|
glow: "rgba(251,146,60,0.3)",
|
||||||
|
renderMode: "badge",
|
||||||
},
|
},
|
||||||
panda: {
|
panda: {
|
||||||
src: "/avatars/twemoji/panda.svg",
|
src: "/avatars/twemoji/panda.svg",
|
||||||
bodyFill: "rgba(82,82,91,0.92)",
|
bodyFill: "rgba(82,82,91,0.92)",
|
||||||
limbStroke: "rgba(39,39,42,0.9)",
|
limbStroke: "rgba(39,39,42,0.9)",
|
||||||
glow: "rgba(228,228,231,0.28)",
|
glow: "rgba(228,228,231,0.28)",
|
||||||
|
renderMode: "badge",
|
||||||
},
|
},
|
||||||
lion: {
|
lion: {
|
||||||
src: "/avatars/twemoji/lion.svg",
|
src: "/avatars/twemoji/lion.svg",
|
||||||
bodyFill: "rgba(202,138,4,0.92)",
|
bodyFill: "rgba(202,138,4,0.92)",
|
||||||
limbStroke: "rgba(161,98,7,0.9)",
|
limbStroke: "rgba(161,98,7,0.9)",
|
||||||
glow: "rgba(250,204,21,0.28)",
|
glow: "rgba(250,204,21,0.28)",
|
||||||
|
renderMode: "badge",
|
||||||
},
|
},
|
||||||
tiger: {
|
tiger: {
|
||||||
src: "/avatars/twemoji/tiger.svg",
|
src: "/avatars/twemoji/tiger.svg",
|
||||||
bodyFill: "rgba(249,115,22,0.94)",
|
bodyFill: "rgba(249,115,22,0.94)",
|
||||||
limbStroke: "rgba(194,65,12,0.9)",
|
limbStroke: "rgba(194,65,12,0.9)",
|
||||||
glow: "rgba(251,146,60,0.3)",
|
glow: "rgba(251,146,60,0.3)",
|
||||||
|
renderMode: "badge",
|
||||||
},
|
},
|
||||||
rabbit: {
|
rabbit: {
|
||||||
src: "/avatars/twemoji/rabbit.svg",
|
src: "/avatars/twemoji/rabbit.svg",
|
||||||
bodyFill: "rgba(236,72,153,0.9)",
|
bodyFill: "rgba(236,72,153,0.9)",
|
||||||
limbStroke: "rgba(219,39,119,0.86)",
|
limbStroke: "rgba(219,39,119,0.86)",
|
||||||
glow: "rgba(244,114,182,0.28)",
|
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>();
|
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) {
|
function clamp(value: number, min: number, max: number) {
|
||||||
return Math.max(min, Math.min(max, value));
|
return Math.max(min, Math.min(max, value));
|
||||||
}
|
}
|
||||||
@@ -502,6 +617,51 @@ function drawAvatarBadge(
|
|||||||
ctx.fill();
|
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(
|
export function drawLiveCameraOverlay(
|
||||||
canvas: HTMLCanvasElement | null,
|
canvas: HTMLCanvasElement | null,
|
||||||
landmarks: PosePoint[] | undefined,
|
landmarks: PosePoint[] | undefined,
|
||||||
@@ -516,11 +676,17 @@ export function drawLiveCameraOverlay(
|
|||||||
const anchors = getAvatarAnchors(landmarks, canvas.width, canvas.height);
|
const anchors = getAvatarAnchors(landmarks, canvas.width, canvas.height);
|
||||||
if (anchors) {
|
if (anchors) {
|
||||||
const sprite = getAvatarImage(avatarState.avatarKey);
|
const sprite = getAvatarImage(avatarState.avatarKey);
|
||||||
|
const visual = AVATAR_VISUALS[avatarState.avatarKey];
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.globalAlpha = 0.95;
|
ctx.globalAlpha = 0.95;
|
||||||
|
if (visual.renderMode === "full-figure") {
|
||||||
|
drawFullFigureAvatar(ctx, anchors, avatarState.avatarKey, sprite);
|
||||||
|
} else {
|
||||||
drawAvatarBadge(ctx, anchors, avatarState.avatarKey, sprite);
|
drawAvatarBadge(ctx, anchors, avatarState.avatarKey, sprite);
|
||||||
|
}
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
||||||
|
if (visual.renderMode !== "full-figure") {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.strokeStyle = "rgba(255,255,255,0.16)";
|
ctx.strokeStyle = "rgba(255,255,255,0.16)";
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
@@ -530,6 +696,7 @@ export function drawLiveCameraOverlay(
|
|||||||
ctx.lineTo(anchors.bodyX, anchors.footY);
|
ctx.lineTo(anchors.bodyX, anchors.footY);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ import {
|
|||||||
createEmptyStabilizedActionMeta,
|
createEmptyStabilizedActionMeta,
|
||||||
createStableActionState,
|
createStableActionState,
|
||||||
drawLiveCameraOverlay,
|
drawLiveCameraOverlay,
|
||||||
|
getAvatarPreset,
|
||||||
resolveAvatarKeyFromPrompt,
|
resolveAvatarKeyFromPrompt,
|
||||||
stabilizeActionStream,
|
stabilizeActionStream,
|
||||||
type AvatarKey,
|
type AvatarKey,
|
||||||
|
type AvatarPreset,
|
||||||
type AvatarRenderState,
|
type AvatarRenderState,
|
||||||
type FrameActionSample,
|
type FrameActionSample,
|
||||||
type LiveActionType,
|
type LiveActionType,
|
||||||
@@ -32,6 +34,7 @@ import {
|
|||||||
Camera,
|
Camera,
|
||||||
CameraOff,
|
CameraOff,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
ExternalLink,
|
||||||
FlipHorizontal,
|
FlipHorizontal,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Minus,
|
Minus,
|
||||||
@@ -1114,7 +1117,10 @@ export default function LiveCamera() {
|
|||||||
const heroAction = ACTION_META[currentAction];
|
const heroAction = ACTION_META[currentAction];
|
||||||
const rawActionMeta = ACTION_META[rawAction];
|
const rawActionMeta = ACTION_META[rawAction];
|
||||||
const pendingActionMeta = stabilityMeta.pendingAction ? ACTION_META[stabilityMeta.pendingAction] : null;
|
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
|
const previewTitle = analyzing
|
||||||
? stabilityMeta.pending && pendingActionMeta
|
? stabilityMeta.pending && pendingActionMeta
|
||||||
? `${pendingActionMeta.label} 切换确认中`
|
? `${pendingActionMeta.label} 切换确认中`
|
||||||
@@ -1204,6 +1210,78 @@ export default function LiveCamera() {
|
|||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="space-y-4 mobile-safe-bottom">
|
<div className="space-y-4 mobile-safe-bottom">
|
||||||
<Dialog open={showSetupGuide} onOpenChange={setShowSetupGuide}>
|
<Dialog open={showSetupGuide} onOpenChange={setShowSetupGuide}>
|
||||||
@@ -1320,7 +1398,7 @@ export default function LiveCamera() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-semibold tracking-tight">实时分析中枢</h1>
|
<h1 className="text-3xl font-semibold tracking-tight">实时分析中枢</h1>
|
||||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-white/70">
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-white/70">
|
||||||
摄像头启动后会持续识别正手、反手、发球、截击、高压、切削、挑高球与未知动作。系统会用 24 帧时间窗口统一动作,再把稳定动作写入片段、训练记录与评分;开启虚拟形象后,画面中的人体会被猩猩、猴子、狗、猪、猫、狐狸、熊猫、狮子、老虎或兔子形象覆盖显示。
|
摄像头启动后会持续识别正手、反手、发球、截击、高压、切削、挑高球与未知动作。系统会用 24 帧时间窗口统一动作,再把稳定动作写入片段、训练记录与评分;开启虚拟形象后,画面中的人体可切换为 10 个轻量动物替身,或 4 个免费的全身 3D Avatar 示例覆盖显示。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1455,7 +1533,7 @@ export default function LiveCamera() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium">虚拟形象替换</div>
|
<div className="text-sm font-medium">虚拟形象替换</div>
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
开启后实时画面会用 10 个免费动物虚拟形象之一覆盖主体,仅影响前端叠加显示,不改变动作识别与原视频归档。
|
开启后实时画面可使用 10 个免费动物替身,或 4 个免费的全身 3D Avatar 示例覆盖主体。当前只影响前端叠加显示,不改变动作识别与原视频归档。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -1467,7 +1545,7 @@ export default function LiveCamera() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
当前映射:{resolvedAvatarLabel}
|
当前映射:{resolvedAvatarLabel}
|
||||||
{avatarPrompt.trim() ? ` · 输入 ${avatarPrompt.trim()}` : " · 可输入猩猩、狐狸、熊猫、兔子等别名自动映射"}
|
{avatarPrompt.trim() ? ` · 输入 ${avatarPrompt.trim()}` : " · 可输入猩猩、狐狸、熊猫、兔子,或 BeachKing、Juanita 等别名自动映射"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -1477,9 +1555,12 @@ export default function LiveCamera() {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{AVATAR_PRESETS.map((preset) => (
|
{animalAvatarPresets.map((preset) => (
|
||||||
<SelectItem key={preset.key} value={preset.key}>{preset.label}</SelectItem>
|
<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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -1488,11 +1569,25 @@ export default function LiveCamera() {
|
|||||||
<Input
|
<Input
|
||||||
value={avatarPrompt}
|
value={avatarPrompt}
|
||||||
onChange={(event) => setAvatarPrompt(event.target.value)}
|
onChange={(event) => setAvatarPrompt(event.target.value)}
|
||||||
placeholder="例如 狐狸 / panda coach / dog mascot"
|
placeholder="例如 狐狸 / panda coach / BeachKing / Juanita"
|
||||||
className="h-12 rounded-2xl border-border/60"
|
className="h-12 rounded-2xl border-border/60"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -5,12 +5,14 @@
|
|||||||
### 功能更新
|
### 功能更新
|
||||||
|
|
||||||
- `/live-camera` 新增 10 个免费动物虚拟形象,可将主体实时替换为猩猩、猴子、狗、猪、猫、狐狸、熊猫、狮子、老虎、兔子
|
- `/live-camera` 新增 10 个免费动物虚拟形象,可将主体实时替换为猩猩、猴子、狗、猪、猫、狐狸、熊猫、狮子、老虎、兔子
|
||||||
|
- `/live-camera` 再新增 4 个免费的全身 3D Avatar 示例,可直接覆盖人物轮廓,并提供对应的 CC0 模型源链接
|
||||||
- 增加形象别名输入,当前可按输入内容自动映射到内置形象
|
- 增加形象别名输入,当前可按输入内容自动映射到内置形象
|
||||||
- 实时分析动作稳定器从短窗口切换为 24 帧时间窗口,降低 1-2 秒内频繁跳动作的问题
|
- 实时分析动作稳定器从短窗口切换为 24 帧时间窗口,降低 1-2 秒内频繁跳动作的问题
|
||||||
- 动作切换新增确认阶段与延迟入库逻辑,连续动作区间改为只按稳定动作聚合
|
- 动作切换新增确认阶段与延迟入库逻辑,连续动作区间改为只按稳定动作聚合
|
||||||
- 画面内新增稳定动作、原始候选、窗口占比、切换确认状态等实时状态提示
|
- 画面内新增稳定动作、原始候选、窗口占比、切换确认状态等实时状态提示
|
||||||
- 实时分析会话保存新增稳定窗口、动作切换次数、原始波动率、虚拟形象状态等指标
|
- 实时分析会话保存新增稳定窗口、动作切换次数、原始波动率、虚拟形象状态等指标
|
||||||
- 动物头像素材切换为本地集成的免费 Twemoji SVG,避免外链依赖
|
- 动物头像素材切换为本地集成的免费 Twemoji SVG,避免外链依赖
|
||||||
|
- 新增 Open Source Avatars 的本地优化透明 WebP 全身素材,减少全身替身叠加时的页面流量和首帧加载时间
|
||||||
|
|
||||||
### 测试
|
### 测试
|
||||||
|
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户