Skip to content

Commit 1b009f5

Browse files
authored
Merge pull request #2 from mofa-org/feat/02-categories
feat: character categories with filter
2 parents 7d0cac0 + d6df9e9 commit 1b009f5

4 files changed

Lines changed: 139 additions & 58 deletions

File tree

app.py

Lines changed: 42 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -24,75 +24,77 @@
2424
OPENAI_TTS_URL = "https://api.openai.com/v1/audio/speech"
2525
OPENAI_KEY = os.environ.get("OPENAI_API_KEY", "")
2626

27+
CATEGORIES = ["医疗健康", "教育", "文化艺术", "劳动者", "商务科技", "生活"]
28+
2729
PRESETS = [
2830
{"id": "lingnan_uncle", "name": "岭南大叔", "file": "lingnan_uncle.png", "voice": "onyx", "gender": "male",
29-
"desc": "50 岁广东老街坊", "samples": ["今天带大家品品正宗广州早茶", "来来来,饮啖茶先"]},
31+
"category": "生活", "desc": "50 岁广东老街坊", "samples": ["今天带大家品品正宗广州早茶", "来来来,饮啖茶先"]},
3032
{"id": "shenzhen_student", "name": "深圳中学生", "file": "shenzhen_student.png", "voice": "shimmer", "gender": "female",
31-
"desc": "15 岁活泼女生", "samples": ["同学们好!今天聊聊科技创新", "暑假我参加了编程夏令营"]},
33+
"category": "生活", "desc": "15 岁活泼女生", "samples": ["同学们好!今天聊聊科技创新", "暑假我参加了编程夏令营"]},
3234
{"id": "news_anchor", "name": "新闻主播", "file": "news_anchor.png", "voice": "nova", "gender": "female",
33-
"desc": "30 岁专业女主播", "samples": ["观众朋友们好,欢迎收看今天的新闻", "接下来是一则重要报道"]},
35+
"category": "商务科技", "desc": "30 岁专业女主播", "samples": ["观众朋友们好,欢迎收看今天的新闻", "接下来是一则重要报道"]},
3436
{"id": "tech_engineer", "name": "IT工程师", "file": "tech_engineer.png", "voice": "echo", "gender": "male",
35-
"desc": "28 岁码农小王", "samples": ["今天来聊聊大模型的最新进展", "这个 bug 我找了三天终于搞定了"]},
37+
"category": "商务科技", "desc": "28 岁码农小王", "samples": ["今天来聊聊大模型的最新进展", "这个 bug 我找了三天终于搞定了"]},
3638
{"id": "retired_professor", "name": "退休教授", "file": "retired_professor.png", "voice": "onyx", "gender": "male",
37-
"desc": "65 岁历史学者", "samples": ["让我们回顾一下这段波澜壮阔的历史", "读书破万卷,下笔如有神"]},
39+
"category": "教育", "desc": "65 岁历史学者", "samples": ["让我们回顾一下这段波澜壮阔的历史", "读书破万卷,下笔如有神"]},
3840
{"id": "college_girl", "name": "大学生小林", "file": "college_girl.png", "voice": "nova", "gender": "female",
39-
"desc": "20 岁开朗学姐", "samples": ["学弟学妹们,选课有什么想问的吗?", "大学四年最重要的是找到自己的方向"]},
41+
"category": "教育", "desc": "20 岁开朗学姐", "samples": ["学弟学妹们,选课有什么想问的吗?", "大学四年最重要的是找到自己的方向"]},
4042
{"id": "fitness_coach", "name": "健身教练", "file": "fitness_coach.png", "voice": "echo", "gender": "male",
41-
"desc": "32 岁运动达人", "samples": ["来!今天做三组深蹲,跟我一起动", "健身不只是练肌肉,更是练意志力"]},
43+
"category": "生活", "desc": "32 岁运动达人", "samples": ["来!今天做三组深蹲,跟我一起动", "健身不只是练肌肉,更是练意志力"]},
4244
{"id": "doctor_li", "name": "李医生", "file": "doctor_li.png", "voice": "nova", "gender": "female",
43-
"desc": "38 岁温柔医师", "samples": ["别紧张,我来给你解释一下检查结果", "预防胜于治疗,记得定期体检"]},
45+
"category": "医疗健康", "desc": "38 岁温柔医师", "samples": ["别紧张,我来给你解释一下检查结果", "预防胜于治疗,记得定期体检"]},
4446
{"id": "hip_hop_youth", "name": "说唱少年", "file": "hip_hop_youth.png", "voice": "echo", "gender": "male",
45-
"desc": "22 岁地下说唱", "samples": ["这首歌献给所有追梦的年轻人", "我的音乐就是我的态度"]},
47+
"category": "生活", "desc": "22 岁地下说唱", "samples": ["这首歌献给所有追梦的年轻人", "我的音乐就是我的态度"]},
4648
{"id": "business_woman", "name": "商务总监", "file": "business_woman.png", "voice": "nova", "gender": "female",
47-
"desc": "42 岁职场精英", "samples": ["这个季度的业绩目标,我们一起看看", "谈判的关键是找到双赢的方案"]},
49+
"category": "商务科技", "desc": "42 岁职场精英", "samples": ["这个季度的业绩目标,我们一起看看", "谈判的关键是找到双赢的方案"]},
4850
{"id": "grandma_chen", "name": "陈奶奶", "file": "grandma_chen.png", "voice": "shimmer", "gender": "female",
49-
"desc": "75 岁慈祥老人", "samples": ["孩子们,奶奶给你们讲个故事", "做人啊,最重要的就是善良"]},
51+
"category": "生活", "desc": "75 岁慈祥老人", "samples": ["孩子们,奶奶给你们讲个故事", "做人啊,最重要的就是善良"]},
5052
{"id": "child_host", "name": "小小主持人", "file": "child_host.png", "voice": "alloy", "gender": "male",
51-
"desc": "10 岁阳光少年", "samples": ["大家好!我是今天的小主持人", "接下来请欣赏我的才艺表演"]},
53+
"category": "教育", "desc": "10 岁阳光少年", "samples": ["大家好!我是今天的小主持人", "接下来请欣赏我的才艺表演"]},
5254
{"id": "yoga_teacher", "name": "瑜伽老师", "file": "yoga_teacher.png", "voice": "shimmer", "gender": "female",
53-
"desc": "30 岁身心教练(半身)", "samples": ["深呼吸,感受当下的平静", "今天的主题是肩颈放松"]},
55+
"category": "生活", "desc": "30 岁身心教练(半身)", "samples": ["深呼吸,感受当下的平静", "今天的主题是肩颈放松"]},
5456
{"id": "street_musician", "name": "街头歌手", "file": "street_musician.png", "voice": "echo", "gender": "male",
55-
"desc": "25 岁独立音乐人(全身)", "samples": ["这首歌送给每一个在路上的人", "音乐不需要舞台,哪里都可以唱"]},
57+
"category": "文化艺术", "desc": "25 岁独立音乐人(全身)", "samples": ["这首歌送给每一个在路上的人", "音乐不需要舞台,哪里都可以唱"]},
5658
{"id": "chef_wang", "name": "王大厨", "file": "chef_wang.png", "voice": "onyx", "gender": "male",
57-
"desc": "45 岁川菜大师(半身)", "samples": ["今天教大家做一道正宗的麻婆豆腐", "火候是厨艺的灵魂"]},
59+
"category": "生活", "desc": "45 岁川菜大师(半身)", "samples": ["今天教大家做一道正宗的麻婆豆腐", "火候是厨艺的灵魂"]},
5860
{"id": "nurse_zhang", "name": "张护士", "file": "nurse_zhang.png", "voice": "nova", "gender": "female",
59-
"desc": "28 岁温暖守护者", "samples": ["别怕,打针一点都不疼", "记得按时吃药,早日康复"]},
61+
"category": "医疗健康", "desc": "28 岁温暖守护者", "samples": ["别怕,打针一点都不疼", "记得按时吃药,早日康复"]},
6062
{"id": "kindergarten_teacher", "name": "幼儿园老师", "file": "kindergarten_teacher.png", "voice": "shimmer", "gender": "female",
61-
"desc": "25 岁孩子王", "samples": ["小朋友们,今天我们来画画吧", "每个孩子都是独一无二的星星"]},
63+
"category": "教育", "desc": "25 岁孩子王", "samples": ["小朋友们,今天我们来画画吧", "每个孩子都是独一无二的星星"]},
6264
{"id": "farmer_uncle", "name": "老农大伯", "file": "farmer_uncle.png", "voice": "onyx", "gender": "male",
63-
"desc": "55 岁稻田守望者", "samples": ["今年的稻子长势不错", "种地这事,急不来,得看天"]},
65+
"category": "劳动者", "desc": "55 岁稻田守望者", "samples": ["今年的稻子长势不错", "种地这事,急不来,得看天"]},
6466
{"id": "firefighter", "name": "消防战士", "file": "firefighter.png", "voice": "echo", "gender": "male",
65-
"desc": "30 岁逆行英雄", "samples": ["我们的使命是保护每一个人的安全", "火灾预防比救火更重要"]},
67+
"category": "劳动者", "desc": "30 岁逆行英雄", "samples": ["我们的使命是保护每一个人的安全", "火灾预防比救火更重要"]},
6668
{"id": "delivery_rider", "name": "外卖小哥", "file": "delivery_rider.png", "voice": "echo", "gender": "male",
67-
"desc": "28 岁城市骑手(半身)", "samples": ["您的订单马上就到", "风里雨里,我们一直在路上"]},
69+
"category": "劳动者", "desc": "28 岁城市骑手(半身)", "samples": ["您的订单马上就到", "风里雨里,我们一直在路上"]},
6870
{"id": "elderly_calligrapher", "name": "书法老人", "file": "elderly_calligrapher.png", "voice": "onyx", "gender": "male",
69-
"desc": "75 岁翰墨传承人", "samples": ["写字如做人,一笔一划都要端正", "这幅字送给有缘人"]},
71+
"category": "文化艺术", "desc": "75 岁翰墨传承人", "samples": ["写字如做人,一笔一划都要端正", "这幅字送给有缘人"]},
7072
{"id": "deaf_artist", "name": "无声画家", "file": "deaf_artist.png", "voice": "shimmer", "gender": "female",
71-
"desc": "29 岁听障艺术家", "samples": ["我的画笔就是我的声音", "色彩能传达语言无法表达的情感"]},
73+
"category": "文化艺术", "desc": "29 岁听障艺术家", "samples": ["我的画笔就是我的声音", "色彩能传达语言无法表达的情感"]},
7274
{"id": "taxi_driver", "name": "出租车师傅", "file": "taxi_driver.png", "voice": "onyx", "gender": "male",
73-
"desc": "50 岁城市百科全书", "samples": ["我开了二十年出租,这城市的故事都装在车里", "上车吧,我带你抄近路"]},
75+
"category": "劳动者", "desc": "50 岁城市百科全书", "samples": ["我开了二十年出租,这城市的故事都装在车里", "上车吧,我带你抄近路"]},
7476
{"id": "tea_master", "name": "茶艺师", "file": "tea_master.png", "voice": "nova", "gender": "female",
75-
"desc": "42 岁传统文化守护者", "samples": ["品茶如品人生,需要慢慢体会", "这壶龙井,是今年明前的新茶"]},
77+
"category": "文化艺术", "desc": "42 岁传统文化守护者", "samples": ["品茶如品人生,需要慢慢体会", "这壶龙井,是今年明前的新茶"]},
7678
{"id": "postal_worker", "name": "乡邮员", "file": "postal_worker.png", "voice": "echo", "gender": "male",
77-
"desc": "45 岁大山信使", "samples": ["这条山路我走了二十年,每一家都认识", "再远的地方,信也能送到"]},
79+
"category": "劳动者", "desc": "45 岁大山信使", "samples": ["这条山路我走了二十年,每一家都认识", "再远的地方,信也能送到"]},
7880
{"id": "welder_woman", "name": "女焊工", "file": "welder_woman.png", "voice": "nova", "gender": "female",
79-
"desc": "35 岁钢铁玫瑰", "samples": ["焊缝要均匀,就像做人要踏实", "谁说女人不能做这行?"]},
81+
"category": "劳动者", "desc": "35 岁钢铁玫瑰", "samples": ["焊缝要均匀,就像做人要踏实", "谁说女人不能做这行?"]},
8082
{"id": "blind_musician", "name": "盲人琴师", "file": "blind_musician.png", "voice": "onyx", "gender": "male",
81-
"desc": "62 岁二胡演奏家", "samples": ["音乐是光,即使看不见也能感受到", "这首二泉映月,送给所有在黑暗中坚持的人"]},
83+
"category": "文化艺术", "desc": "62 岁二胡演奏家", "samples": ["音乐是光,即使看不见也能感受到", "这首二泉映月,送给所有在黑暗中坚持的人"]},
8284
{"id": "migrant_mom", "name": "打工妈妈", "file": "migrant_mom.png", "voice": "nova", "gender": "female",
83-
"desc": "35 岁远方的牵挂", "samples": ["孩子,妈妈在外面一切都好", "等攒够了钱,就回家陪你"]},
85+
"category": "生活", "desc": "35 岁远方的牵挂", "samples": ["孩子,妈妈在外面一切都好", "等攒够了钱,就回家陪你"]},
8486
{"id": "game_developer", "name": "游戏开发者", "file": "game_developer.png", "voice": "echo", "gender": "male",
85-
"desc": "26 岁独立开发者", "samples": ["这个游戏的灵感来自我的童年", "做游戏最重要的是好玩"]},
87+
"category": "商务科技", "desc": "26 岁独立开发者", "samples": ["这个游戏的灵感来自我的童年", "做游戏最重要的是好玩"]},
8688
{"id": "librarian", "name": "图书管理员", "file": "librarian.png", "voice": "nova", "gender": "female",
87-
"desc": "52 岁知识守门人", "samples": ["这本书我推荐了三十年", "图书馆是最公平的地方,人人都能来"]},
89+
"category": "教育", "desc": "52 岁知识守门人", "samples": ["这本书我推荐了三十年", "图书馆是最公平的地方,人人都能来"]},
8890
{"id": "fisherman", "name": "老渔民", "file": "fisherman.png", "voice": "onyx", "gender": "male",
89-
"desc": "55 岁海上老人(全身)", "samples": ["大海教会我的,比学校多得多", "今天浪不大,出海正好"]},
91+
"category": "劳动者", "desc": "55 岁海上老人(全身)", "samples": ["大海教会我的,比学校多得多", "今天浪不大,出海正好"]},
9092
{"id": "ballet_dancer", "name": "芭蕾舞者", "file": "ballet_dancer.png", "voice": "shimmer", "gender": "female",
91-
"desc": "23 岁翩翩起舞(半身)", "samples": ["每一次旋转都是和自己的对话", "台上一分钟,台下十年功"]},
93+
"category": "文化艺术", "desc": "23 岁翩翩起舞(半身)", "samples": ["每一次旋转都是和自己的对话", "台上一分钟,台下十年功"]},
9294
{"id": "construction_worker", "name": "建筑工人", "file": "construction_worker.png", "voice": "echo", "gender": "male",
93-
"desc": "35 岁城市建造者", "samples": ["这栋楼是我们一砖一瓦盖起来的", "虽然辛苦,但看到高楼拔地而起很自豪"]},
95+
"category": "劳动者", "desc": "35 岁城市建造者", "samples": ["这栋楼是我们一砖一瓦盖起来的", "虽然辛苦,但看到高楼拔地而起很自豪"]},
9496
{"id": "flower_shop_owner", "name": "花店老板娘", "file": "flower_shop_owner.png", "voice": "nova", "gender": "female",
95-
"desc": "40 岁用花传情(半身)", "samples": ["每束花都有它的语言", "今天这束向日葵,送给需要阳光的人"]},
97+
"category": "生活", "desc": "40 岁用花传情(半身)", "samples": ["每束花都有它的语言", "今天这束向日葵,送给需要阳光的人"]},
9698
]
9799

