diff --git a/app.py b/app.py index b1c5678..ca47cbd 100644 --- a/app.py +++ b/app.py @@ -24,75 +24,77 @@ OPENAI_TTS_URL = "https://api.openai.com/v1/audio/speech" OPENAI_KEY = os.environ.get("OPENAI_API_KEY", "") +CATEGORIES = ["医疗健康", "教育", "文化艺术", "劳动者", "商务科技", "生活"] + PRESETS = [ {"id": "lingnan_uncle", "name": "岭南大叔", "file": "lingnan_uncle.png", "voice": "onyx", "gender": "male", - "desc": "50 岁广东老街坊", "samples": ["今天带大家品品正宗广州早茶", "来来来,饮啖茶先"]}, + "category": "生活", "desc": "50 岁广东老街坊", "samples": ["今天带大家品品正宗广州早茶", "来来来,饮啖茶先"]}, {"id": "shenzhen_student", "name": "深圳中学生", "file": "shenzhen_student.png", "voice": "shimmer", "gender": "female", - "desc": "15 岁活泼女生", "samples": ["同学们好!今天聊聊科技创新", "暑假我参加了编程夏令营"]}, + "category": "生活", "desc": "15 岁活泼女生", "samples": ["同学们好!今天聊聊科技创新", "暑假我参加了编程夏令营"]}, {"id": "news_anchor", "name": "新闻主播", "file": "news_anchor.png", "voice": "nova", "gender": "female", - "desc": "30 岁专业女主播", "samples": ["观众朋友们好,欢迎收看今天的新闻", "接下来是一则重要报道"]}, + "category": "商务科技", "desc": "30 岁专业女主播", "samples": ["观众朋友们好,欢迎收看今天的新闻", "接下来是一则重要报道"]}, {"id": "tech_engineer", "name": "IT工程师", "file": "tech_engineer.png", "voice": "echo", "gender": "male", - "desc": "28 岁码农小王", "samples": ["今天来聊聊大模型的最新进展", "这个 bug 我找了三天终于搞定了"]}, + "category": "商务科技", "desc": "28 岁码农小王", "samples": ["今天来聊聊大模型的最新进展", "这个 bug 我找了三天终于搞定了"]}, {"id": "retired_professor", "name": "退休教授", "file": "retired_professor.png", "voice": "onyx", "gender": "male", - "desc": "65 岁历史学者", "samples": ["让我们回顾一下这段波澜壮阔的历史", "读书破万卷,下笔如有神"]}, + "category": "教育", "desc": "65 岁历史学者", "samples": ["让我们回顾一下这段波澜壮阔的历史", "读书破万卷,下笔如有神"]}, {"id": "college_girl", "name": "大学生小林", "file": "college_girl.png", "voice": "nova", "gender": "female", - "desc": "20 岁开朗学姐", "samples": ["学弟学妹们,选课有什么想问的吗?", "大学四年最重要的是找到自己的方向"]}, + "category": "教育", "desc": "20 岁开朗学姐", "samples": ["学弟学妹们,选课有什么想问的吗?", "大学四年最重要的是找到自己的方向"]}, {"id": "fitness_coach", "name": "健身教练", "file": "fitness_coach.png", "voice": "echo", "gender": "male", - "desc": "32 岁运动达人", "samples": ["来!今天做三组深蹲,跟我一起动", "健身不只是练肌肉,更是练意志力"]}, + "category": "生活", "desc": "32 岁运动达人", "samples": ["来!今天做三组深蹲,跟我一起动", "健身不只是练肌肉,更是练意志力"]}, {"id": "doctor_li", "name": "李医生", "file": "doctor_li.png", "voice": "nova", "gender": "female", - "desc": "38 岁温柔医师", "samples": ["别紧张,我来给你解释一下检查结果", "预防胜于治疗,记得定期体检"]}, + "category": "医疗健康", "desc": "38 岁温柔医师", "samples": ["别紧张,我来给你解释一下检查结果", "预防胜于治疗,记得定期体检"]}, {"id": "hip_hop_youth", "name": "说唱少年", "file": "hip_hop_youth.png", "voice": "echo", "gender": "male", - "desc": "22 岁地下说唱", "samples": ["这首歌献给所有追梦的年轻人", "我的音乐就是我的态度"]}, + "category": "生活", "desc": "22 岁地下说唱", "samples": ["这首歌献给所有追梦的年轻人", "我的音乐就是我的态度"]}, {"id": "business_woman", "name": "商务总监", "file": "business_woman.png", "voice": "nova", "gender": "female", - "desc": "42 岁职场精英", "samples": ["这个季度的业绩目标,我们一起看看", "谈判的关键是找到双赢的方案"]}, + "category": "商务科技", "desc": "42 岁职场精英", "samples": ["这个季度的业绩目标,我们一起看看", "谈判的关键是找到双赢的方案"]}, {"id": "grandma_chen", "name": "陈奶奶", "file": "grandma_chen.png", "voice": "shimmer", "gender": "female", - "desc": "75 岁慈祥老人", "samples": ["孩子们,奶奶给你们讲个故事", "做人啊,最重要的就是善良"]}, + "category": "生活", "desc": "75 岁慈祥老人", "samples": ["孩子们,奶奶给你们讲个故事", "做人啊,最重要的就是善良"]}, {"id": "child_host", "name": "小小主持人", "file": "child_host.png", "voice": "alloy", "gender": "male", - "desc": "10 岁阳光少年", "samples": ["大家好!我是今天的小主持人", "接下来请欣赏我的才艺表演"]}, + "category": "教育", "desc": "10 岁阳光少年", "samples": ["大家好!我是今天的小主持人", "接下来请欣赏我的才艺表演"]}, {"id": "yoga_teacher", "name": "瑜伽老师", "file": "yoga_teacher.png", "voice": "shimmer", "gender": "female", - "desc": "30 岁身心教练(半身)", "samples": ["深呼吸,感受当下的平静", "今天的主题是肩颈放松"]}, + "category": "生活", "desc": "30 岁身心教练(半身)", "samples": ["深呼吸,感受当下的平静", "今天的主题是肩颈放松"]}, {"id": "street_musician", "name": "街头歌手", "file": "street_musician.png", "voice": "echo", "gender": "male", - "desc": "25 岁独立音乐人(全身)", "samples": ["这首歌送给每一个在路上的人", "音乐不需要舞台,哪里都可以唱"]}, + "category": "文化艺术", "desc": "25 岁独立音乐人(全身)", "samples": ["这首歌送给每一个在路上的人", "音乐不需要舞台,哪里都可以唱"]}, {"id": "chef_wang", "name": "王大厨", "file": "chef_wang.png", "voice": "onyx", "gender": "male", - "desc": "45 岁川菜大师(半身)", "samples": ["今天教大家做一道正宗的麻婆豆腐", "火候是厨艺的灵魂"]}, + "category": "生活", "desc": "45 岁川菜大师(半身)", "samples": ["今天教大家做一道正宗的麻婆豆腐", "火候是厨艺的灵魂"]}, {"id": "nurse_zhang", "name": "张护士", "file": "nurse_zhang.png", "voice": "nova", "gender": "female", - "desc": "28 岁温暖守护者", "samples": ["别怕,打针一点都不疼", "记得按时吃药,早日康复"]}, + "category": "医疗健康", "desc": "28 岁温暖守护者", "samples": ["别怕,打针一点都不疼", "记得按时吃药,早日康复"]}, {"id": "kindergarten_teacher", "name": "幼儿园老师", "file": "kindergarten_teacher.png", "voice": "shimmer", "gender": "female", - "desc": "25 岁孩子王", "samples": ["小朋友们,今天我们来画画吧", "每个孩子都是独一无二的星星"]}, + "category": "教育", "desc": "25 岁孩子王", "samples": ["小朋友们,今天我们来画画吧", "每个孩子都是独一无二的星星"]}, {"id": "farmer_uncle", "name": "老农大伯", "file": "farmer_uncle.png", "voice": "onyx", "gender": "male", - "desc": "55 岁稻田守望者", "samples": ["今年的稻子长势不错", "种地这事,急不来,得看天"]}, + "category": "劳动者", "desc": "55 岁稻田守望者", "samples": ["今年的稻子长势不错", "种地这事,急不来,得看天"]}, {"id": "firefighter", "name": "消防战士", "file": "firefighter.png", "voice": "echo", "gender": "male", - "desc": "30 岁逆行英雄", "samples": ["我们的使命是保护每一个人的安全", "火灾预防比救火更重要"]}, + "category": "劳动者", "desc": "30 岁逆行英雄", "samples": ["我们的使命是保护每一个人的安全", "火灾预防比救火更重要"]}, {"id": "delivery_rider", "name": "外卖小哥", "file": "delivery_rider.png", "voice": "echo", "gender": "male", - "desc": "28 岁城市骑手(半身)", "samples": ["您的订单马上就到", "风里雨里,我们一直在路上"]}, + "category": "劳动者", "desc": "28 岁城市骑手(半身)", "samples": ["您的订单马上就到", "风里雨里,我们一直在路上"]}, {"id": "elderly_calligrapher", "name": "书法老人", "file": "elderly_calligrapher.png", "voice": "onyx", "gender": "male", - "desc": "75 岁翰墨传承人", "samples": ["写字如做人,一笔一划都要端正", "这幅字送给有缘人"]}, + "category": "文化艺术", "desc": "75 岁翰墨传承人", "samples": ["写字如做人,一笔一划都要端正", "这幅字送给有缘人"]}, {"id": "deaf_artist", "name": "无声画家", "file": "deaf_artist.png", "voice": "shimmer", "gender": "female", - "desc": "29 岁听障艺术家", "samples": ["我的画笔就是我的声音", "色彩能传达语言无法表达的情感"]}, + "category": "文化艺术", "desc": "29 岁听障艺术家", "samples": ["我的画笔就是我的声音", "色彩能传达语言无法表达的情感"]}, {"id": "taxi_driver", "name": "出租车师傅", "file": "taxi_driver.png", "voice": "onyx", "gender": "male", - "desc": "50 岁城市百科全书", "samples": ["我开了二十年出租,这城市的故事都装在车里", "上车吧,我带你抄近路"]}, + "category": "劳动者", "desc": "50 岁城市百科全书", "samples": ["我开了二十年出租,这城市的故事都装在车里", "上车吧,我带你抄近路"]}, {"id": "tea_master", "name": "茶艺师", "file": "tea_master.png", "voice": "nova", "gender": "female", - "desc": "42 岁传统文化守护者", "samples": ["品茶如品人生,需要慢慢体会", "这壶龙井,是今年明前的新茶"]}, + "category": "文化艺术", "desc": "42 岁传统文化守护者", "samples": ["品茶如品人生,需要慢慢体会", "这壶龙井,是今年明前的新茶"]}, {"id": "postal_worker", "name": "乡邮员", "file": "postal_worker.png", "voice": "echo", "gender": "male", - "desc": "45 岁大山信使", "samples": ["这条山路我走了二十年,每一家都认识", "再远的地方,信也能送到"]}, + "category": "劳动者", "desc": "45 岁大山信使", "samples": ["这条山路我走了二十年,每一家都认识", "再远的地方,信也能送到"]}, {"id": "welder_woman", "name": "女焊工", "file": "welder_woman.png", "voice": "nova", "gender": "female", - "desc": "35 岁钢铁玫瑰", "samples": ["焊缝要均匀,就像做人要踏实", "谁说女人不能做这行?"]}, + "category": "劳动者", "desc": "35 岁钢铁玫瑰", "samples": ["焊缝要均匀,就像做人要踏实", "谁说女人不能做这行?"]}, {"id": "blind_musician", "name": "盲人琴师", "file": "blind_musician.png", "voice": "onyx", "gender": "male", - "desc": "62 岁二胡演奏家", "samples": ["音乐是光,即使看不见也能感受到", "这首二泉映月,送给所有在黑暗中坚持的人"]}, + "category": "文化艺术", "desc": "62 岁二胡演奏家", "samples": ["音乐是光,即使看不见也能感受到", "这首二泉映月,送给所有在黑暗中坚持的人"]}, {"id": "migrant_mom", "name": "打工妈妈", "file": "migrant_mom.png", "voice": "nova", "gender": "female", - "desc": "35 岁远方的牵挂", "samples": ["孩子,妈妈在外面一切都好", "等攒够了钱,就回家陪你"]}, + "category": "生活", "desc": "35 岁远方的牵挂", "samples": ["孩子,妈妈在外面一切都好", "等攒够了钱,就回家陪你"]}, {"id": "game_developer", "name": "游戏开发者", "file": "game_developer.png", "voice": "echo", "gender": "male", - "desc": "26 岁独立开发者", "samples": ["这个游戏的灵感来自我的童年", "做游戏最重要的是好玩"]}, + "category": "商务科技", "desc": "26 岁独立开发者", "samples": ["这个游戏的灵感来自我的童年", "做游戏最重要的是好玩"]}, {"id": "librarian", "name": "图书管理员", "file": "librarian.png", "voice": "nova", "gender": "female", - "desc": "52 岁知识守门人", "samples": ["这本书我推荐了三十年", "图书馆是最公平的地方,人人都能来"]}, + "category": "教育", "desc": "52 岁知识守门人", "samples": ["这本书我推荐了三十年", "图书馆是最公平的地方,人人都能来"]}, {"id": "fisherman", "name": "老渔民", "file": "fisherman.png", "voice": "onyx", "gender": "male", - "desc": "55 岁海上老人(全身)", "samples": ["大海教会我的,比学校多得多", "今天浪不大,出海正好"]}, + "category": "劳动者", "desc": "55 岁海上老人(全身)", "samples": ["大海教会我的,比学校多得多", "今天浪不大,出海正好"]}, {"id": "ballet_dancer", "name": "芭蕾舞者", "file": "ballet_dancer.png", "voice": "shimmer", "gender": "female", - "desc": "23 岁翩翩起舞(半身)", "samples": ["每一次旋转都是和自己的对话", "台上一分钟,台下十年功"]}, + "category": "文化艺术", "desc": "23 岁翩翩起舞(半身)", "samples": ["每一次旋转都是和自己的对话", "台上一分钟,台下十年功"]}, {"id": "construction_worker", "name": "建筑工人", "file": "construction_worker.png", "voice": "echo", "gender": "male", - "desc": "35 岁城市建造者", "samples": ["这栋楼是我们一砖一瓦盖起来的", "虽然辛苦,但看到高楼拔地而起很自豪"]}, + "category": "劳动者", "desc": "35 岁城市建造者", "samples": ["这栋楼是我们一砖一瓦盖起来的", "虽然辛苦,但看到高楼拔地而起很自豪"]}, {"id": "flower_shop_owner", "name": "花店老板娘", "file": "flower_shop_owner.png", "voice": "nova", "gender": "female", - "desc": "40 岁用花传情(半身)", "samples": ["每束花都有它的语言", "今天这束向日葵,送给需要阳光的人"]}, + "category": "生活", "desc": "40 岁用花传情(半身)", "samples": ["每束花都有它的语言", "今天这束向日葵,送给需要阳光的人"]}, ] VOICE_LABELS = { @@ -123,6 +125,10 @@ def _find_preset(avatar_id): async def list_voices(): return [{"id": k, "label": v} for k, v in VOICE_LABELS.items()] +@app.get("/api/categories") +async def list_categories(): + return CATEGORIES + @app.get("/api/avatars") async def list_avatars(): result = [] @@ -130,13 +136,14 @@ async def list_avatars(): result.append({ "id": p["id"], "name": p["name"], "preset": True, "voice": p["voice"], "gender": p["gender"], + "category": p.get("category", ""), "desc": p.get("desc", ""), "samples": p.get("samples", []), }) for aid, info in _load_meta().items(): result.append({ "id": aid, "name": info["name"], "preset": False, "voice": info.get("voice", "nova"), "gender": info.get("gender", "unknown"), - "desc": "", "samples": [], + "category": "", "desc": "", "samples": [], }) return result diff --git a/static/app.js b/static/app.js index 0ddc815..bde66c6 100644 --- a/static/app.js +++ b/static/app.js @@ -3,9 +3,11 @@ const $$ = (s) => document.querySelectorAll(s); let selectedAvatar = null; let avatarData = {}; +let allAvatars = []; let generating = false; let currentMode = "direct"; let currentEngine = "sadtalker"; +let currentCategory = null; let voiceLabels = {}; function toast(msg, ms = 3000) { @@ -26,31 +28,75 @@ async function loadVoices() { } catch {} } -async function loadAvatars() { +async function loadCategories() { try { - const r = await fetch("/api/avatars"); + const r = await fetch("/api/categories"); if (!r.ok) return; - const avatars = await r.json(); - const list = $("#avatar-list"); - list.innerHTML = ""; - avatars.forEach((a) => { - avatarData[a.id] = a; - const card = document.createElement("div"); - card.className = "avatar-card" + (selectedAvatar === a.id ? " active" : ""); - card.title = a.desc || a.name; - card.innerHTML = ` - ${a.name} - ${a.name} - `; - card.addEventListener("click", () => { - selectedAvatar = a.id; - $$(".avatar-card").forEach((c) => c.classList.remove("active")); - card.classList.add("active"); - showSamples(a); - updateUI(); + const cats = await r.json(); + const container = $("#category-filter"); + if (!container) return; + container.innerHTML = ""; + + const allChip = document.createElement("button"); + allChip.className = "category-chip active"; + allChip.textContent = "全部"; + allChip.addEventListener("click", () => { + currentCategory = null; + $$(".category-chip").forEach((c) => c.classList.remove("active")); + allChip.classList.add("active"); + renderAvatars(); + }); + container.appendChild(allChip); + + cats.forEach((cat) => { + const chip = document.createElement("button"); + chip.className = "category-chip"; + chip.textContent = cat; + chip.addEventListener("click", () => { + currentCategory = cat; + $$(".category-chip").forEach((c) => c.classList.remove("active")); + chip.classList.add("active"); + renderAvatars(); }); - list.appendChild(card); + container.appendChild(chip); }); + } catch (e) { + console.error("loadCategories:", e); + } +} + +function renderAvatars() { + const list = $("#avatar-list"); + list.innerHTML = ""; + const filtered = currentCategory + ? allAvatars.filter((a) => a.category === currentCategory) + : allAvatars; + filtered.forEach((a) => { + const card = document.createElement("div"); + card.className = "avatar-card" + (selectedAvatar === a.id ? " active" : ""); + card.title = a.desc || a.name; + card.innerHTML = ` + ${a.name} + ${a.name} + `; + card.addEventListener("click", () => { + selectedAvatar = a.id; + $$(".avatar-card").forEach((c) => c.classList.remove("active")); + card.classList.add("active"); + showSamples(a); + updateUI(); + }); + list.appendChild(card); + }); +} + +async function loadAvatars() { + try { + const r = await fetch("/api/avatars"); + if (!r.ok) return; + allAvatars = await r.json(); + allAvatars.forEach((a) => { avatarData[a.id] = a; }); + renderAvatars(); } catch (e) { console.error("loadAvatars:", e); } @@ -307,6 +353,7 @@ $("#send-btn").addEventListener("click", generate); // Init (async () => { await loadVoices(); + await loadCategories(); await loadAvatars(); checkHealth(); updateUI(); diff --git a/static/index.html b/static/index.html index 177def7..7d23bec 100644 --- a/static/index.html +++ b/static/index.html @@ -4,7 +4,7 @@ 数字人直播 - + @@ -24,6 +24,7 @@
选择角色
+
@@ -90,6 +91,6 @@ - + diff --git a/static/style.css b/static/style.css index 62c99e8..a50eb31 100644 --- a/static/style.css +++ b/static/style.css @@ -60,6 +60,32 @@ body { letter-spacing: 0.8px; } +/* ── Category filter ── */ +.category-filter { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 4px; +} +.category-chip { + padding: 4px 10px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 14px; + font-size: 0.65rem; + color: var(--text2); + cursor: pointer; + transition: all 0.15s; + font-family: var(--font); + white-space: nowrap; +} +.category-chip:hover { border-color: var(--accent); color: var(--accent); } +.category-chip.active { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + /* ── Avatar grid ── */ .avatar-grid { display: grid;