diff --git a/client/public/avatars/opensource3d/ATTRIBUTION.txt b/client/public/avatars/opensource3d/ATTRIBUTION.txt new file mode 100644 index 0000000..b0ee749 --- /dev/null +++ b/client/public/avatars/opensource3d/ATTRIBUTION.txt @@ -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. diff --git a/client/public/avatars/opensource3d/beach-king.webp b/client/public/avatars/opensource3d/beach-king.webp new file mode 100644 index 0000000..62a03b6 Binary files /dev/null and b/client/public/avatars/opensource3d/beach-king.webp differ diff --git a/client/public/avatars/opensource3d/jenny.webp b/client/public/avatars/opensource3d/jenny.webp new file mode 100644 index 0000000..768634e Binary files /dev/null and b/client/public/avatars/opensource3d/jenny.webp differ diff --git a/client/public/avatars/opensource3d/juanita.webp b/client/public/avatars/opensource3d/juanita.webp new file mode 100644 index 0000000..97f8b87 Binary files /dev/null and b/client/public/avatars/opensource3d/juanita.webp differ diff --git a/client/public/avatars/opensource3d/sport-tv.webp b/client/public/avatars/opensource3d/sport-tv.webp new file mode 100644 index 0000000..831ca62 Binary files /dev/null and b/client/public/avatars/opensource3d/sport-tv.webp differ diff --git a/client/src/lib/liveCamera.test.ts b/client/src/lib/liveCamera.test.ts index 9600146..90c517b 100644 --- a/client/src/lib/liveCamera.test.ts +++ b/client/src/lib/liveCamera.test.ts @@ -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 }; diff --git a/client/src/lib/liveCamera.ts b/client/src/lib/liveCamera.ts index d75a4b2..8234075 100644 --- a/client/src/lib/liveCamera.ts +++ b/client/src/lib/liveCamera.ts @@ -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 = { @@ -115,65 +180,115 @@ const AVATAR_VISUALS: Record = { 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(); +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; } } diff --git a/client/src/pages/LiveCamera.tsx b/client/src/pages/LiveCamera.tsx index e5877d9..2d89184 100644 --- a/client/src/pages/LiveCamera.tsx +++ b/client/src/pages/LiveCamera.tsx @@ -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() { ); + const renderAvatarShowcaseCard = (preset: AvatarPreset) => { + const active = resolvedAvatarKey === preset.key; + return ( +
+ +
+
VRM 示例源
+ {preset.modelUrl ? ( + + 查看模型 + + + ) : null} +
+
+ ); + }; + return (
@@ -1320,7 +1398,7 @@ export default function LiveCamera() {

实时分析中枢

- 摄像头启动后会持续识别正手、反手、发球、截击、高压、切削、挑高球与未知动作。系统会用 24 帧时间窗口统一动作,再把稳定动作写入片段、训练记录与评分;开启虚拟形象后,画面中的人体会被猩猩、猴子、狗、猪、猫、狐狸、熊猫、狮子、老虎或兔子形象覆盖显示。 + 摄像头启动后会持续识别正手、反手、发球、截击、高压、切削、挑高球与未知动作。系统会用 24 帧时间窗口统一动作,再把稳定动作写入片段、训练记录与评分;开启虚拟形象后,画面中的人体可切换为 10 个轻量动物替身,或 4 个免费的全身 3D Avatar 示例覆盖显示。

@@ -1455,7 +1533,7 @@ export default function LiveCamera() {
虚拟形象替换
- 开启后实时画面会用 10 个免费动物虚拟形象之一覆盖主体,仅影响前端叠加显示,不改变动作识别与原视频归档。 + 开启后实时画面可使用 10 个免费动物替身,或 4 个免费的全身 3D Avatar 示例覆盖主体。当前只影响前端叠加显示,不改变动作识别与原视频归档。
当前映射:{resolvedAvatarLabel} - {avatarPrompt.trim() ? ` · 输入 ${avatarPrompt.trim()}` : " · 可输入猩猩、狐狸、熊猫、兔子等别名自动映射"} + {avatarPrompt.trim() ? ` · 输入 ${avatarPrompt.trim()}` : " · 可输入猩猩、狐狸、熊猫、兔子,或 BeachKing、Juanita 等别名自动映射"}
@@ -1477,9 +1555,12 @@ export default function LiveCamera() { - {AVATAR_PRESETS.map((preset) => ( + {animalAvatarPresets.map((preset) => ( {preset.label} ))} + {fullBodyAvatarPresets.map((preset) => ( + {preset.label} · 3D + ))}
@@ -1488,11 +1569,25 @@ export default function LiveCamera() { setAvatarPrompt(event.target.value)} - placeholder="例如 狐狸 / panda coach / dog mascot" + placeholder="例如 狐狸 / panda coach / BeachKing / Juanita" className="h-12 rounded-2xl border-border/60" /> +
+
+
+
免费 3D 全身范例
+
+ 这 4 个示例来自 Open Source Avatars 的 CC0 集合,当前已处理成轻量透明素材用于实时覆盖;后续若切换到 VRM/three-vrm,可继续沿用同一批模型源。 +
+
+ CC0 · Open Source Avatars +
+
+ {fullBodyAvatarPresets.map(renderAvatarShowcaseCard)} +
+
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ad99d56..f59f32b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,12 +5,14 @@ ### 功能更新 - `/live-camera` 新增 10 个免费动物虚拟形象,可将主体实时替换为猩猩、猴子、狗、猪、猫、狐狸、熊猫、狮子、老虎、兔子 +- `/live-camera` 再新增 4 个免费的全身 3D Avatar 示例,可直接覆盖人物轮廓,并提供对应的 CC0 模型源链接 - 增加形象别名输入,当前可按输入内容自动映射到内置形象 - 实时分析动作稳定器从短窗口切换为 24 帧时间窗口,降低 1-2 秒内频繁跳动作的问题 - 动作切换新增确认阶段与延迟入库逻辑,连续动作区间改为只按稳定动作聚合 - 画面内新增稳定动作、原始候选、窗口占比、切换确认状态等实时状态提示 - 实时分析会话保存新增稳定窗口、动作切换次数、原始波动率、虚拟形象状态等指标 - 动物头像素材切换为本地集成的免费 Twemoji SVG,避免外链依赖 +- 新增 Open Source Avatars 的本地优化透明 WebP 全身素材,减少全身替身叠加时的页面流量和首帧加载时间 ### 测试