feat(gallery): lucide/flag icon redesign + community marketplace (omnivoice-gallery) (#207)
* feat(gallery): lucide icons + country flags + card redesign (replace emoji) - backend archetypes emit lucide-react icon *names* (cross-platform; emoji render inconsistently across OSes) for use-cases and the 24 featured voices. - new frontend/src/utils/archetypeIcons.jsx: name→lucide map, accent→country flag (country-flag-icons, tree-shaken to ~11), per-category color scale, color-coded avatar tile, and a CSS-animated now-playing equalizer (prefers-reduced-motion aware). - card redesign: real elevated surfaces (cards were invisible on the dark bg), avatar + name + facet sub-line, accent/flag chips, and a footer with Preview / category-colored "Use voice" / Open-in-Designer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(marketplace): community voice gallery via omnivoice-gallery submodule Offloads curated + community gallery content to the standalone debpalash/omnivoice-gallery repo — added here as a submodule for authoring, loaded at runtime via the jsDelivr CDN so the binary stays small. Content repo (seeded + pushed separately): manifest.json (24-voice starter pack generated from the featured archetypes), a JSON schema, CONTRIBUTING, and GitHub submission templates carrying consent / no-impersonation guardrails. Backend (api/routers/community.py): configurable sources (env var > file > default), CDN fetch with offline disk cache, strict validation (invalid presets and non-allow-listed audio URLs are dropped, so a bad community entry can neither crash synthesis nor fetch from an arbitrary host), filtering, the prefilled submit URL, and "use" (preset → archetype render path; voice → sha256-verified download). 11 tests. Frontend: a third gallery zone, "Community", reusing the redesigned card, plus "Submit a preset / voice" buttons opening the prefilled GitHub forms. Local-first preserved: network only on open/refresh; everything cached; the built-in generated archetypes need no network, so the gallery is never empty. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(marketplace): address review feedback on the community gallery - community_use: run the blocking manifest read + voice download in a thread (asyncio.to_thread) so they don't stall the event loop (greptile P2). - community_submit_url: validate the `source` override against an owner/repo pattern, falling back to the configured default (greptile P1 hardening). - rename the `type` query param to `item_type` (alias="type") so it no longer shadows the Python builtin (coderabbit). - frontend submit buttons use the canonical openExternal() (Tauri-aware) instead of window.open, which doesn't open the system browser in the desktop app. - lowercase the `currentcolor` CSS keyword (stylelint). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(marketplace): rename useCommunityItem -> addCommunityItem (not a hook) Avoids the use-prefix on a plain API function (rules-of-hooks smell). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9e04d5c683
commit
253122325c
@@ -0,0 +1,3 @@
|
||||
[submodule "omnivoice-gallery"]
|
||||
path = omnivoice-gallery
|
||||
url = https://github.com/debpalash/omnivoice-gallery.git
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Community gallery (marketplace) API.
|
||||
|
||||
Loads designed *presets* and recorded *voices* from configured content repos
|
||||
(default: ``debpalash/omnivoice-gallery``) over the jsDelivr CDN, caches them
|
||||
locally, validates strictly, and exposes them to the gallery.
|
||||
|
||||
Design / safety
|
||||
===============
|
||||
* **Local-first.** The network is touched only when the user opens the
|
||||
marketplace or hits refresh. Everything is cached under
|
||||
``DATA_DIR/gallery_cache`` and served offline from cache; the app's built-in
|
||||
generated archetypes need no network, so the gallery is never empty.
|
||||
* **Data only, never code.** Remote content is JSON + audio. Presets are
|
||||
validated against the engine's taxonomy and *dropped* if invalid (so a bad
|
||||
community entry can't reproduce issue #89). Audio URLs are restricted to an
|
||||
allow-list of hosts (jsDelivr / GitHub) — no arbitrary SSRF target.
|
||||
* **Reuse.** "Use a preset" renders through the same path as archetypes
|
||||
(one TTS code path).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from core import archetypes
|
||||
from core.config import DATA_DIR
|
||||
|
||||
logger = logging.getLogger("omnivoice.community")
|
||||
router = APIRouter()
|
||||
|
||||
_CACHE_DIR = Path(DATA_DIR) / "gallery_cache"
|
||||
_DEFAULT_SOURCES = ["debpalash/omnivoice-gallery"]
|
||||
_ALLOWED_AUDIO_HOSTS = {
|
||||
"cdn.jsdelivr.net", "github.com", "raw.githubusercontent.com",
|
||||
"objects.githubusercontent.com", "release-assets.githubusercontent.com",
|
||||
}
|
||||
_VALID_TOKENS = set(archetypes._VD._INSTRUCT_ALL_VALID)
|
||||
_USE_CASE_IDS = {c["id"] for c in archetypes.USE_CASES}
|
||||
_SOURCE_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$") # owner/repo only
|
||||
|
||||
|
||||
# ── Config: which content repos to load ───────────────────────────────────────
|
||||
def configured_sources() -> list[str]:
|
||||
"""Gallery sources, in priority order. Env var > config file > default."""
|
||||
env = os.environ.get("OMNIVOICE_GALLERY_SOURCES")
|
||||
if env:
|
||||
return [s.strip() for s in env.split(",") if s.strip()]
|
||||
cfg = Path(DATA_DIR) / "gallery_sources.json"
|
||||
if cfg.exists():
|
||||
try:
|
||||
data = json.loads(cfg.read_text(encoding="utf-8"))
|
||||
srcs = data.get("sources")
|
||||
if isinstance(srcs, list) and srcs:
|
||||
return [str(s) for s in srcs]
|
||||
except Exception:
|
||||
logger.warning("gallery_sources.json unreadable; using default")
|
||||
return list(_DEFAULT_SOURCES)
|
||||
|
||||
|
||||
def _manifest_url(source: str) -> str:
|
||||
return f"https://cdn.jsdelivr.net/gh/{source}@main/manifest.json"
|
||||
|
||||
|
||||
def _cache_path(source: str) -> Path:
|
||||
return _CACHE_DIR / source.replace("/", "__") / "manifest.json"
|
||||
|
||||
|
||||
def _safe_audio_url(url: str) -> bool:
|
||||
try:
|
||||
u = urlparse(url or "")
|
||||
return u.scheme == "https" and (u.hostname in _ALLOWED_AUDIO_HOSTS)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def is_valid_instruct(instruct: str) -> bool:
|
||||
toks = [t.strip() for t in (instruct or "").split(",") if t.strip()]
|
||||
return bool(toks) and all(t in _VALID_TOKENS for t in toks)
|
||||
|
||||
|
||||
def validate_item(raw: dict) -> Optional[dict]:
|
||||
"""Return a normalized item, or None if it must be dropped."""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
it = dict(raw)
|
||||
if it.get("type") not in ("preset", "voice"):
|
||||
return None
|
||||
if not it.get("id") or not it.get("name"):
|
||||
return None
|
||||
if it.get("use_case") not in _USE_CASE_IDS:
|
||||
return None
|
||||
if it["type"] == "preset" and not is_valid_instruct(it.get("instruct", "")):
|
||||
return None # would crash synthesis — drop it
|
||||
if it["type"] == "voice" and not _safe_audio_url((it.get("audio") or {}).get("url", "")):
|
||||
return None
|
||||
it.setdefault("facets", {})
|
||||
it.setdefault("icon", archetypes._USE_ICON.get(it["use_case"], "Sparkles"))
|
||||
it.setdefault("language", it.get("facets", {}).get("lang", "English"))
|
||||
it["is_community"] = it.get("source") != "starter"
|
||||
return it
|
||||
|
||||
|
||||
def _merge(manifests: list[tuple[str, Optional[dict]]]) -> tuple[list, list]:
|
||||
items, packs, seen = [], [], set()
|
||||
for src, m in manifests:
|
||||
if not m:
|
||||
continue
|
||||
for raw in (m.get("items") or []):
|
||||
v = validate_item(raw)
|
||||
if v and v["id"] not in seen:
|
||||
v["_source_repo"] = src
|
||||
seen.add(v["id"])
|
||||
items.append(v)
|
||||
for p in (m.get("packs") or []):
|
||||
if isinstance(p, dict):
|
||||
packs.append({**p, "_source_repo": src})
|
||||
return items, packs
|
||||
|
||||
|
||||
def _fetch_manifest(source: str, refresh: bool) -> Optional[dict]:
|
||||
"""Return a source's manifest from cache, or fetch + cache it. None if both fail."""
|
||||
cache = _cache_path(source)
|
||||
if not refresh and cache.exists():
|
||||
try:
|
||||
return json.loads(cache.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import httpx
|
||||
with httpx.Client(timeout=15.0, follow_redirects=True) as client:
|
||||
resp = client.get(_manifest_url(source))
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text(json.dumps(data), encoding="utf-8")
|
||||
return data
|
||||
except Exception as e: # offline / 404 / bad json
|
||||
logger.warning("manifest fetch failed for %s: %s", source, e)
|
||||
if cache.exists():
|
||||
try:
|
||||
return json.loads(cache.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _load(refresh: bool) -> tuple[list[str], list, list, bool]:
|
||||
srcs = configured_sources()
|
||||
manifests = [(s, _fetch_manifest(s, refresh)) for s in srcs]
|
||||
items, packs = _merge(manifests)
|
||||
offline = all(m is None for _, m in manifests)
|
||||
return srcs, items, packs, offline
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
@router.get("/community/sources")
|
||||
def community_sources():
|
||||
"""The content repos the gallery loads from (default: omnivoice-gallery)."""
|
||||
return {"sources": configured_sources()}
|
||||
|
||||
|
||||
@router.get("/community/manifest")
|
||||
def community_manifest(refresh: bool = Query(False)):
|
||||
srcs, items, packs, offline = _load(refresh)
|
||||
return {"sources": srcs, "packs": packs, "items": items, "count": len(items), "offline": offline}
|
||||
|
||||
|
||||
@router.get("/community/items")
|
||||
def community_items(
|
||||
use_case: Optional[str] = None,
|
||||
gender: Optional[str] = None,
|
||||
item_type: Optional[str] = Query(None, alias="type"),
|
||||
lang: Optional[str] = None,
|
||||
q: Optional[str] = None,
|
||||
limit: int = Query(60, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
refresh: bool = Query(False),
|
||||
):
|
||||
_, items, _, _ = _load(refresh)
|
||||
|
||||
def keep(it: dict) -> bool:
|
||||
f = it.get("facets", {})
|
||||
if use_case and it.get("use_case") != use_case:
|
||||
return False
|
||||
if gender and f.get("gender") != gender:
|
||||
return False
|
||||
if item_type and it.get("type") != item_type:
|
||||
return False
|
||||
if lang and it.get("language") != lang:
|
||||
return False
|
||||
if q and q.lower() not in (it.get("name", "").lower()):
|
||||
return False
|
||||
return True
|
||||
|
||||
items = [it for it in items if keep(it)]
|
||||
return {"total": len(items), "limit": limit, "offset": offset, "items": items[offset:offset + limit]}
|
||||
|
||||
|
||||
@router.get("/community/submit-url")
|
||||
def community_submit_url(item_type: str = Query("preset", alias="type"), source: Optional[str] = Query(None)):
|
||||
"""Build the prefilled GitHub submission URL (server-free, local-first)."""
|
||||
src = source or configured_sources()[0]
|
||||
if not _SOURCE_RE.match(src or ""):
|
||||
src = configured_sources()[0] # ignore a malformed/untrusted source override
|
||||
template = "preset-submission.yml" if item_type == "preset" else "voice-submission.yml"
|
||||
return {"url": f"https://github.com/{src}/issues/new?template={template}"}
|
||||
|
||||
|
||||
@router.post("/community/items/{item_id}/use")
|
||||
async def community_use(item_id: str, name: Optional[str] = Query(None)):
|
||||
"""Materialize a community item into a reusable voice profile.
|
||||
|
||||
Preset → render through the archetype engine. Voice → download the
|
||||
(host-allow-listed, SHA-256-verified) reference clip. Both create a
|
||||
``voice_profiles`` row usable everywhere voices are picked.
|
||||
"""
|
||||
_, items, _, _ = await asyncio.to_thread(_load, False)
|
||||
item = next((it for it in items if it["id"] == item_id), None)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Item not found in the gallery.")
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from core import event_bus
|
||||
from core.db import db_conn
|
||||
from core.config import VOICES_DIR
|
||||
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
audio_filename = f"{profile_id}.wav"
|
||||
audio_path = Path(VOICES_DIR) / audio_filename
|
||||
profile_name = (name or item["name"]).strip() or item["name"]
|
||||
instruct = item.get("instruct", "") if item["type"] == "preset" else ""
|
||||
ref_text = item.get("sample_script") or (item.get("audio") or {}).get("ref_text", "")
|
||||
|
||||
try:
|
||||
if item["type"] == "preset":
|
||||
from api.routers.archetypes import _render_archetype_wav
|
||||
pseudo = {
|
||||
"instruct": instruct,
|
||||
"language": item.get("language", "English"),
|
||||
"sample_script": ref_text or "Hello — this is a preview of this voice.",
|
||||
}
|
||||
await _render_archetype_wav(pseudo, audio_path)
|
||||
else: # voice — download the reference clip (off the event loop)
|
||||
await asyncio.to_thread(_download_voice_audio, item, audio_path)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Community 'use' failed", exc_info=True)
|
||||
raise HTTPException(status_code=503, detail=f"Couldn't add this voice right now. Error: {e}")
|
||||
|
||||
try:
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(profile_id, profile_name, audio_filename, ref_text, instruct,
|
||||
item.get("language", "Auto"), None, item["id"], time.time()),
|
||||
)
|
||||
except Exception:
|
||||
with __import__("contextlib").suppress(OSError):
|
||||
os.remove(audio_path)
|
||||
raise
|
||||
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
||||
return {"profile_id": profile_id, "name": profile_name}
|
||||
|
||||
|
||||
def _download_voice_audio(item: dict, out_path: Path) -> None:
|
||||
import hashlib
|
||||
audio = item.get("audio") or {}
|
||||
url = audio.get("url", "")
|
||||
if not _safe_audio_url(url):
|
||||
raise HTTPException(status_code=400, detail="Voice audio URL is not from an allowed host.")
|
||||
import httpx
|
||||
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
|
||||
resp = client.get(url)
|
||||
resp.raise_for_status()
|
||||
data = resp.content
|
||||
expected = audio.get("sha256")
|
||||
if expected and hashlib.sha256(data).hexdigest() != expected:
|
||||
raise HTTPException(status_code=502, detail="Downloaded voice failed its integrity check.")
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_bytes(data)
|
||||
+34
-31
@@ -82,14 +82,17 @@ _DIALECTS_SORTED = sorted(_CAT[5]) # 12 Chinese dialects
|
||||
|
||||
|
||||
# ── Use-case categories (replaces the named-real-person buckets) ──────────────
|
||||
# `icon` values are lucide-react component names; the frontend maps them to SVG
|
||||
# components (see frontend/src/utils/archetypeIcons.jsx). No emoji — they render
|
||||
# inconsistently across OSes.
|
||||
USE_CASES = [
|
||||
{"id": "narration", "name": "Narration & Story", "icon": "📖"},
|
||||
{"id": "conversational", "name": "Conversational", "icon": "💬"},
|
||||
{"id": "characters", "name": "Characters & Animation", "icon": "🎭"},
|
||||
{"id": "social", "name": "Social Media", "icon": "📱"},
|
||||
{"id": "entertainment", "name": "Entertainment & TV", "icon": "📺"},
|
||||
{"id": "advertisement", "name": "Advertisement", "icon": "📣"},
|
||||
{"id": "informative", "name": "Informative & Educational", "icon": "🎓"},
|
||||
{"id": "narration", "name": "Narration & Story", "icon": "BookOpen"},
|
||||
{"id": "conversational", "name": "Conversational", "icon": "MessagesSquare"},
|
||||
{"id": "characters", "name": "Characters & Animation", "icon": "Drama"},
|
||||
{"id": "social", "name": "Social Media", "icon": "Smartphone"},
|
||||
{"id": "entertainment", "name": "Entertainment & TV", "icon": "Tv"},
|
||||
{"id": "advertisement", "name": "Advertisement", "icon": "Megaphone"},
|
||||
{"id": "informative", "name": "Informative & Educational", "icon": "GraduationCap"},
|
||||
]
|
||||
_USE_ICON = {c["id"]: c["icon"] for c in USE_CASES}
|
||||
|
||||
@@ -260,36 +263,36 @@ def _build(gender, age, pitch, *, accent=None, dialect=None, whisper=False,
|
||||
# (gender, age, pitch, accent, whisper, use_case, name, icon)
|
||||
_FEATURED_SPEC = [
|
||||
# Narration & Story
|
||||
("female", "middle-aged", "low pitch", "british accent", False, "narration", "The Librarian", "📚"),
|
||||
("male", "middle-aged", "low pitch", "american accent", False, "narration", "The Documentarian", "🎙️"),
|
||||
("female", "middle-aged", "low pitch", None, True, "narration", "The Calm Guide", "🌙"),
|
||||
("male", "elderly", "low pitch", "british accent", False, "narration", "The Storyteller", "🧙"),
|
||||
("female", "middle-aged", "low pitch", "british accent", False, "narration", "The Librarian", "Library"),
|
||||
("male", "middle-aged", "low pitch", "american accent", False, "narration", "The Documentarian", "Mic"),
|
||||
("female", "middle-aged", "low pitch", None, True, "narration", "The Calm Guide", "Moon"),
|
||||
("male", "elderly", "low pitch", "british accent", False, "narration", "The Storyteller", "Wand2"),
|
||||
# Conversational
|
||||
("female", "young adult", "moderate pitch", "american accent", False, "conversational", "The Neighbor", "😊"),
|
||||
("female", "young adult", "moderate pitch", "indian accent", False, "conversational", "The Helpdesk", "🎧"),
|
||||
("male", "young adult", "moderate pitch", "australian accent", False, "conversational", "The Mate", "🙂"),
|
||||
("female", "middle-aged", "moderate pitch", "canadian accent", False, "conversational", "The Companion", "☕"),
|
||||
("female", "young adult", "moderate pitch", "american accent", False, "conversational", "The Neighbor", "Smile"),
|
||||
("female", "young adult", "moderate pitch", "indian accent", False, "conversational", "The Helpdesk", "Headphones"),
|
||||
("male", "young adult", "moderate pitch", "australian accent", False, "conversational", "The Mate", "MessageSquare"),
|
||||
("female", "middle-aged", "moderate pitch", "canadian accent", False, "conversational", "The Companion", "Coffee"),
|
||||
# Characters & Animation
|
||||
("male", "elderly", "very low pitch", None, False, "characters", "Captain Crusty", "☠️"),
|
||||
(None, "teenager", "high pitch", None, False, "characters", "Junior Quacks", "🦆"),
|
||||
("male", "young adult", "high pitch", "american accent", False, "characters", "The Champion", "🦸"),
|
||||
("female", "child", "very high pitch", None, False, "characters", "The Pixie", "🧚"),
|
||||
("male", "middle-aged", "very low pitch", None, False, "characters", "The Ogre", "👹"),
|
||||
("male", "elderly", "very low pitch", None, False, "characters", "Captain Crusty", "Skull"),
|
||||
(None, "teenager", "high pitch", None, False, "characters", "Junior Quacks", "Bird"),
|
||||
("male", "young adult", "high pitch", "american accent", False, "characters", "The Champion", "Shield"),
|
||||
("female", "child", "very high pitch", None, False, "characters", "The Pixie", "Sparkles"),
|
||||
("male", "middle-aged", "very low pitch", None, False, "characters", "The Ogre", "Ghost"),
|
||||
# Social Media
|
||||
("female", "young adult", "high pitch", "australian accent", False, "social", "The Podcaster", "🎤"),
|
||||
("male", "young adult", "very high pitch", "american accent", False, "social", "The Hype Host", "⚡"),
|
||||
("female", "young adult", "high pitch", None, False, "social", "The Vlogger", "📱"),
|
||||
("female", "young adult", "high pitch", "australian accent", False, "social", "The Podcaster", "Radio"),
|
||||
("male", "young adult", "very high pitch", "american accent", False, "social", "The Hype Host", "Zap"),
|
||||
("female", "young adult", "high pitch", None, False, "social", "The Vlogger", "Video"),
|
||||
# Entertainment & TV
|
||||
("male", "middle-aged", "moderate pitch", "american accent", False, "entertainment", "The Anchor", "📺"),
|
||||
("male", "middle-aged", "high pitch", "british accent", False, "entertainment", "The Commentator", "🏟️"),
|
||||
("male", "middle-aged", "moderate pitch", None, False, "entertainment", "The Game Host", "🎬"),
|
||||
("male", "middle-aged", "moderate pitch", "american accent", False, "entertainment", "The Anchor", "Tv"),
|
||||
("male", "middle-aged", "high pitch", "british accent", False, "entertainment", "The Commentator", "Trophy"),
|
||||
("male", "middle-aged", "moderate pitch", None, False, "entertainment", "The Game Host", "Clapperboard"),
|
||||
# Advertisement
|
||||
("male", "middle-aged", "low pitch", None, False, "advertisement", "The Promo Voice", "📣"),
|
||||
("female", "middle-aged", "moderate pitch", "british accent", False, "advertisement", "The Luxe", "💎"),
|
||||
("female", "young adult", "high pitch", "american accent", False, "advertisement", "The Upbeat", "✨"),
|
||||
("male", "middle-aged", "low pitch", None, False, "advertisement", "The Promo Voice", "Megaphone"),
|
||||
("female", "middle-aged", "moderate pitch", "british accent", False, "advertisement", "The Luxe", "Gem"),
|
||||
("female", "young adult", "high pitch", "american accent", False, "advertisement", "The Upbeat", "Music"),
|
||||
# Informative & Educational
|
||||
("female", "middle-aged", "moderate pitch", "american accent", False, "informative", "The Teacher", "👩🏫"),
|
||||
("male", "young adult", "moderate pitch", "british accent", False, "informative", "The Explainer", "💡"),
|
||||
("female", "middle-aged", "moderate pitch", "american accent", False, "informative", "The Teacher", "GraduationCap"),
|
||||
("male", "young adult", "moderate pitch", "british accent", False, "informative", "The Explainer", "Lightbulb"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -299,6 +299,7 @@ from api.routers import (
|
||||
setup,
|
||||
gallery,
|
||||
archetypes,
|
||||
community,
|
||||
batch,
|
||||
watermark,
|
||||
events,
|
||||
@@ -626,6 +627,7 @@ app.include_router(stories.router)
|
||||
app.include_router(setup.router)
|
||||
app.include_router(gallery.router)
|
||||
app.include_router(archetypes.router)
|
||||
app.include_router(community.router)
|
||||
app.include_router(batch.router)
|
||||
app.include_router(watermark.router)
|
||||
app.include_router(events.router)
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Tests for the community gallery (marketplace) loader.
|
||||
|
||||
Covers the no-network surface: strict item validation (invalid presets and
|
||||
unsafe audio URLs are dropped so they can never crash synthesis or fetch from
|
||||
an arbitrary host), manifest merge/dedup, offline cache reads, filtering, and
|
||||
the prefilled submit URL. The render/download paths need the model/network and
|
||||
are exercised at runtime.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
_TMP = tempfile.mkdtemp(prefix="omnivoice_community_test_")
|
||||
_config = types.ModuleType("core.config")
|
||||
_config.DATA_DIR = _TMP
|
||||
_config.VOICES_DIR = str(Path(_TMP) / "voices")
|
||||
_config.OUTPUTS_DIR = str(Path(_TMP) / "outputs")
|
||||
sys.modules["core.config"] = _config
|
||||
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from api.routers import community # noqa: E402
|
||||
|
||||
_FIXTURE = {
|
||||
"schema_version": 1,
|
||||
"items": [
|
||||
{"id": "p1", "type": "preset", "name": "Test Narrator", "use_case": "narration",
|
||||
"facets": {"gender": "female", "age": "middle-aged", "pitch": "low pitch", "lang": "English"},
|
||||
"instruct": "female, middle-aged, low pitch", "language": "English", "source": "community"},
|
||||
# invalid instruct token -> dropped
|
||||
{"id": "p_bad", "type": "preset", "name": "Bad", "use_case": "narration",
|
||||
"instruct": "female, raspy, smoky"},
|
||||
# unsafe audio host -> dropped
|
||||
{"id": "v_bad", "type": "voice", "name": "Sketchy", "use_case": "narration",
|
||||
"audio": {"url": "http://evil.example.com/x.wav"}},
|
||||
# valid voice (allow-listed host)
|
||||
{"id": "v1", "type": "voice", "name": "Recorded One", "use_case": "narration",
|
||||
"facets": {"gender": "male", "lang": "English"},
|
||||
"audio": {"url": "https://github.com/debpalash/omnivoice-gallery/releases/download/voices-v1/v1.wav"}},
|
||||
# unknown use_case -> dropped
|
||||
{"id": "u1", "type": "preset", "name": "Mystery", "use_case": "banana", "instruct": "male"},
|
||||
],
|
||||
"packs": [{"id": "starter", "name": "Starter", "item_ids": ["p1"]}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def seed_cache():
|
||||
cache = community._cache_path("debpalash/omnivoice-gallery")
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text(json.dumps(_FIXTURE), encoding="utf-8")
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
app = FastAPI()
|
||||
app.include_router(community.router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ── pure validation ───────────────────────────────────────────────────────────
|
||||
def test_valid_preset_kept():
|
||||
assert community.validate_item(_FIXTURE["items"][0]) is not None
|
||||
|
||||
|
||||
def test_invalid_instruct_dropped():
|
||||
assert community.validate_item(_FIXTURE["items"][1]) is None
|
||||
|
||||
|
||||
def test_unsafe_audio_url_dropped():
|
||||
assert community.validate_item(_FIXTURE["items"][2]) is None
|
||||
|
||||
|
||||
def test_unknown_use_case_dropped():
|
||||
assert community.validate_item(_FIXTURE["items"][4]) is None
|
||||
|
||||
|
||||
def test_is_valid_instruct():
|
||||
assert community.is_valid_instruct("male, elderly, very low pitch")
|
||||
assert not community.is_valid_instruct("male, sultry")
|
||||
assert not community.is_valid_instruct("")
|
||||
|
||||
|
||||
# ── merge keeps only valid items ──────────────────────────────────────────────
|
||||
def test_merge_drops_invalid_and_dedups():
|
||||
items, packs = community._merge([("debpalash/omnivoice-gallery", _FIXTURE)])
|
||||
ids = {i["id"] for i in items}
|
||||
assert ids == {"p1", "v1"}
|
||||
assert packs and packs[0]["id"] == "starter"
|
||||
|
||||
|
||||
# ── endpoints (served from cache, no network) ─────────────────────────────────
|
||||
def test_manifest_endpoint_from_cache(client):
|
||||
body = client.get("/community/manifest").json()
|
||||
assert body["count"] == 2
|
||||
assert {i["id"] for i in body["items"]} == {"p1", "v1"}
|
||||
assert "debpalash/omnivoice-gallery" in body["sources"]
|
||||
|
||||
|
||||
def test_items_filter_by_type(client):
|
||||
body = client.get("/community/items", params={"type": "voice"}).json()
|
||||
assert [i["id"] for i in body["items"]] == ["v1"]
|
||||
|
||||
|
||||
def test_items_filter_by_use_case(client):
|
||||
body = client.get("/community/items", params={"use_case": "narration"}).json()
|
||||
assert body["total"] == 2
|
||||
|
||||
|
||||
def test_sources_endpoint(client):
|
||||
assert client.get("/community/sources").json()["sources"]
|
||||
|
||||
|
||||
def test_submit_url(client):
|
||||
preset = client.get("/community/submit-url", params={"type": "preset"}).json()["url"]
|
||||
voice = client.get("/community/submit-url", params={"type": "voice"}).json()["url"]
|
||||
assert "preset-submission.yml" in preset and "omnivoice-gallery" in preset
|
||||
assert "voice-submission.yml" in voice
|
||||
@@ -38,6 +38,7 @@
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"country-flag-icons": "^1.6.17",
|
||||
"i18next": "^26.0.8",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
@@ -500,6 +501,8 @@
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"country-flag-icons": ["country-flag-icons@1.6.17", "", {}, "sha512-Nmik0289ZVZSI3c7mJR/amg6DyY7Z59b0sTFSKayeX72mHfPzCPJygwJs2pYgQULzuAyWeCUgwAJ+Dq8OR+JFw=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"country-flag-icons": "^1.6.17",
|
||||
"i18next": "^26.0.8",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Community gallery (marketplace) API — designed presets + recorded voices
|
||||
* loaded from the omnivoice-gallery content repo via the backend
|
||||
* (CDN-fetched, cached, validated). See backend/api/routers/community.py.
|
||||
*/
|
||||
import { apiJson } from './client';
|
||||
|
||||
export interface CommunityItem {
|
||||
id: string;
|
||||
type: 'preset' | 'voice';
|
||||
name: string;
|
||||
icon: string;
|
||||
use_case: string;
|
||||
facets: Record<string, any>;
|
||||
instruct?: string;
|
||||
language: string;
|
||||
sample_script?: string;
|
||||
audio?: { url: string; ref_text?: string; duration?: number; sha256?: string };
|
||||
author?: string;
|
||||
license?: string;
|
||||
source?: string;
|
||||
is_community?: boolean;
|
||||
}
|
||||
|
||||
export interface CommunityPage {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
items: CommunityItem[];
|
||||
}
|
||||
|
||||
export interface CommunityManifest {
|
||||
sources: string[];
|
||||
packs: any[];
|
||||
items: CommunityItem[];
|
||||
count: number;
|
||||
offline: boolean;
|
||||
}
|
||||
|
||||
export interface CommunityFilters {
|
||||
use_case?: string | null;
|
||||
gender?: string | null;
|
||||
type?: string | null;
|
||||
lang?: string | null;
|
||||
q?: string | null;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
refresh?: boolean;
|
||||
}
|
||||
|
||||
export const listCommunityItems = (filters: CommunityFilters = {}): Promise<CommunityPage> => {
|
||||
const qs = new URLSearchParams();
|
||||
Object.entries(filters).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && v !== '') qs.set(k, String(v));
|
||||
});
|
||||
const q = qs.toString();
|
||||
return apiJson(`/community/items${q ? `?${q}` : ''}`);
|
||||
};
|
||||
|
||||
export const communityManifest = (refresh = false): Promise<CommunityManifest> =>
|
||||
apiJson(`/community/manifest?refresh=${refresh}`);
|
||||
|
||||
export const communitySources = (): Promise<{ sources: string[] }> => apiJson('/community/sources');
|
||||
|
||||
export const communitySubmitUrl = (type: 'preset' | 'voice'): Promise<{ url: string }> =>
|
||||
apiJson(`/community/submit-url?type=${type}`);
|
||||
|
||||
export const addCommunityItem = (id: string, name?: string): Promise<{ profile_id: string; name: string }> => {
|
||||
const q = name ? `?name=${encodeURIComponent(name)}` : '';
|
||||
return apiJson(`/community/items/${encodeURIComponent(id)}/use${q}`, { method: 'POST' });
|
||||
};
|
||||
@@ -10,6 +10,8 @@ import * as setupApi from './setup';
|
||||
import * as galleryApi from './gallery';
|
||||
import * as archetypesApi from './archetypes';
|
||||
import type { ArchetypeFilters } from './archetypes';
|
||||
import * as communityApi from './community';
|
||||
import type { CommunityFilters } from './community';
|
||||
|
||||
// ── Keys (prevents typos, enables targeted invalidation) ─────────────────
|
||||
export const queryKeys = {
|
||||
@@ -26,6 +28,8 @@ export const queryKeys = {
|
||||
galleryCategories: ['gallery-categories'] as const,
|
||||
archetypeCategories: ['archetype-categories'] as const,
|
||||
archetypes: (filters?: any) => ['archetypes', filters] as const,
|
||||
communityItems: (filters?: any) => ['community-items', filters] as const,
|
||||
communityManifest: (refresh?: boolean) => ['community-manifest', !!refresh] as const,
|
||||
};
|
||||
|
||||
// ── Polling queries (sysinfo, model status, logs) ────────────────────────
|
||||
@@ -158,6 +162,24 @@ export function useArchetypes(filters: ArchetypeFilters = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Community gallery (marketplace) ───────────────────────────────────────
|
||||
export function useCommunityItems(filters: CommunityFilters = {}) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.communityItems(filters),
|
||||
queryFn: () => communityApi.listCommunityItems(filters),
|
||||
staleTime: 5 * 60_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCommunityManifest(refresh = false) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.communityManifest(refresh),
|
||||
queryFn: () => communityApi.communityManifest(refresh),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mutations ────────────────────────────────────────────────────────────
|
||||
|
||||
export function useInstallModel() {
|
||||
|
||||
@@ -372,29 +372,68 @@
|
||||
.gallery-content.gallery-scroll { overflow-y: auto; }
|
||||
.archetype-section { margin-bottom: 14px; }
|
||||
.count-badge { margin-left: 6px; padding: 1px 7px; border-radius: 10px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.65rem; font-weight: 400; }
|
||||
.archetype-grid.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 8px; }
|
||||
.archetype-grid.list { display: flex; flex-direction: column; gap: 5px; }
|
||||
.archetype-grid.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(248px, 1fr)); gap: 10px; }
|
||||
.archetype-grid.list { display: flex; flex-direction: column; gap: 6px; }
|
||||
|
||||
.archetype-card { position: relative; display: flex; flex-direction: column; gap: 8px; padding: 12px; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 10px; transition: border-color 0.15s ease, background 0.15s ease; }
|
||||
.archetype-card:hover { border-color: var(--border-hover); }
|
||||
.archetype-card.list { flex-direction: row; align-items: center; padding: 8px 10px; }
|
||||
/* Card surface — a real, elevated tile so cards read as cards on the dark bg. */
|
||||
.archetype-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 13px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.038), rgba(255, 255, 255, 0.012));
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 13px;
|
||||
transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease;
|
||||
}
|
||||
.archetype-card:hover { transform: translateY(-2px); border-color: rgba(255, 255, 255, 0.16); box-shadow: 0 6px 22px rgba(0, 0, 0, 0.4); }
|
||||
.archetype-card.playing { border-color: var(--card-accent, var(--accent)); box-shadow: 0 0 0 1px var(--card-accent, var(--accent)), 0 6px 22px rgba(0, 0, 0, 0.4); }
|
||||
|
||||
.fav-btn { position: absolute; top: 8px; right: 8px; display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border: none; background: transparent; color: var(--text-secondary); border-radius: 6px; cursor: pointer; }
|
||||
.fav-btn:hover { color: var(--accent); }
|
||||
/* Header: avatar tile + title + favorite */
|
||||
.arch-head { display: flex; align-items: center; gap: 10px; }
|
||||
.arch-avatar { position: relative; flex-shrink: 0; display: flex; align-items: center; justify-content: center; border: 1px solid transparent; border-radius: 11px; }
|
||||
.arch-avatar-flag { position: absolute; right: -4px; bottom: -4px; display: flex; line-height: 0; border-radius: 3px; overflow: hidden; box-shadow: 0 0 0 2px var(--bg-tertiary, #1d2021); }
|
||||
.accent-flag { display: block; border-radius: 2px; }
|
||||
.flag-globe { color: var(--text-secondary); }
|
||||
.arch-title { flex: 1; min-width: 0; }
|
||||
.archetype-name { font-size: 0.84rem; font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.archetype-sub { font-size: 0.68rem; color: var(--text-secondary); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.fav-btn { flex-shrink: 0; display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; background: transparent; color: var(--text-secondary); border-radius: 7px; cursor: pointer; transition: color 0.15s ease, background 0.15s ease; }
|
||||
.fav-btn:hover { color: #fabd2f; background: rgba(255, 255, 255, 0.05); }
|
||||
.fav-btn.on { color: #fabd2f; }
|
||||
.archetype-card.list .fav-btn { position: static; order: 3; }
|
||||
|
||||
.archetype-play { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; flex-shrink: 0; border-radius: 50%; border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-primary); cursor: pointer; }
|
||||
.archetype-play:hover { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.archetype-card.list .archetype-play { width: 30px; height: 30px; }
|
||||
/* Facet chips (accent chip carries its flag) */
|
||||
.archetype-chips { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.facet-chip { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border-radius: 7px; background: rgba(255, 255, 255, 0.05); color: var(--text-secondary); font-size: 0.64rem; line-height: 1.6; }
|
||||
.facet-chip.with-flag { padding-left: 5px; }
|
||||
|
||||
.archetype-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.archetype-name { display: flex; align-items: center; gap: 6px; font-size: 0.8rem; font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.archetype-icon { font-size: 0.95rem; line-height: 1; }
|
||||
.archetype-chips { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.facet-chip { padding: 1px 7px; border-radius: 8px; background: var(--bg-secondary); color: var(--text-secondary); font-size: 0.62rem; }
|
||||
.archetype-actions { display: flex; gap: 5px; }
|
||||
.archetype-card.list .archetype-actions { margin-left: auto; }
|
||||
/* Footer actions */
|
||||
.arch-foot { display: flex; align-items: center; gap: 6px; margin-top: auto; }
|
||||
.preview-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 11px; border: 1px solid rgba(255, 255, 255, 0.09); background: rgba(255, 255, 255, 0.03); color: var(--text-primary); border-radius: 8px; font-size: 0.7rem; cursor: pointer; transition: border-color 0.15s ease, color 0.15s ease; }
|
||||
.preview-btn:hover { border-color: var(--card-accent, var(--accent)); color: var(--card-accent, var(--accent)); }
|
||||
.use-btn { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 6px 10px; border: none; border-radius: 8px; background: var(--card-accent, var(--accent)); color: #1d2021; font-size: 0.72rem; font-weight: 600; cursor: pointer; transition: filter 0.15s ease; }
|
||||
.use-btn:hover { filter: brightness(1.08); }
|
||||
.designer-btn { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; flex-shrink: 0; border: 1px solid rgba(255, 255, 255, 0.09); background: rgba(255, 255, 255, 0.03); color: var(--text-secondary); border-radius: 8px; cursor: pointer; transition: border-color 0.15s ease, color 0.15s ease; }
|
||||
.designer-btn:hover { color: var(--card-accent, var(--accent)); border-color: var(--card-accent, var(--accent)); }
|
||||
|
||||
/* Animated "now playing" equalizer */
|
||||
.now-playing { display: inline-flex; align-items: flex-end; gap: 2px; width: 15px; height: 14px; }
|
||||
.now-playing i { width: 2.5px; background: currentcolor; border-radius: 1px; transform-origin: bottom; animation: eq 0.9s ease-in-out infinite; }
|
||||
.now-playing i:nth-child(1) { height: 45%; animation-delay: 0s; }
|
||||
.now-playing i:nth-child(2) { height: 80%; animation-delay: 0.15s; }
|
||||
.now-playing i:nth-child(3) { height: 60%; animation-delay: 0.3s; }
|
||||
.now-playing i:nth-child(4) { height: 95%; animation-delay: 0.45s; }
|
||||
@keyframes eq { 0%, 100% { transform: scaleY(0.4); } 50% { transform: scaleY(1); } }
|
||||
@media (prefers-reduced-motion: reduce) { .now-playing i { animation: none; } .archetype-card { transition: none; } .archetype-card:hover { transform: none; } }
|
||||
|
||||
/* Category chips: lucide icon + label */
|
||||
.use-case-chips .category-chip svg { flex-shrink: 0; }
|
||||
|
||||
.load-more { display: flex; justify-content: center; padding: 12px 0; }
|
||||
.import-explainer { flex-shrink: 0; padding: 8px 10px; margin-bottom: 8px; background: var(--bg-tertiary); border-radius: 8px; font-size: 0.72rem; color: var(--text-secondary); line-height: 1.4; }
|
||||
.import-explainer { flex-shrink: 0; padding: 8px 10px; margin-bottom: 8px; background: var(--bg-tertiary); border-radius: 8px; font-size: 0.72rem; color: var(--text-secondary); line-height: 1.4; }
|
||||
.community-explainer { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.submit-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||||
.submit-btn { display: inline-flex; align-items: center; gap: 5px; padding: 6px 10px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.03); color: var(--text-primary); border-radius: 8px; font-size: 0.7rem; cursor: pointer; transition: border-color 0.15s ease, color 0.15s ease; }
|
||||
.submit-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
@@ -8,11 +8,13 @@ import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Search, Download, Play, Pause, Trash2, X, Loader, Star,
|
||||
Wand2, UserPlus, Sparkles, RotateCcw, Grid, List, Upload, Scissors,
|
||||
Wand2, UserPlus, Sparkles, RotateCcw, Grid, List, Upload, Scissors, Store, Send,
|
||||
} from 'lucide-react';
|
||||
import { Button, Input } from '../ui';
|
||||
import { useArchetypeCategories, useArchetypes, useGalleryVoices } from '../api/hooks';
|
||||
import { useArchetypeCategories, useArchetypes, useGalleryVoices, useCommunityItems } from '../api/hooks';
|
||||
import { archetypePreviewUrl, useArchetypeAsProfile } from '../api/archetypes';
|
||||
import { addCommunityItem, communitySubmitUrl } from '../api/community';
|
||||
import { openExternal } from '../api/external';
|
||||
import {
|
||||
searchYoutube, downloadYoutubeClip, deleteGalleryVoice,
|
||||
saveVoiceAsProfile, uploadVoiceClip, previewVoiceUrl,
|
||||
@@ -22,6 +24,7 @@ import { useAppStore } from '../store';
|
||||
import { apiUrl } from '../api/client';
|
||||
import { isTauri } from '../utils/media';
|
||||
import { askConfirm } from '../utils/dialog';
|
||||
import { ArchetypeAvatar, ArchetypeIcon, AccentFlag, NowPlaying, USE_CASE_COLOR } from '../utils/archetypeIcons';
|
||||
import './VoiceGallery.css';
|
||||
|
||||
const BROWSE_PAGE = 60;
|
||||
@@ -131,6 +134,9 @@ export default function VoiceGallery() {
|
||||
<button className={`zone-tab ${zone === 'archetypes' ? 'active' : ''}`} onClick={() => setZone('archetypes')}>
|
||||
<Sparkles size={14} /> {t('gallery.zone_archetypes', { defaultValue: 'Archetypes' })}
|
||||
</button>
|
||||
<button className={`zone-tab ${zone === 'community' ? 'active' : ''}`} onClick={() => setZone('community')}>
|
||||
<Store size={14} /> {t('gallery.zone_community', { defaultValue: 'Community' })}
|
||||
</button>
|
||||
<button className={`zone-tab ${zone === 'imports' ? 'active' : ''}`} onClick={() => setZone('imports')}>
|
||||
<Upload size={14} /> {t('gallery.zone_imports', { defaultValue: 'My Imports' })}
|
||||
</button>
|
||||
@@ -167,6 +173,17 @@ export default function VoiceGallery() {
|
||||
setMode('design');
|
||||
}}
|
||||
/>
|
||||
) : zone === 'community' ? (
|
||||
<CommunityZone
|
||||
t={t}
|
||||
playingId={playingId}
|
||||
loadingPreviewId={loadingPreviewId}
|
||||
favorites={favorites}
|
||||
toggleFavorite={toggleFavorite}
|
||||
onPlayAudio={(url, id) => playUrl(url, id)}
|
||||
flash={flash}
|
||||
onDesign={(instruct) => { setVdStates({ ...vdStates }); setInstruct(instruct); setMode('design'); }}
|
||||
/>
|
||||
) : (
|
||||
<ImportsZone
|
||||
t={t}
|
||||
@@ -229,7 +246,7 @@ function ArchetypesZone({
|
||||
onClick={() => setFilter('use_case', filters.use_case === c.id ? null : c.id)}
|
||||
title={c.name}
|
||||
>
|
||||
<span className="chip-emoji">{c.icon}</span>
|
||||
<ArchetypeIcon name={c.icon} size={13} />
|
||||
{t(`archetypes.use_${c.id}`, { defaultValue: c.name })}
|
||||
</button>
|
||||
))}
|
||||
@@ -315,37 +332,126 @@ function ArchetypeCard({
|
||||
a, t, viewMode, isFavorite, isPlaying, isLoadingPreview,
|
||||
onPreview, onUse, onDesign, onToggleFavorite,
|
||||
}) {
|
||||
const chips = [a.facets.gender, a.facets.age, a.facets.pitch, a.facets.accent]
|
||||
.filter(Boolean)
|
||||
.map((x) => facetLabel(x));
|
||||
if (a.facets.whisper) chips.push('Whisper');
|
||||
const color = USE_CASE_COLOR[a.use_case] || '#83a598';
|
||||
const sub = [a.facets.gender, a.facets.age, a.facets.pitch]
|
||||
.filter(Boolean).map(facetLabel).join(' · ');
|
||||
const dialect = a.attrs?.ChineseDialect && a.attrs.ChineseDialect !== 'Auto' ? a.attrs.ChineseDialect : null;
|
||||
const accentLabel = a.facets.accent
|
||||
? facetLabel(a.facets.accent)
|
||||
: (dialect || (a.language === 'Chinese' ? 'Chinese' : null));
|
||||
|
||||
return (
|
||||
<div className={`archetype-card ${viewMode}`}>
|
||||
<button
|
||||
className={`fav-btn ${isFavorite ? 'on' : ''}`}
|
||||
onClick={() => onToggleFavorite(a.id)}
|
||||
title={t('gallery.favorite', { defaultValue: 'Favorite' })}
|
||||
>
|
||||
<Star size={14} fill={isFavorite ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
<button className="archetype-play" onClick={() => onPreview(a)} title={t('gallery.preview', { defaultValue: 'Preview' })}>
|
||||
{isLoadingPreview ? <Loader className="spin" size={18} /> : isPlaying ? <Pause size={18} /> : <Play size={18} />}
|
||||
</button>
|
||||
<div className="archetype-body">
|
||||
<div className="archetype-name"><span className="archetype-icon">{a.icon}</span>{a.name}</div>
|
||||
<div className={`archetype-card ${viewMode} ${isPlaying ? 'playing' : ''}`} style={{ '--card-accent': color }}>
|
||||
<div className="arch-head">
|
||||
<ArchetypeAvatar item={a} />
|
||||
<div className="arch-title">
|
||||
<div className="archetype-name">{a.name}</div>
|
||||
{sub && <div className="archetype-sub">{sub}</div>}
|
||||
</div>
|
||||
<button
|
||||
className={`fav-btn ${isFavorite ? 'on' : ''}`}
|
||||
onClick={() => onToggleFavorite(a.id)}
|
||||
title={t('gallery.favorite', { defaultValue: 'Favorite' })}
|
||||
>
|
||||
<Star size={15} fill={isFavorite ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(accentLabel || a.facets.whisper) && (
|
||||
<div className="archetype-chips">
|
||||
{chips.map((c, i) => <span key={i} className="facet-chip">{c}</span>)}
|
||||
{accentLabel && (
|
||||
<span className="facet-chip with-flag">
|
||||
<AccentFlag accent={a.facets.accent} lang={a.language} size={14} />
|
||||
{accentLabel}
|
||||
</span>
|
||||
)}
|
||||
{a.facets.whisper && (
|
||||
<span className="facet-chip">{t('archetypes.facet_whisper', { defaultValue: 'Whisper' })}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="arch-foot">
|
||||
<button className="preview-btn" onClick={() => onPreview(a)} title={t('gallery.preview', { defaultValue: 'Preview' })}>
|
||||
{isLoadingPreview ? <Loader className="spin" size={15} /> : isPlaying ? <NowPlaying color={color} /> : <Play size={15} />}
|
||||
<span>{t('gallery.preview', { defaultValue: 'Preview' })}</span>
|
||||
</button>
|
||||
<button className="use-btn" onClick={() => onUse(a)}>
|
||||
<UserPlus size={14} /> {t('gallery.use_voice', { defaultValue: 'Use voice' })}
|
||||
</button>
|
||||
<button className="designer-btn" onClick={() => onDesign(a)} title={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}>
|
||||
<Wand2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Community zone (marketplace) ─────────────────────────────────────────────
|
||||
function CommunityZone({ t, playingId, loadingPreviewId, favorites, toggleFavorite, onPlayAudio, flash, onDesign }) {
|
||||
const itemsQ = useCommunityItems({ limit: 100 });
|
||||
const items = itemsQ.data?.items || [];
|
||||
const favSet = useMemo(() => new Set(favorites), [favorites]);
|
||||
|
||||
const submit = async (type) => {
|
||||
try {
|
||||
const { url } = await communitySubmitUrl(type);
|
||||
await openExternal(url);
|
||||
} catch {
|
||||
flash(t('gallery.submit_failed', { defaultValue: 'Could not open the submission form.' }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="gallery-content gallery-scroll">
|
||||
<div className="import-explainer community-explainer">
|
||||
<span>{t('gallery.community_explainer', { defaultValue: 'Designed presets and recorded voices shared by the community, loaded from the omnivoice-gallery.' })}</span>
|
||||
<div className="submit-actions">
|
||||
<button className="submit-btn" onClick={() => submit('preset')}>
|
||||
<Send size={13} /> {t('gallery.submit_preset', { defaultValue: 'Submit a preset' })}
|
||||
</button>
|
||||
<button className="submit-btn" onClick={() => submit('voice')}>
|
||||
<Send size={13} /> {t('gallery.submit_voice', { defaultValue: 'Submit a voice' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="archetype-actions">
|
||||
<Button size="sm" onClick={() => onUse(a)} title={t('gallery.use_voice', { defaultValue: 'Use voice' })}>
|
||||
<UserPlus size={13} /> {t('gallery.use_voice', { defaultValue: 'Use voice' })}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => onDesign(a)} title={t('gallery.open_designer', { defaultValue: 'Open in Designer' })}>
|
||||
<Wand2 size={13} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{itemsQ.isLoading ? (
|
||||
<div className="loading"><Loader className="spin" size={18} /></div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="empty">
|
||||
{t('gallery.community_empty', { defaultValue: 'No community voices loaded yet — connect to the internet and reopen, or be the first to submit one.' })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="archetype-grid grid">
|
||||
{items.map((it) => (
|
||||
<ArchetypeCard
|
||||
key={it.id}
|
||||
a={it}
|
||||
t={t}
|
||||
viewMode="grid"
|
||||
isFavorite={favSet.has(it.id)}
|
||||
isPlaying={playingId === it.id}
|
||||
isLoadingPreview={loadingPreviewId === it.id}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
onPreview={(item) => (item.audio?.url
|
||||
? onPlayAudio(item.audio.url, item.id)
|
||||
: flash(t('gallery.no_preview', { defaultValue: 'No preview — add it with "Use voice" to hear it.' })))}
|
||||
onUse={async (item) => {
|
||||
try {
|
||||
const r = await addCommunityItem(item.id, item.name);
|
||||
flash(t('gallery.saved_as_profile', { defaultValue: 'Added "{{name}}" to your voices.', name: r.name }));
|
||||
} catch {
|
||||
flash(t('gallery.use_failed', { defaultValue: 'Could not add that voice.' }));
|
||||
}
|
||||
}}
|
||||
onDesign={(item) => (item.instruct
|
||||
? onDesign(item.instruct)
|
||||
: flash(t('gallery.no_designer', { defaultValue: 'Recorded voice — use "Use voice" instead.' })))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
export type GalleryZone = 'archetypes' | 'imports';
|
||||
export type GalleryZone = 'archetypes' | 'imports' | 'community';
|
||||
|
||||
export interface ArchetypeFilterState {
|
||||
use_case: string | null;
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// Icon system for the Voice Gallery — lucide SVGs (not emoji, which render
|
||||
// inconsistently across OSes), country flags for accents, a per-category color
|
||||
// scale, and a CSS-animated "now playing" equalizer.
|
||||
import React from 'react';
|
||||
import {
|
||||
BookOpen, MessagesSquare, MessageSquare, Drama, Smartphone, Tv, Megaphone,
|
||||
GraduationCap, Library, Mic, Moon, Wand2, Smile, Headphones, Coffee, Skull,
|
||||
Bird, Shield, Sparkles, Ghost, Radio, Zap, Video, Trophy, Clapperboard, Gem,
|
||||
Music, Lightbulb, Globe,
|
||||
} from 'lucide-react';
|
||||
import US from 'country-flag-icons/react/3x2/US';
|
||||
import GB from 'country-flag-icons/react/3x2/GB';
|
||||
import AU from 'country-flag-icons/react/3x2/AU';
|
||||
import CA from 'country-flag-icons/react/3x2/CA';
|
||||
import IN from 'country-flag-icons/react/3x2/IN';
|
||||
import CN from 'country-flag-icons/react/3x2/CN';
|
||||
import JP from 'country-flag-icons/react/3x2/JP';
|
||||
import KR from 'country-flag-icons/react/3x2/KR';
|
||||
import PT from 'country-flag-icons/react/3x2/PT';
|
||||
import RU from 'country-flag-icons/react/3x2/RU';
|
||||
|
||||
// lucide component name → component (icon identity comes from the backend).
|
||||
const ICONS = {
|
||||
BookOpen, MessagesSquare, MessageSquare, Drama, Smartphone, Tv, Megaphone,
|
||||
GraduationCap, Library, Mic, Moon, Wand2, Smile, Headphones, Coffee, Skull,
|
||||
Bird, Shield, Sparkles, Ghost, Radio, Zap, Video, Trophy, Clapperboard, Gem,
|
||||
Music, Lightbulb, Globe,
|
||||
};
|
||||
|
||||
// One accent color per use-case (gruvbox palette, matches the app theme).
|
||||
export const USE_CASE_COLOR = {
|
||||
narration: '#83a598',
|
||||
conversational: '#8ec07c',
|
||||
characters: '#d3869b',
|
||||
social: '#fe8019',
|
||||
entertainment: '#fabd2f',
|
||||
advertisement: '#fb4934',
|
||||
informative: '#b8bb26',
|
||||
};
|
||||
|
||||
const FLAGS = {
|
||||
'american accent': US,
|
||||
'british accent': GB,
|
||||
'australian accent': AU,
|
||||
'canadian accent': CA,
|
||||
'indian accent': IN,
|
||||
'chinese accent': CN,
|
||||
'japanese accent': JP,
|
||||
'korean accent': KR,
|
||||
'portuguese accent': PT,
|
||||
'russian accent': RU,
|
||||
};
|
||||
|
||||
function tint(hex, alpha) {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
|
||||
}
|
||||
|
||||
export function ArchetypeIcon({ name, size = 18, color }) {
|
||||
const Cmp = ICONS[name] || Sparkles;
|
||||
return <Cmp size={size} color={color} strokeWidth={2} />;
|
||||
}
|
||||
|
||||
/** Country flag for an accent (or the Chinese flag for dialect voices, or a globe). */
|
||||
export function AccentFlag({ accent, lang, size = 14 }) {
|
||||
let Flag = accent ? FLAGS[accent] : null;
|
||||
if (!Flag && lang === 'Chinese') Flag = CN;
|
||||
if (!Flag) return <Globe size={size} className="flag-globe" />;
|
||||
return <Flag className="accent-flag" style={{ width: size, height: Math.round(size * 0.75) }} />;
|
||||
}
|
||||
|
||||
/** Animated equalizer shown on the card whose preview is playing (bars in CSS). */
|
||||
export function NowPlaying({ color }) {
|
||||
return (
|
||||
<span className="now-playing" style={color ? { color } : undefined} aria-hidden="true">
|
||||
<i /><i /><i /><i />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Color-coded icon tile with a small flag badge — the visual anchor of a card. */
|
||||
export function ArchetypeAvatar({ item, size = 44 }) {
|
||||
const color = USE_CASE_COLOR[item.use_case] || '#83a598';
|
||||
return (
|
||||
<div
|
||||
className="arch-avatar"
|
||||
style={{ width: size, height: size, background: tint(color, 0.14), borderColor: tint(color, 0.32) }}
|
||||
>
|
||||
<ArchetypeIcon name={item.icon} size={Math.round(size * 0.46)} color={color} />
|
||||
<span className="arch-avatar-flag">
|
||||
<AccentFlag accent={item.facets?.accent} lang={item.language} size={15} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Submodule
+1
Submodule omnivoice-gallery added at 22e8e6da80
Reference in New Issue
Block a user