98100
VOICE_LABELS = {
@@ -123,20 +125,25 @@ def _find_preset(avatar_id):
123125
async def list_voices():
124126
return [{"id": k, "label": v} for k, v in VOICE_LABELS.items()]
125127

128+
@app.get("/api/categories")
129+
async def list_categories():
130+
return CATEGORIES
131+
126132
@app.get("/api/avatars")
127133
async def list_avatars():
128134
result = []
129135
for p in PRESETS:
130136
result.append({
131137
"id": p["id"], "name": p["name"], "preset": True,
132138
"voice": p["voice"], "gender": p["gender"],
139+
"category": p.get("category", ""),
133140
"desc": p.get("desc", ""), "samples": p.get("samples", []),
134141
})
135142
for aid, info in _load_meta().items():
136143
result.append({
137144
"id": aid, "name": info["name"], "preset": False,
138145
"voice": info.get("voice", "nova"), "gender": info.get("gender", "unknown"),
139-
"desc": "", "samples": [],
146+
"category": "", "desc": "", "samples": [],
140147
})
141148
return result
142149

static/app.js

Lines changed: 68 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ const $$ = (s) => document.querySelectorAll(s);
33

44
let selectedAvatar = null;
55
let avatarData = {};
6+
let allAvatars = [];
67
let generating = false;
78
let currentMode = "direct";
89
let currentEngine = "sadtalker";
10+
let currentCategory = null;
911
let voiceLabels = {};
1012

1113
function toast(msg, ms = 3000) {
@@ -26,31 +28,75 @@ async function loadVoices() {
2628
} catch {}
2729
}
2830

29-
async function loadAvatars() {
31+
async function loadCategories() {
3032
try {
31-
const r = await fetch("/api/avatars");
33+
const r = await fetch("/api/categories");
3234
if (!r.ok) return;
33-
const avatars = await r.json();
34-
const list = $("#avatar-list");
35-
list.innerHTML = "";
36-
avatars.forEach((a) => {
37-
avatarData[a.id] = a;
38-
const card = document.createElement("div");
39-
card.className = "avatar-card" + (selectedAvatar === a.id ? " active" : "");
40-
card.title = a.desc || a.name;
41-
card.innerHTML = `
42-
<img src="/api/avatars/${a.id}/image" alt="${a.name}" loading="lazy">
43-
<span class="avatar-name">${a.name}</span>
44-
`;
45-
card.addEventListener("click", () => {
46-
selectedAvatar = a.id;
47-
$$(".avatar-card").forEach((c) => c.classList.remove("active"));
48-
card.classList.add("active");
49-
showSamples(a);
50-
updateUI();
35+
const cats = await r.json();
36+
const container = $("#category-filter");
37+
if (!container) return;
38+
container.innerHTML = "";
39+
40+
const allChip = document.createElement("button");
41+
allChip.className = "category-chip active";
42+
allChip.textContent = "全部";
43+
allChip.addEventListener("click", () => {
44+
currentCategory = null;
45+
$$(".category-chip").forEach((c) => c.classList.remove("active"));
46+
allChip.classList.add("active");
47+
renderAvatars();
48+
});
49+
container.appendChild(allChip);
50+
51+
cats.forEach((cat) => {
52+
const chip = document.createElement("button");
53+
chip.className = "category-chip";
54+
chip.textContent = cat;
55+
chip.addEventListener("click", () => {
56+
currentCategory = cat;
57+
$$(".category-chip").forEach((c) => c.classList.remove("active"));
58+
chip.classList.add("active");
59+
renderAvatars();
5160
});
52-
list.appendChild(card);
61+
container.appendChild(chip);
5362
});
63+
} catch (e) {
64+
console.error("loadCategories:", e);
65+
}
66+
}
67+
68+
function renderAvatars() {
69+
const list = $("#avatar-list");
70+
list.innerHTML = "";
71+
const filtered = currentCategory
72+
? allAvatars.filter((a) => a.category === currentCategory)
73+
: allAvatars;
74+
filtered.forEach((a) => {
75+
const card = document.createElement("div");
76+
card.className = "avatar-card" + (selectedAvatar === a.id ? " active" : "");
77+
card.title = a.desc || a.name;
78+
card.innerHTML = `
79+
<img src="/api/avatars/${a.id}/image" alt="${a.name}" loading="lazy">
80+
<span class="avatar-name">${a.name}</span>
81+
`;
82+
card.addEventListener("click", () => {
83+
selectedAvatar = a.id;
84+
$$(".avatar-card").forEach((c) => c.classList.remove("active"));
85+
card.classList.add("active");
86+
showSamples(a);
87+
updateUI();
88+
});
89+
list.appendChild(card);
90+
});
91+
}
92+
93+
async function loadAvatars() {
94+
try {
95+
const r = await fetch("/api/avatars");
96+
if (!r.ok) return;
97+
allAvatars = await r.json();
98+
allAvatars.forEach((a) => { avatarData[a.id] = a; });
99+
renderAvatars();
54100
} catch (e) {
55101
console.error("loadAvatars:", e);
56102
}
@@ -307,6 +353,7 @@ $("#send-btn").addEventListener("click", generate);
307353
// Init
308354
(async () => {
309355
await loadVoices();
356+
await loadCategories();
310357
await loadAvatars();
311358
checkHealth();
312359
updateUI();

static/index.html

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<meta charset="UTF-8">
55
<meta name="viewport" content="width=device-width, initial-scale=1.0">
66
<title>数字人直播</title>
7-
<link rel="stylesheet" href="style.css?v=9">
7+
<link rel="stylesheet" href="style.css?v=10">
88
<link rel="preconnect" href="https://fonts.googleapis.com">
99
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600&display=swap" rel="stylesheet">
1010
</head>
@@ -24,6 +24,7 @@
2424

2525
<div class="section">
2626
<div class="section-label">选择角色</div>
27+
<div id="category-filter" class="category-filter"></div>
2728
<div id="avatar-list" class="avatar-grid"></div>
2829
</div>
2930

@@ -90,6 +91,6 @@
9091

9192
<div id="toast" class="toast hidden"></div>
9293

93-
<script src="app.js?v=9"></script>
94+
<script src="app.js?v=10"></script>
9495
</body>
9596
</html>

0 commit comments

Comments
 (0)