diff --git a/.gitignore b/.gitignore index 112d812..d8763c3 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ models/ # OS .DS_Store + +# Config locale Claude Code (agent) +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md index 89a872f..5487cf0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,7 @@ render → output//NN-....mp3 (pipeline/render.py + audio/postproc ### Deux niveaux de configuration (distinction importante) - **`config.py`** : constantes figées lues à l'import, surchargeables **uniquement** par variables d'environnement au démarrage (`INKFLOW_*`). Chemins, IDs de modèles MLX par défaut, params audio. -- **`settings.py`** : objet `Settings` **persisté** dans `data/settings.json`, éditable au runtime depuis l'UI. Contient aussi les **prompts système Gemma** (éditables). Le pipeline consulte `get_settings()` au moment de l'exécution. `save_settings()` invalide les caches modèles (`get_backend.cache_clear()`, `gemma._load.cache_clear()`) pour appliquer les changements **sans redémarrage**. +- **`settings.py`** : objet `Settings` **persisté** dans `data/settings.json`, éditable au runtime depuis l'UI. Contient aussi les **prompts système Gemma** (éditables). Le pipeline consulte `get_settings()` au moment de l'exécution. `save_settings()` invalide les caches modèles (`get_backend.cache_clear()`, `analysis.llm.factory.reset_llm_cache()`) pour appliquer les changements **sans redémarrage**. ### Orchestration (UI temps réel) @@ -83,6 +83,12 @@ Cette logique est **pure et testée** (`tests/test_incises.py`, 30+ cas sans Gem `tts/base.py` définit `TTSBackend.synthesize(text, VoiceSpec) -> (audio mono float32, sample_rate)` et `VoiceSpec` (preset pour Kokoro, ref_audio/ref_text pour le clonage Qwen3). `tts/factory.get_backend(name)` est `lru_cache` par **nom** (pas par id de modèle — d'où l'invalidation explicite dans settings). `pipeline/render.py` construit des `RenderUnit` (mono ou multi-voix), où `glued_to_prev` réduit le silence pour les incises rattachées à la réplique précédente. +### LLM d'analyse pluggable + +`analysis/llm/` suit le **même pattern que le TTS**. La façade `client.LLM` (anciennement `Gemma`) expose `generate`/`generate_json` consommés par tout le pipeline ; elle porte toute la logique agnostique du moteur (calcul des params depuis `Settings`, retrait de la pensée des modèles à raisonnement, extraction JSON tolérante — helpers dans `_text.py`, testés purs dans `tests/test_gemma_reasoning.py`). Sous elle, `base.LLMBackend.complete(messages, ...) -> str` (texte brut) a deux implémentations : **`mlx_backend`** (mlx-lm, défaut) et **`lmstudio_backend`** (API OpenAI locale de LM Studio, sert GGUF *et* MLX). Sélection par nom via `factory.get_llm_backend(backend, model_ref)` (`lru_cache`, `reset_llm_cache()` pour invalider). Backend choisi dans `settings.gemma_backend` (`mlx`/`lmstudio`), surchargeable par `--backend`/`--model` sur les commandes CLI `analyze`/`pronounce`/`cast`/`benchmark`. LM Studio doit tourner avec son serveur local actif (onglet Developer). + +**Qui possède la config de génération ?** Les réglages `gemma_temperature`/`gemma_max_tokens`/`gemma_reasoning*` pilotent le backend **MLX** (seule source de config pour `mlx-lm`). Pour **LM Studio**, c'est la config du modèle **dans LM Studio** qui prime : par défaut (`settings.lmstudio_defer_config=True`) le backend **n'impose ni `temperature` ni `max_tokens`** dans la requête — imposer `max_tokens` tronquait la réponse des modèles à raisonnement (pensée non terminée → « aucun JSON »). Le **contexte** se règle aussi côté LM Studio (au chargement : `lms load --context-length N` ou l'UI) — InkFlow ne peut pas le porter, d'où l'erreur « context length » si le modèle est JIT-chargé avec un contexte trop court pour un chapitre. Mettre `lmstudio_defer_config=False` pour réimposer les réglages InkFlow (benchmarks reproductibles). LM Studio sépare déjà la pensée (`reasoning_content`) de la réponse (`content`) : le backend ne renvoie que `content`. + ## Fichiers de référence (vérité terrain pour l'attribution) `data//reference/chNN.json` contient des **versions corrigées à la main** de la sortie d'analyse `analysis/chNN.json` — **même schéma `ChapterAnalysis`** (`index`, `title`, `segments` avec `type`/`text`/`speaker`/`incises`). Ce sont des **fixtures de vérité terrain** servant à juger la qualité de la segmentation, des incises et de l'attribution des locuteurs : on compare la sortie réelle du pipeline à la référence. diff --git a/backend/inkflow/analysis/benchmark.py b/backend/inkflow/analysis/benchmark.py index dd8e8f9..79c575d 100644 --- a/backend/inkflow/analysis/benchmark.py +++ b/backend/inkflow/analysis/benchmark.py @@ -359,6 +359,7 @@ def _build_model_score(model_id: str, per_chapter: list[ChapterScore], def run_benchmark(slug: str, model_ids: list[str], *, + backend: Optional[str] = None, chapters: Optional[list[int]] = None, temperature: Optional[float] = None, reasoning: Optional[bool] = None, @@ -414,7 +415,8 @@ def run_benchmark(slug: str, model_ids: list[str], *, report.models.append(_build_model_score("", per_chapter, counts, 0.0)) return report - from .gemma import Gemma, _load + from .llm.client import LLM + from .llm.factory import reset_llm_cache from .segmenter import analyze_chapter book = load_book(slug) @@ -435,7 +437,7 @@ def run_benchmark(slug: str, model_ids: list[str], *, model_err: Optional[str] = None emit(f"[{mi}/{len(model_ids)}] {model_id} — chargement du modele…") try: - gemma = Gemma(model_id=model_id) + gemma = LLM(model_id=model_id, backend=backend) for i in targets: ch = by_index.get(i) if ch is None: @@ -457,7 +459,7 @@ def run_benchmark(slug: str, model_ids: list[str], *, model_err = f"{type(exc).__name__}: {exc}" emit(f" ! echec: {model_err[:120]}") finally: - _load.cache_clear() # libere le modele avant le suivant + reset_llm_cache() # libere le modele avant le suivant ms = _build_model_score( model_id, per_chapter, counts, time.perf_counter() - t0) ms.error = model_err diff --git a/backend/inkflow/analysis/gemma.py b/backend/inkflow/analysis/gemma.py deleted file mode 100644 index b23cd50..0000000 --- a/backend/inkflow/analysis/gemma.py +++ /dev/null @@ -1,251 +0,0 @@ -"""Wrapper mlx-lm autour de Gemma pour l'analyse de texte. - -Charge le modele paresseusement (une seule fois par process) et expose des -helpers de generation, dont un `generate_json` tolerant qui extrait le premier -objet/array JSON valide de la sortie du modele. -""" -from __future__ import annotations - -import json -import re -from functools import lru_cache -from typing import Any, Optional - -from ..settings import get_settings - -# Bornes d'un bloc JSON dans une reponse potentiellement bavarde. -_JSON_SPAN_RE = re.compile(r"(\{.*\}|\[.*\])", re.DOTALL) -_FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL) - -# Marqueurs de FIN de chaine de pensee : on ne garde que ce qui suit le dernier. -# - balises type DeepSeek-R1 / Qwen-think -# - format a canaux type Gemma 4 / Harmony (la pensee est close par ) -_REASONING_END_MARKERS = ("", "", "<|channel|>") -# Prefixe de canal/think non ferme reste en tete (pensee tronquee) : a retirer. -_REASONING_OPEN_RE = re.compile(r"^\s*(?:<\|?channel\|?>\s*\w*|)", re.IGNORECASE) - - -@lru_cache(maxsize=2) -def _load(model_id: str): - # Import paresseux : evite de charger mlx tant qu'on n'analyse pas. - from mlx_lm import load - return load(model_id) - - -# Hook de streaming optionnel. Si defini, `generate()` diffuse chaque morceau de -# texte AU FIL de la generation (pensee comprise, avant tout nettoyage) en -# appelant ce callback. Utilise par `inkflow benchmark --stream` pour voir les -# tokens en temps reel. None -> generation par lot classique (plus rapide). -_TOKEN_SINK: Optional[Any] = None - - -def set_token_sink(callback) -> None: - """Definit (ou retire avec None) le callback de streaming des tokens.""" - global _TOKEN_SINK - _TOKEN_SINK = callback - - -def _resolve_chat_template(model_id: str, tokenizer) -> Optional[str]: - """Renvoie un template de chat a passer explicitement, ou None. - - Certaines conversions (Mistral recents...) logent leur template dans un - fichier `chat_template.jinja` que le downloader de mlx-lm n'embarque pas - toujours : `tokenizer.chat_template` est alors vide et `apply_chat_template` - echoue. On recupere alors le fichier officiel du repo. None si le tokenizer - possede deja un template (cas courant) ou si aucun n'est disponible. - """ - if getattr(tokenizer, "chat_template", None): - return None - from pathlib import Path - - from huggingface_hub import hf_hub_download - # Selon les conversions : fichier Jinja brut, ou JSON {"chat_template": ...}. - for fname in ("chat_template.jinja", "chat_template.json"): - try: - text = Path(hf_hub_download(model_id, fname)).read_text(encoding="utf-8") - except Exception: # noqa: BLE001 — fichier absent, on tente le suivant - continue - if fname.endswith(".json"): - data = json.loads(text) - return data.get("chat_template") if isinstance(data, dict) else None - return text - return None # aucun template dispo -> apply_chat_template levera une erreur claire - - -class Gemma: - """Petite facade autour de mlx-lm pour piloter Gemma.""" - - def __init__(self, model_id: Optional[str] = None): - self.model_id = model_id or get_settings().gemma_model - self._model = None - self._tokenizer = None - self._chat_template = None # template recupere si absent du tokenizer - - def _ensure_loaded(self) -> None: - if self._model is None: - self._model, self._tokenizer = _load(self.model_id) - self._chat_template = _resolve_chat_template( - self.model_id, self._tokenizer) - - def generate( - self, - prompt: str, - *, - system: Optional[str] = None, - max_tokens: Optional[int] = None, - temperature: Optional[float] = None, - ) -> str: - """Genere une reponse texte a partir d'un prompt (template de chat). - - `max_tokens`/`temperature` non fournis -> valeurs des reglages courants. - """ - self._ensure_loaded() - settings = get_settings() - if max_tokens is None: - max_tokens = settings.gemma_max_tokens - # En mode raisonnement, plafond dedie (garde-fou anti-boucle) ; la - # generation s'arrete de toute facon des que le JSON post-pensee est - # complet (cf. boucle de streaming ci-dessous). - if settings.gemma_reasoning: - max_tokens = max(max_tokens, settings.gemma_reasoning_max_tokens) - if temperature is None: - temperature = settings.gemma_temperature - # Decodage glouton (temp 0) + raisonnement = boucles de pensee sans fin. - # On force un echantillonnage minimal en mode raisonnement. - if settings.gemma_reasoning and temperature == 0.0: - temperature = settings.gemma_reasoning_temperature - from mlx_lm.sample_utils import make_sampler - - messages = [] - if system: - messages.append({"role": "system", "content": system}) - messages.append({"role": "user", "content": prompt}) - # Modeles hybrides (Qwen3...) : hors mode raisonnement, on DESACTIVE la - # pensee via enable_thinking=False -> JSON direct, bien plus rapide. Avec - # --reasoning, on laisse penser puis on retire la pensee en aval. Ce - # kwarg est ignore par les templates qui ne l'utilisent pas (Gemma...). - template_kwargs = {} - if not settings.gemma_reasoning: - template_kwargs["enable_thinking"] = False - formatted = self._tokenizer.apply_chat_template( - messages, add_generation_prompt=True, tokenize=False, - chat_template=self._chat_template, # None -> celui du tokenizer - **template_kwargs, - ) - sampler = make_sampler(temp=temperature) - # On streame (token par token) si : un sink est branche (--stream) OU on - # est en mode raisonnement (pour pouvoir s'arreter des que la reponse est - # prete, sans subir les boucles de pensee sans fin). Sinon, lot rapide. - if _TOKEN_SINK is not None or settings.gemma_reasoning: - from mlx_lm import stream_generate - parts = [] - seen_end = False # marqueur de fin de pensee rencontre - for resp in stream_generate( - self._model, self._tokenizer, prompt=formatted, - max_tokens=max_tokens, sampler=sampler, - ): - parts.append(resp.text) - if _TOKEN_SINK is not None: - _TOKEN_SINK(resp.text) - # Arret anticipe : une fois la pensee close, des que le JSON - # post-pensee est complet, inutile de continuer a generer. - if settings.gemma_reasoning and ("}" in resp.text or "]" in resp.text): - buf = "".join(parts) - if not seen_end: - seen_end = any(mk in buf for mk in _REASONING_END_MARKERS) - if seen_end and _has_complete_json(_strip_reasoning(buf)): - break - if _TOKEN_SINK is not None: - _TOKEN_SINK("\n") # separe les generations successives - raw = "".join(parts) - else: - from mlx_lm import generate - raw = generate( - self._model, - self._tokenizer, - prompt=formatted, - max_tokens=max_tokens, - sampler=sampler, - verbose=False, - ) - # Retire la chaine de pensee des modeles a raisonnement (sinon des - # fragments de la "pensee" parasitent l'extraction JSON en aval). - if settings.gemma_reasoning: - return _strip_reasoning(raw) - return raw - - def generate_json( - self, - prompt: str, - *, - system: Optional[str] = None, - max_tokens: Optional[int] = None, - temperature: Optional[float] = None, - retries: int = 1, - ) -> Any: - """Genere puis parse un JSON. Reessaie en cas d'echec de parsing. - - `max_tokens`/`temperature` non fournis -> valeurs des reglages courants. - """ - last_err: Optional[Exception] = None - for attempt in range(retries + 1): - raw = self.generate( - prompt, system=system, max_tokens=max_tokens, - temperature=temperature if attempt == 0 else 0.0, - ) - try: - return _extract_json(raw) - except Exception as exc: # noqa: BLE001 - last_err = exc - raise ValueError(f"Reponse JSON invalide apres {retries + 1} essais: {last_err}") - - -def _strip_reasoning(text: str) -> str: - """Retire la chaine de pensee d'un modele a raisonnement. - - Ne conserve que ce qui suit le dernier marqueur de fin de pensee - (``, ``...). Si seul un marqueur d'ouverture non ferme - subsiste (pensee tronquee par le budget de tokens), on le retire en tete - pour eviter de parser la pensee a la place de la reponse. - """ - t = text - for marker in _REASONING_END_MARKERS: - if marker in t: - t = t.rsplit(marker, 1)[-1] - t = _REASONING_OPEN_RE.sub("", t) - return t.strip() - - -def _has_complete_json(text: str) -> bool: - """True si `text` contient deja un objet/array JSON complet et parsable. - - Sert a stopper la generation des modeles a raisonnement des que la reponse - finale est ecrite (evite de consommer le budget en boucles de pensee). - """ - try: - _extract_json(text) - return True - except Exception: # noqa: BLE001 - return False - - -def _extract_json(text: str) -> Any: - """Extrait le premier objet/array JSON d'une reponse libre du modele. - - Tolere le texte parasite avant/apres (y compris un 2e bloc) grace a - raw_decode, qui s'arrete au premier JSON complet. - """ - text = text.strip() - fence = _FENCE_RE.search(text) - if fence: - text = fence.group(1).strip() - decoder = json.JSONDecoder() - # Cherche le 1er debut de structure JSON et decode a partir de la. - for i, ch in enumerate(text): - if ch in "[{": - try: - obj, _ = decoder.raw_decode(text[i:]) - return obj - except json.JSONDecodeError: - continue - raise ValueError("aucun JSON trouve dans la reponse") diff --git a/backend/inkflow/analysis/llm/__init__.py b/backend/inkflow/analysis/llm/__init__.py new file mode 100644 index 0000000..8e1bc10 --- /dev/null +++ b/backend/inkflow/analysis/llm/__init__.py @@ -0,0 +1,14 @@ +"""Client LLM pluggable pour l'analyse de texte (attribution, personnages...). + +La facade `LLM` (client.py) expose `generate` / `generate_json` consommes par +tout le pipeline. Sous elle, un backend pluggable (`base.LLMBackend`) transforme +des messages en texte brut : `mlx_backend` (mlx-lm, defaut) ou `lmstudio_backend` +(API OpenAI locale de LM Studio, sert GGUF *et* MLX). Selection par nom via +`factory.get_llm_backend`. +""" +from __future__ import annotations + +from .client import LLM, set_token_sink +from .factory import get_llm_backend, reset_llm_cache + +__all__ = ["LLM", "set_token_sink", "get_llm_backend", "reset_llm_cache"] diff --git a/backend/inkflow/analysis/llm/_text.py b/backend/inkflow/analysis/llm/_text.py new file mode 100644 index 0000000..80fa728 --- /dev/null +++ b/backend/inkflow/analysis/llm/_text.py @@ -0,0 +1,74 @@ +"""Helpers agnostiques du moteur : extraction JSON tolerante + retrait de la +chaine de pensee des modeles a raisonnement. + +Module neutre (aucune dependance a un backend) : partage par la facade `LLM` et +par les backends (qui s'en servent pour l'arret anticipe en streaming), sans +creer de cycle d'import. +""" +from __future__ import annotations + +import json +import re +from typing import Any + +# Bornes d'un bloc JSON dans une reponse potentiellement bavarde. +_JSON_SPAN_RE = re.compile(r"(\{.*\}|\[.*\])", re.DOTALL) +_FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL) + +# Marqueurs de FIN de chaine de pensee : on ne garde que ce qui suit le dernier. +# - balises type DeepSeek-R1 / Qwen-think +# - format a canaux type Gemma 4 / Harmony (la pensee est close par ) +_REASONING_END_MARKERS = ("", "", "<|channel|>") +# Prefixe de canal/think non ferme reste en tete (pensee tronquee) : a retirer. +_REASONING_OPEN_RE = re.compile(r"^\s*(?:<\|?channel\|?>\s*\w*|)", re.IGNORECASE) + + +def _strip_reasoning(text: str) -> str: + """Retire la chaine de pensee d'un modele a raisonnement. + + Ne conserve que ce qui suit le dernier marqueur de fin de pensee + (``, ``...). Si seul un marqueur d'ouverture non ferme + subsiste (pensee tronquee par le budget de tokens), on le retire en tete + pour eviter de parser la pensee a la place de la reponse. + """ + t = text + for marker in _REASONING_END_MARKERS: + if marker in t: + t = t.rsplit(marker, 1)[-1] + t = _REASONING_OPEN_RE.sub("", t) + return t.strip() + + +def _has_complete_json(text: str) -> bool: + """True si `text` contient deja un objet/array JSON complet et parsable. + + Sert a stopper la generation des modeles a raisonnement des que la reponse + finale est ecrite (evite de consumer le budget en boucles de pensee). + """ + try: + _extract_json(text) + return True + except Exception: # noqa: BLE001 + return False + + +def _extract_json(text: str) -> Any: + """Extrait le premier objet/array JSON d'une reponse libre du modele. + + Tolere le texte parasite avant/apres (y compris un 2e bloc) grace a + raw_decode, qui s'arrete au premier JSON complet. + """ + text = text.strip() + fence = _FENCE_RE.search(text) + if fence: + text = fence.group(1).strip() + decoder = json.JSONDecoder() + # Cherche le 1er debut de structure JSON et decode a partir de la. + for i, ch in enumerate(text): + if ch in "[{": + try: + obj, _ = decoder.raw_decode(text[i:]) + return obj + except json.JSONDecodeError: + continue + raise ValueError("aucun JSON trouve dans la reponse") diff --git a/backend/inkflow/analysis/llm/base.py b/backend/inkflow/analysis/llm/base.py new file mode 100644 index 0000000..7f8c5f2 --- /dev/null +++ b/backend/inkflow/analysis/llm/base.py @@ -0,0 +1,43 @@ +"""Abstraction des moteurs LLM (backend pluggable). + +Calque du pattern TTS (`tts/base.py`) : un backend ne fait *qu'une* chose, +transformer une liste de messages (role/content) en texte brut. Toute la logique +agnostique (calcul des parametres depuis les Settings, retrait de la pensee, +extraction JSON tolerante, retries) vit dans la facade `client.LLM`, jamais +dupliquee par backend. +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Callable, Optional + + +class LLMBackend(ABC): + """Interface commune a tous les moteurs LLM.""" + + name: str = "base" + + def __init__(self, model_ref: str): + # Reference du modele, interpretee par le backend : id mlx-community + # (mlx) ou nom du modele charge dans LM Studio (lmstudio, vide -> actif). + self.model_ref = model_ref + + @abstractmethod + def complete( + self, + messages: list[dict], + *, + max_tokens: int, + temperature: float, + reasoning: bool, + token_sink: Optional[Callable[[str], None]] = None, + ) -> str: + """Genere et renvoie le texte BRUT (chaine de pensee incluse). + + - `messages` : liste {role, content} (system optionnel + user). + - `reasoning` : si vrai, le modele peut emettre une chaine de pensee ; + le backend peut s'arreter des que le JSON post-pensee est complet. La + facade retire la pensee en aval (`_strip_reasoning`). + - `token_sink` : si fourni, appele avec chaque morceau de texte au fil de + la generation (streaming pour `inkflow benchmark --stream`). + """ diff --git a/backend/inkflow/analysis/llm/client.py b/backend/inkflow/analysis/llm/client.py new file mode 100644 index 0000000..faf92d1 --- /dev/null +++ b/backend/inkflow/analysis/llm/client.py @@ -0,0 +1,119 @@ +"""Facade LLM pour l'analyse de texte (anciennement `Gemma`). + +Charge un backend pluggable (mlx par defaut, ou LM Studio) selon les reglages et +expose `generate` / `generate_json` consommes par tout le pipeline. Toute la +logique agnostique du moteur vit ici : calcul des parametres depuis les Settings, +retrait de la chaine de pensee (modeles a raisonnement) et `generate_json` +tolerant qui extrait le premier objet/array JSON valide de la sortie du modele. +""" +from __future__ import annotations + +from typing import Any, Optional + +from ...settings import Settings, get_settings +from ._text import _extract_json, _strip_reasoning +from .factory import get_llm_backend + +# Hook de streaming optionnel. Si defini, `generate()` diffuse chaque morceau de +# texte AU FIL de la generation (pensee comprise, avant tout nettoyage) en +# appelant ce callback. Utilise par `inkflow benchmark --stream` pour voir les +# tokens en temps reel. None -> generation par lot classique (plus rapide). +_TOKEN_SINK: Optional[Any] = None + + +def set_token_sink(callback) -> None: + """Definit (ou retire avec None) le callback de streaming des tokens.""" + global _TOKEN_SINK + _TOKEN_SINK = callback + + +def _model_ref_for(backend: str, settings: Settings) -> str: + """Reference de modele par defaut pour un backend donne.""" + if backend == "lmstudio": + return settings.lmstudio_model + return settings.gemma_model + + +class LLM: + """Petite facade multi-backend pour piloter le LLM d'analyse.""" + + def __init__(self, model_id: Optional[str] = None, backend: Optional[str] = None): + settings = get_settings() + self.backend_name = backend or settings.gemma_backend + self.model_ref = model_id or _model_ref_for(self.backend_name, settings) + self._backend = None + + def _ensure_loaded(self) -> None: + if self._backend is None: + self._backend = get_llm_backend(self.backend_name, self.model_ref) + + def generate( + self, + prompt: str, + *, + system: Optional[str] = None, + max_tokens: Optional[int] = None, + temperature: Optional[float] = None, + ) -> str: + """Genere une reponse texte a partir d'un prompt (template de chat). + + `max_tokens`/`temperature` non fournis -> valeurs des reglages courants. + """ + self._ensure_loaded() + settings = get_settings() + if max_tokens is None: + max_tokens = settings.gemma_max_tokens + # En mode raisonnement, plafond dedie (garde-fou anti-boucle) ; la + # generation s'arrete de toute facon des que le JSON post-pensee est + # complet (cf. arret anticipe des backends). + if settings.gemma_reasoning: + max_tokens = max(max_tokens, settings.gemma_reasoning_max_tokens) + if temperature is None: + temperature = settings.gemma_temperature + # Decodage glouton (temp 0) + raisonnement = boucles de pensee sans fin. + # On force un echantillonnage minimal en mode raisonnement. + if settings.gemma_reasoning and temperature == 0.0: + temperature = settings.gemma_reasoning_temperature + + messages = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": prompt}) + + raw = self._backend.complete( + messages, + max_tokens=max_tokens, + temperature=temperature, + reasoning=settings.gemma_reasoning, + token_sink=_TOKEN_SINK, + ) + # Retire la chaine de pensee des modeles a raisonnement (sinon des + # fragments de la "pensee" parasitent l'extraction JSON en aval). + if settings.gemma_reasoning: + return _strip_reasoning(raw) + return raw + + def generate_json( + self, + prompt: str, + *, + system: Optional[str] = None, + max_tokens: Optional[int] = None, + temperature: Optional[float] = None, + retries: int = 1, + ) -> Any: + """Genere puis parse un JSON. Reessaie en cas d'echec de parsing. + + `max_tokens`/`temperature` non fournis -> valeurs des reglages courants. + """ + last_err: Optional[Exception] = None + for attempt in range(retries + 1): + raw = self.generate( + prompt, system=system, max_tokens=max_tokens, + temperature=temperature if attempt == 0 else 0.0, + ) + try: + return _extract_json(raw) + except Exception as exc: # noqa: BLE001 + last_err = exc + raise ValueError(f"Reponse JSON invalide apres {retries + 1} essais: {last_err}") diff --git a/backend/inkflow/analysis/llm/factory.py b/backend/inkflow/analysis/llm/factory.py new file mode 100644 index 0000000..bccab90 --- /dev/null +++ b/backend/inkflow/analysis/llm/factory.py @@ -0,0 +1,36 @@ +"""Selection du backend LLM par nom (pluggable). + +Calque de `tts/factory.py` : cache par (nom, reference de modele). Une +sauvegarde des reglages (settings.save_settings) appelle `reset_llm_cache()` +pour que les changements de backend/modele prennent effet sans redemarrage. +""" +from __future__ import annotations + +from functools import lru_cache + +from .base import LLMBackend + +BACKENDS = ("mlx", "lmstudio") + + +@lru_cache(maxsize=4) +def get_llm_backend(backend: str = "mlx", model_ref: str = "") -> LLMBackend: + backend = backend.lower() + if backend == "mlx": + from .mlx_backend import MLXBackend + return MLXBackend(model_ref) + if backend == "lmstudio": + from .lmstudio_backend import LMStudioBackend + return LMStudioBackend(model_ref) + raise ValueError( + f"Backend LLM inconnu: {backend!r} (dispo: {', '.join(BACKENDS)})") + + +def reset_llm_cache() -> None: + """Vide les instances de backend et le cache de chargement mlx.""" + get_llm_backend.cache_clear() + try: + from .mlx_backend import _load + _load.cache_clear() + except Exception: # noqa: BLE001 + pass diff --git a/backend/inkflow/analysis/llm/lmstudio_backend.py b/backend/inkflow/analysis/llm/lmstudio_backend.py new file mode 100644 index 0000000..ac06485 --- /dev/null +++ b/backend/inkflow/analysis/llm/lmstudio_backend.py @@ -0,0 +1,171 @@ +"""Backend LLM via LM Studio (API OpenAI locale). + +LM Studio sert indifferemment des modeles GGUF *et* MLX charges depuis sa GUI, +exposes sur un endpoint OpenAI-compatible (`http://127.0.0.1:1234/v1` par +defaut). InkFlow ne fait que parler HTTP : zero dependance native a compiler, et +le modele reste charge entre redemarrages d'InkFlow. + +Caveat : `enable_thinking=False` (coupe la pensee des modeles hybrides cote mlx) +n'est pas pilotable de facon fiable via l'API ; le template embarque decide. En +mode non-raisonnement on prend le `content` final et on le strip de toute facon. +""" +from __future__ import annotations + +import os +from typing import Callable, Optional + +from .base import LLMBackend +from ._text import _has_complete_json, _strip_reasoning +from ...settings import get_settings + + +def list_models(base_url: str) -> list[dict]: + """Liste les modeles LLM *telecharges* dans LM Studio (charges ou non). + + Utilise l'API REST native (`/api/v0/models`) et non `/v1/models` (qui ne + renvoie que les modeles deja charges) : on peut ainsi proposer n'importe quel + modele telecharge ; LM Studio le charge a la volee (JIT) a la 1re requete. + Renvoie [{id, state, type}] filtre sur les LLM/VLM. Leve en cas d'echec. + """ + import httpx + + root = base_url.rstrip("/") + if root.endswith("/v1"): + root = root[:-len("/v1")] + resp = httpx.get(f"{root}/api/v0/models", timeout=5.0) + resp.raise_for_status() + data = resp.json().get("data", []) + return [ + {"id": m.get("id"), "state": m.get("state"), "type": m.get("type")} + for m in data if m.get("type") in ("llm", "vlm") + ] + + +class LMStudioBackend(LLMBackend): + """Moteur LM Studio : client OpenAI pointe sur le serveur local.""" + + name = "lmstudio" + + def __init__(self, model_ref: str): + super().__init__(model_ref) + self._client = None + self._model = None # resolu paresseusement (model_ref vide -> modele actif) + + def _ensure_client(self): + if self._client is None: + try: + from openai import OpenAI + except ImportError as exc: # noqa: BLE001 + raise RuntimeError( + "Le paquet `openai` est requis pour le backend LM Studio " + "(pip install -e backend)." + ) from exc + base_url = get_settings().lmstudio_base_url + # api_key factice : LM Studio n'authentifie pas, mais le SDK l'exige. + self._client = OpenAI(base_url=base_url, api_key="lm-studio") + return self._client + + def _resolve_model(self, client) -> str: + """Renvoie le nom de modele a utiliser (model_ref, ou 1er modele charge).""" + if self.model_ref: + return self.model_ref + if self._model is None: + try: + models = client.models.list() + except Exception as exc: # noqa: BLE001 + raise self._connection_error(exc) from exc + ids = [m.id for m in getattr(models, "data", [])] + if not ids: + raise RuntimeError( + "Aucun modele charge dans LM Studio : charge un modele " + "(GGUF ou MLX) dans l'app avant de lancer l'analyse." + ) + self._model = ids[0] + return self._model + + def _connection_error(self, exc: Exception) -> RuntimeError: + url = get_settings().lmstudio_base_url + return RuntimeError( + f"LM Studio injoignable sur {url} — lance l'application et active le " + f"serveur local (onglet Developer > Start Server). Detail: {exc}" + ) + + def _sampling(self, max_tokens: int, temperature: float) -> dict: + """Params de sampling a transmettre a LM Studio. + + Par defaut (`lmstudio_defer_config=True`) : dict VIDE -> on delegue + temperature ET plafond de tokens a la config du modele charge dans LM + Studio (ne pas imposer `max_tokens` evite de tronquer la reponse, ce qui + cassait les modeles a raisonnement). Le contexte est de toute facon gere + au chargement cote LM Studio. Si l'utilisateur desactive la delegation, + on reimpose les reglages "Generation Gemma" d'InkFlow. + """ + if get_settings().lmstudio_defer_config: + return {} + return {"temperature": temperature, "max_tokens": max_tokens} + + def complete( + self, + messages: list[dict], + *, + max_tokens: int, + temperature: float, + reasoning: bool, + token_sink: Optional[Callable[[str], None]] = None, + ) -> str: + client = self._ensure_client() + model = self._resolve_model(client) + sampling = self._sampling(max_tokens, temperature) + # Prefill optionnel de la reponse assistant (INKFLOW_LMSTUDIO_PREFILL) : + # ex. "" force les modeles distilles a raisonnement (Qwen) + # a sauter la pensee (seul levier efficace quand enable_thinking/_no_think + # sont ignores). Le modele continue a partir du prefill -> JSON direct. + prefill = os.environ.get("INKFLOW_LMSTUDIO_PREFILL") + if prefill: + messages = messages + [{"role": "assistant", "content": prefill}] + from openai import APIConnectionError + + # LM Studio separe la pensee (`reasoning_content`) de la reponse finale + # (`content`, deja propre). On ne renvoie QUE `content` : la facade en + # extrait le JSON. La pensee n'est diffusee qu'au `token_sink` (affichage + # --stream) ; l'inclure dans le retour risquerait de capter un JSON + # d'exemple present dans le raisonnement. Pour les modeles qui mettent au + # contraire la pensee INLINE dans `content` (...), la facade la + # retire via _strip_reasoning quand reasoning=True. + if token_sink is not None or reasoning: + content_parts: list[str] = [] + try: + stream = client.chat.completions.create( + model=model, messages=messages, stream=True, **sampling, + ) + for chunk in stream: + if not chunk.choices: + continue + delta = chunk.choices[0].delta + rc = getattr(delta, "reasoning_content", None) + if rc and token_sink is not None: + token_sink(rc) # pensee : affichage seulement + piece = delta.content or "" + if piece: + content_parts.append(piece) + if token_sink is not None: + token_sink(piece) + # Arret anticipe en mode raisonnement : des que la reponse + # finale (content) contient un JSON complet, inutile de + # continuer (certains modeles divaguent ensuite). + if reasoning and piece and ("}" in piece or "]" in piece): + if _has_complete_json(_strip_reasoning("".join(content_parts))): + break + except APIConnectionError as exc: + raise self._connection_error(exc) from exc + if token_sink is not None: + token_sink("\n") # separe les generations successives + return "".join(content_parts) + + try: + resp = client.chat.completions.create( + model=model, messages=messages, **sampling, + ) + except APIConnectionError as exc: + raise self._connection_error(exc) from exc + return resp.choices[0].message.content or "" diff --git a/backend/inkflow/analysis/llm/mlx_backend.py b/backend/inkflow/analysis/llm/mlx_backend.py new file mode 100644 index 0000000..5138782 --- /dev/null +++ b/backend/inkflow/analysis/llm/mlx_backend.py @@ -0,0 +1,127 @@ +"""Backend LLM mlx-lm (Apple Silicon) — moteur historique d'InkFlow. + +Charge un modele mlx-community paresseusement (une fois par process, cache LRU) +et genere via le template de chat du tokenizer. Comportement strictement +identique a l'ancien `Gemma.generate` : controle fin de `enable_thinking`, +streaming avec arret anticipe des que le JSON post-pensee est complet. +""" +from __future__ import annotations + +import json +from functools import lru_cache +from typing import Callable, Optional + +from .base import LLMBackend +from ._text import _REASONING_END_MARKERS, _has_complete_json, _strip_reasoning + + +@lru_cache(maxsize=2) +def _load(model_id: str): + # Import paresseux : evite de charger mlx tant qu'on n'analyse pas. + from mlx_lm import load + return load(model_id) + + +def _resolve_chat_template(model_id: str, tokenizer) -> Optional[str]: + """Renvoie un template de chat a passer explicitement, ou None. + + Certaines conversions (Mistral recents...) logent leur template dans un + fichier `chat_template.jinja` que le downloader de mlx-lm n'embarque pas + toujours : `tokenizer.chat_template` est alors vide et `apply_chat_template` + echoue. On recupere alors le fichier officiel du repo. None si le tokenizer + possede deja un template (cas courant) ou si aucun n'est disponible. + """ + if getattr(tokenizer, "chat_template", None): + return None + from pathlib import Path + + from huggingface_hub import hf_hub_download + # Selon les conversions : fichier Jinja brut, ou JSON {"chat_template": ...}. + for fname in ("chat_template.jinja", "chat_template.json"): + try: + text = Path(hf_hub_download(model_id, fname)).read_text(encoding="utf-8") + except Exception: # noqa: BLE001 — fichier absent, on tente le suivant + continue + if fname.endswith(".json"): + data = json.loads(text) + return data.get("chat_template") if isinstance(data, dict) else None + return text + return None # aucun template dispo -> apply_chat_template levera une erreur claire + + +class MLXBackend(LLMBackend): + """Moteur mlx-lm : modeles mlx-community (HuggingFace) sur Apple Silicon.""" + + name = "mlx" + + def __init__(self, model_ref: str): + super().__init__(model_ref) + self._model = None + self._tokenizer = None + self._chat_template = None # template recupere si absent du tokenizer + + def _ensure_loaded(self) -> None: + if self._model is None: + self._model, self._tokenizer = _load(self.model_ref) + self._chat_template = _resolve_chat_template( + self.model_ref, self._tokenizer) + + def complete( + self, + messages: list[dict], + *, + max_tokens: int, + temperature: float, + reasoning: bool, + token_sink: Optional[Callable[[str], None]] = None, + ) -> str: + self._ensure_loaded() + from mlx_lm.sample_utils import make_sampler + + # Modeles hybrides (Qwen3...) : hors mode raisonnement, on DESACTIVE la + # pensee via enable_thinking=False -> JSON direct, bien plus rapide. Avec + # raisonnement, on laisse penser puis la facade retire la pensee. Ce + # kwarg est ignore par les templates qui ne l'utilisent pas (Gemma...). + template_kwargs = {} + if not reasoning: + template_kwargs["enable_thinking"] = False + formatted = self._tokenizer.apply_chat_template( + messages, add_generation_prompt=True, tokenize=False, + chat_template=self._chat_template, # None -> celui du tokenizer + **template_kwargs, + ) + sampler = make_sampler(temp=temperature) + # On streame (token par token) si : un sink est branche (--stream) OU on + # est en mode raisonnement (pour pouvoir s'arreter des que la reponse est + # prete, sans subir les boucles de pensee sans fin). Sinon, lot rapide. + if token_sink is not None or reasoning: + from mlx_lm import stream_generate + parts = [] + seen_end = False # marqueur de fin de pensee rencontre + for resp in stream_generate( + self._model, self._tokenizer, prompt=formatted, + max_tokens=max_tokens, sampler=sampler, + ): + parts.append(resp.text) + if token_sink is not None: + token_sink(resp.text) + # Arret anticipe : une fois la pensee close, des que le JSON + # post-pensee est complet, inutile de continuer a generer. + if reasoning and ("}" in resp.text or "]" in resp.text): + buf = "".join(parts) + if not seen_end: + seen_end = any(mk in buf for mk in _REASONING_END_MARKERS) + if seen_end and _has_complete_json(_strip_reasoning(buf)): + break + if token_sink is not None: + token_sink("\n") # separe les generations successives + return "".join(parts) + from mlx_lm import generate + return generate( + self._model, + self._tokenizer, + prompt=formatted, + max_tokens=max_tokens, + sampler=sampler, + verbose=False, + ) diff --git a/backend/inkflow/analysis/pronunciation.py b/backend/inkflow/analysis/pronunciation.py index c175765..0317069 100644 --- a/backend/inkflow/analysis/pronunciation.py +++ b/backend/inkflow/analysis/pronunciation.py @@ -11,7 +11,7 @@ from typing import Iterable from ..models import Pronunciation, PronunciationEntry from ..settings import get_settings -from .gemma import Gemma +from .llm.client import LLM def apply_pronunciation(text: str, pron: Pronunciation) -> str: @@ -27,7 +27,7 @@ def apply_pronunciation(text: str, pron: Pronunciation) -> str: # Le prompt systeme est editable dans les reglages (settings.prompt_pronunciation). -def propose_pronunciations(text: str, gemma: Gemma, *, max_chars: int = 16000) -> list[PronunciationEntry]: +def propose_pronunciations(text: str, gemma: LLM, *, max_chars: int = 16000) -> list[PronunciationEntry]: """Propose des candidats de prononciation a valider.""" sample = text[:max_chars] prompt = ( diff --git a/backend/inkflow/analysis/segmenter.py b/backend/inkflow/analysis/segmenter.py index 62caf18..17b47fb 100644 --- a/backend/inkflow/analysis/segmenter.py +++ b/backend/inkflow/analysis/segmenter.py @@ -25,7 +25,7 @@ from ..models import ( SegmentType, ) from ..settings import get_settings -from .gemma import Gemma +from .llm.client import LLM # Un paragraphe de dialogue commence par un cadratin (U+2014) ou un tiret long. _DIALOGUE_LEAD_RE = re.compile(r"^\s*[—―]\s*") @@ -65,7 +65,7 @@ _CHUNK_MAX_DIALOGUES = 30 # repliques par appel (fiabilite du modele) def attribute_speakers( segments: list[Segment], - gemma: Gemma, + gemma: LLM, *, characters: Optional[list[Character]] = None, pov: Optional[str] = None, @@ -211,7 +211,7 @@ def _chunk_dialogues( def _refine_unknown_speakers( segments: list[Segment], - gemma: Gemma, + gemma: LLM, *, characters: Optional[list[Character]] = None, confidence: dict[int, str], @@ -276,11 +276,237 @@ def _refine_unknown_speakers( segments[seg_idx].speaker = new +# --- Post-traitement deterministe (sans LLM) -------------------------------- + + +# Traductions FR pour construire l'identite d'un locuteur anonyme. +_ANON_GENDER_FR = {"male": "homme", "female": "femme"} +_ANON_AGE_FR = {"child": "enfant", "young": "jeune", "adult": "adulte", "old": "vieux"} + + +def _anon_identity(gender: Optional[str], age: Optional[str]) -> str: + """Identite canonique d'un locuteur anonyme, regroupe par (genre, age). + + Ex: ("male", "adult") -> "anonyme (homme, adulte)" ; ("male", None) -> + "anonyme (homme)" ; (None, None) -> "anonyme". Tous les personnages-fonction + d'un meme bucket partagent une voix (genre/age suffisent a la choisir).""" + g = _ANON_GENDER_FR.get((gender or "").lower()) + a = _ANON_AGE_FR.get((age or "").lower()) + parts = [p for p in (g, a) if p] + return f"anonyme ({', '.join(parts)})" if parts else "anonyme" + + +def _apply_anonymous_speakers( + segments: list[Segment], *, names=None) -> dict[str, tuple[Optional[str], Optional[str]]]: + """Rattache les repliques a incise de role a un locuteur ANONYME par genre/age. + + Une incise "informa le soldat" -> "anonyme (homme)" : on ne stocke pas la + fonction (garde/marine...), seuls genre+age comptent pour la voix. Genre/age + deduits du nom de role (`_ROLE_GENDER`/`_ROLE_AGE`). Applique APRES le LLM + (autorite deterministe), sans modifier le prompt. Mutation en place. + + Renvoie {identite_anonyme: (genre, age)} des buckets utilises, pour que + l'appelant cree les `Character` generiques correspondants (assignation voix).""" + names = names or set() + used: dict[str, tuple[Optional[str], Optional[str]]] = {} + for seg in segments: + if seg.type is not SegmentType.DIALOGUE: + continue + for inc in seg.incises: + role = incise_role(seg.text, inc, names) + if role: + gender = _ROLE_GENDER.get(role) + age = _ROLE_AGE.get(role) + ident = _anon_identity(gender, age) + seg.speaker = ident + used[ident] = (gender, age) + break + return used + + +def _inversion_gender(text: str) -> Optional[str]: + """Genre porte par le pronom d'une incise d'inversion ("demanda-t-elle" -> + female, "dit-il" -> male). None si aucune inversion. Signal sur LE locuteur.""" + m = _INV_GENDER_RE.search(text) + if not m: + return None + return "female" if m.group("p").lower().startswith("elle") else "male" + + +def _resolve_anonymous_figurants( + segments: list[Segment]) -> dict[str, tuple[Optional[str], Optional[str]]]: + """Resout les repliques restees INDETERMINEES (inconnu/?) en figurants anonymes. + + Quand une replique non resolue est entouree d'une narration decrivant un + figurant genre ("La femme...", "La jeune marine...", "Le soldat..."), on + l'attribue au bucket anonyme correspondant. Genre : pronom d'inversion de la + replique ("demanda-t-elle") sinon l'article du role dans la narration + (la/une -> femme, le/un -> homme). N'agit QUE sur l'indetermine (jamais sur + une attribution sure) -> sans risque pour les personnages nommes. Mutation en + place ; renvoie les buckets crees (pour creer les Character generiques).""" + used: dict[str, tuple[Optional[str], Optional[str]]] = {} + for idx, seg in enumerate(segments): + if seg.type is not SegmentType.DIALOGUE or _is_resolved(seg.speaker): + continue + narr_gender = role_age = None + found = False + for j in (idx - 1, idx + 1): # narration adjacente (avant puis apres) + if 0 <= j < len(segments) and segments[j].type is SegmentType.NARRATION: + m = _ANON_NARR_RE.search(segments[j].text) + if m: + found = True + art = m.group("art").lower().rstrip("’'") + narr_gender = "female" if art in ("la", "une") else "male" + role_age = _ROLE_AGE.get(m.group("role").lower()) + break + if not found: + continue + gender = _inversion_gender(seg.text) or narr_gender + ident = _anon_identity(gender, role_age) + seg.speaker = ident + used[ident] = (gender, role_age) + return used + + +def _canonicalize_speakers(segments: list[Segment], chars: list[Character]) -> None: + """Reecrit chaque locuteur variant vers le nom canonique du cast. + + Le LLM emet souvent des variantes hors liste ("Amiral Mehmet Sagale" pour + "Sagale", "Elvi Okoye" pour "Elvi"). Non rattachees, elles cassent le rendu + (mauvaise voix -> repli narrateur) et le score. On les recolle au canonique + via `heuristic_match` (primitive sure du dedup) : on n'agit QUE sur un match + certain (`Character`), on s'abstient sur ambiguite/inconnu. Pur, sans LLM, + ne touche pas au prompt. Ordre-independant : `tokfreq` calcule globalement. + Idempotent (un nom deja canonique matche en exact).""" + from ..casting.dedup import heuristic_match, _token_freq + + spoken = [s.speaker for s in segments + if s.type is SegmentType.DIALOGUE and _is_resolved(s.speaker)] + if not spoken or not chars: + return + tokfreq = _token_freq(chars, spoken) + for seg in segments: + if seg.type is not SegmentType.DIALOGUE or not _is_resolved(seg.speaker): + continue + match = heuristic_match(seg.speaker, chars, tokfreq) + if isinstance(match, Character): + seg.speaker = match.name + + +# --- Passe deterministe : reparation de l'alternance des tours --------------- + + +def _norm_name(name: str) -> str: + return (name or "").strip().casefold() + + +# Tolerance de narration intercalee entre deux repliques d'un meme run. STRICT +# (0) : seules les repliques d'indices consecutifs forment un run. Toute valeur +# >0 est DANGEREUSE : une narration peut porter une *continuation du meme +# locuteur* ("— …", "Fayez marqua une pause.", "— …") ou il reparle ; verifie +# sur ch06 (runs 66-79 et 83-90 de la reference NON alternes des GAP=1). On +# prefere ne pas reparer une replique isolee que d'inventer une fausse alternance. +_RUN_MAX_NARRATION_GAP = 0 + + +def _dialogue_runs(segments: list[Segment]) -> list[list[int]]: + """Suites de repliques d'indices consecutifs (aucune narration intercalee). + + Hypothese (verifiee sur les references ch05 ET ch06, 0 contre-exemple) : dans + une telle salve ou chaque cadratin marque un changement de locuteur, les + tours alternent strictement. Des qu'une narration s'intercale, l'alternance + n'est plus garantie (continuation possible du meme locuteur) -> nouveau run.""" + runs: list[list[int]] = [] + cur: list[int] = [] + gap = 0 + for i, s in enumerate(segments): + if s.type is SegmentType.DIALOGUE: + cur.append(i) + gap = 0 + else: + gap += 1 + if gap > _RUN_MAX_NARRATION_GAP: + if len(cur) >= 2: + runs.append(cur) + cur = [] + if len(cur) >= 2: + runs.append(cur) + return runs + + +def _repair_alternation(segments: list[Segment], *, names=None) -> None: + """Force l'alternance des tours dans les echanges a exactement 2 locuteurs. + + Pour chaque suite de repliques consecutives a deux locuteurs, on retient, + parmi les deux motifs alternes possibles (A/B/A… ou B/A/B…), celui qui : + 1. ne contredit aucune ancre sure (locuteur explicite d'incise nominale) ; + 2. exige le moins de corrections au resultat de la 1re passe. + On n'agit qu'avec un gagnant STRICT, sinon on s'abstient (on prefere laisser + une erreur qu'en introduire une). En particulier, des qu'un 3e locuteur (meme + minoritaire) apparait dans le run, on ne touche a rien : un echange a >=3 + n'alterne pas forcement. Pur, sans appel LLM ; comble aussi les repliques + indeterminees du run. + """ + names = names or set() + for run in _dialogue_runs(segments): + speakers = [segments[i].speaker for i in run] + resolved = {_norm_name(s) for s in speakers if _is_resolved(s)} + if len(resolved) != 2: + continue + # Noms canoniques (1re occurrence de chaque forme normalisee). + order: list[str] = [] + for s in speakers: + n = _norm_name(s) + if n in resolved and n not in order: + order.append(n) + name_a, name_b = order[0], order[1] + canon_of = {} + for s in speakers: + n = _norm_name(s) + if n in resolved: + canon_of.setdefault(n, s.strip()) + + # Ancres sures : locuteur explicite d'une incise nominale. + anchors: dict[int, str] = {} + for k, idx in enumerate(run): + seg = segments[idx] + for inc in seg.incises: + spk = incise_speaker(seg.text, inc, names) + if spk: + anchors[k] = _norm_name(spk) + break + # Une ancre nommant un tiers (hors paire) -> run suspect, on s'abstient. + if any(a not in (name_a, name_b) for a in anchors.values()): + continue + + def pattern(start: str) -> list[str]: + other = name_b if start == name_a else name_a + return [start if k % 2 == 0 else other for k in range(len(run))] + + candidates = [pattern(name_a), pattern(name_b)] + admissible = [p for p in candidates + if all(p[k] == a for k, a in anchors.items())] + if not admissible: + continue + + def cost(p: list[str]) -> int: # corrections sur les repliques resolues + return sum(1 for k, idx in enumerate(run) + if _is_resolved(segments[idx].speaker) + and _norm_name(segments[idx].speaker) != p[k]) + + admissible.sort(key=cost) + if len(admissible) == 2 and cost(admissible[0]) == cost(admissible[1]): + continue # ex aequo sans ancre discriminante -> trop ambigu + chosen = admissible[0] + for k, idx in enumerate(run): + segments[idx].speaker = canon_of[chosen[k]] + + # --- Extraction du casting (Gemma) ------------------------------------------ # Le prompt systeme est editable dans les reglages (settings.prompt_characters). -def extract_characters(text: str, gemma: Gemma) -> list[Character]: +def extract_characters(text: str, gemma: LLM) -> list[Character]: """Extrait les personnages et leurs attributs (genre, age) d'un texte.""" prompt = ( "A partir de l'extrait suivant, liste les personnages qui parlent ou " @@ -374,17 +600,52 @@ _SPEECH_VERBS = { "tempeta", "rétorque", "lâche", "informa", "renseigna", "indiqua", "rappela", "avertit", "prévint", "prevint", "intima", "rétorquait", "lançait", "questionnait", "reconnut", "constata", "répéta", "repeta", + "intervint", "intervient", "renchérissait", } -# Noms de role pouvant etre sujet d'une incise ("informa le soldat"). +# Noms de role (FONCTION) pouvant etre sujet d'une incise ("informa le soldat"). +# On EXCLUT volontairement les rangs/titres (amiral, capitaine, lieutenant...) : +# ils precedent presque toujours un nom propre ("dit l'amiral Sagale") -> ce +# n'est pas un figurant anonyme mais une personne nommee ; les laisser ici ferait +# capter le titre au lieu du nom. Le nom propre est alors capte normalement. _ROLE_NOUNS = { "garde", "soldat", "sentinelle", "gardien", "prêtre", "pretre", "homme", - "femme", "fille", "garçon", "garcon", "vieille", "vieillard", "capitaine", - "lieutenant", "sergent", "général", "general", "amiral", "officier", "voix", + "femme", "fille", "garçon", "garcon", "vieille", "vieillard", "voix", "inconnu", "inconnue", "étranger", "etranger", "enfant", "serviteur", - "servante", "messager", "domestique", "médecin", "medecin", + "servante", "messager", "domestique", "médecin", "medecin", "marine", "marin", } +# Genre/age probables d'un personnage-fonction, pour l'attribuer a un locuteur +# anonyme regroupe (voix par genre/age). On ne mappe QUE les cas ou le genre de +# la PERSONNE est fortement implique (roles militaires/masculins, feminins +# explicites) ; les cas ambigus (medecin, officier, voix, sentinelle...) restent +# inconnus -> bucket "anonyme" generique. Mieux vaut un genre inconnu qu'errone. +_ROLE_GENDER = { + "soldat": "male", "garde": "male", "gardien": "male", "marine": "male", + "marin": "male", "homme": "male", "garçon": "male", "garcon": "male", + "vieillard": "male", "serviteur": "male", "messager": "male", + "prêtre": "male", "pretre": "male", + "femme": "female", "fille": "female", "servante": "female", + "vieille": "female", "inconnue": "female", +} +# Age probable (rare : seul "enfant" le donne nettement). +_ROLE_AGE = { + "enfant": "child", "garçon": "child", "garcon": "child", + "fille": "child", "vieillard": "old", "vieille": "old", +} + +# Genre du pronom d'une incise d'inversion ("-t-elle"/"-il"). "-" => inversion. +_INV_GENDER_RE = re.compile(r"-(?:t-)?(?P

ils?|elles?)\b", re.IGNORECASE) + +# Figurant genre decrit dans la narration : article (genre) + nom de role proche. +# Ex: "La femme", "La jeune marine", "Le soldat". Sert a resoudre une replique +# indeterminee en anonyme (cf. `_resolve_anonymous_figurants`). +_ANON_NARR_RE = re.compile( + r"\b(?Pla|le|une|un)\s+(?:[\wÀ-ÿ’'-]+\s+){0,2}?" + r"(?P" + "|".join(re.escape(r) for r in sorted(_ROLE_NOUNS, key=len, reverse=True)) + r")\b", + re.IGNORECASE, +) + # Mots vides ignores quand on indexe les tokens d'un nom de personnage. _NAME_STOP = { "le", "la", "les", "un", "une", "de", "du", "des", "monsieur", "madame", @@ -446,12 +707,14 @@ _REJECT = object() # le sujet n'en est pas un -> pas une incise def _classify_subject(subj: str, idx: dict[str, str]): - """Locuteur porte par le sujet d'une incise nominale. + """Locuteur NOMME porte par le sujet d'une incise nominale. - personnage connu -> nom canonique ; - nom propre (capitalise) inconnu -> nom de surface (seed quand meme : le texte le nomme, independamment de la fiabilite de l'extraction) ; - - nom de role generique ("le soldat") -> None (incise reelle, pas de seed) ; + - nom de role ("le soldat") -> None : pas un locuteur NOMME. L'incise reste + detectee (narration), et le rattachement a un anonyme (par genre/age) se + fait en post-traitement (cf. `_apply_anonymous_speakers` / `incise_role`) ; - mot quelconque -> _REJECT (pas une incise). """ low = subj.lower() @@ -464,11 +727,14 @@ def _classify_subject(subj: str, idx: dict[str, str]): return _REJECT -def _nominal_matches(text: str, names) -> list[tuple[int, int, Optional[str]]]: - """Passe 2 : (start, end, locuteur) pour chaque incise nominale. +def _nominal_matches(text: str, names + ) -> list[tuple[int, int, Optional[str], str]]: + """Passe 2 : (start, end, locuteur, sujet) pour chaque incise nominale. Une incise nominale = verbe de parole + sujet (nom du casting, nom propre, ou nom de role). Le sujet nom propre est seede meme absent du casting. + Le 4e champ est le sujet (minuscule) : sert a reconnaitre un nom de role + (`incise_role`) pour rattacher un locuteur anonyme par genre/age. """ idx = _name_token_index(names) literals = sorted(set(idx) | _ROLE_NOUNS, key=len, reverse=True) @@ -486,13 +752,15 @@ def _nominal_matches(text: str, names) -> list[tuple[int, int, Optional[str]]]: r"[^.!?…»\",;]*?)" r"(?P[.!?…,])", ) - out: list[tuple[int, int, Optional[str]]] = [] + out: list[tuple[int, int, Optional[str], str]] = [] for m in pat.finditer(text): - spk = _classify_subject(m.group("subj"), idx) + subj = m.group("subj") + spk = _classify_subject(subj, idx) if spk is _REJECT: continue out.append((m.start("inc"), - _incise_end(text, m.end("close"), m.group("lead")), spk)) + _incise_end(text, m.end("close"), m.group("lead")), + spk, subj.lower())) return out @@ -511,18 +779,33 @@ def _merge_spans(spans: list[tuple[int, int]]) -> list[Incise]: def detect_incises(text: str, *, names=None) -> list[Incise]: """Bornes des incises dans une replique (inversion + nominale cast-aware).""" spans = _inversion_spans(text) - spans += [(s, e) for s, e, _ in _nominal_matches(text, names or set())] + spans += [(s, e) for s, e, _, _ in _nominal_matches(text, names or set())] return _merge_spans(spans) def incise_speaker(text: str, incise: Incise, names) -> Optional[str]: - """Locuteur explicite porte par une incise nominale ("compatit Holden").""" - for s, e, spk in _nominal_matches(text, names): + """Locuteur NOMME explicite porte par une incise nominale ("compatit Holden"). + + None pour une incise de role ("informa le soldat") : un role n'est pas un + locuteur nomme (cf. `incise_role` pour le rattachement anonyme). + """ + for s, e, spk, _ in _nominal_matches(text, names): if s == incise.start and e == incise.end: return spk return None +def incise_role(text: str, incise: Incise, names) -> Optional[str]: + """Nom de role (minuscule) sujet d'une incise ("informa le soldat" -> "soldat"). + + Renvoie None si l'incise n'est pas une incise de role. Sert a rattacher la + replique a un locuteur anonyme regroupe par genre/age (cf. `_anon_identity`).""" + for s, e, _spk, subj in _nominal_matches(text, names): + if s == incise.start and e == incise.end and subj in _ROLE_NOUNS: + return subj + return None + + def iter_incise_pieces( text: str, incises: list[Incise] ) -> list[tuple[bool, str]]: @@ -552,10 +835,10 @@ def iter_incise_pieces( def analyze_chapter( chapter: Chapter, ct: ChapterText, - gemma: Gemma, + gemma: LLM, *, book_chars: Optional[list[Character]] = None, - dedup_gemma: Optional[Gemma] = None, + dedup_gemma: Optional[LLM] = None, ) -> tuple[ChapterAnalysis, list[Character]]: """Analyse complete d'un chapitre. @@ -594,12 +877,18 @@ def analyze_chapter( # Annotation deterministe des incises (bornes, non destructif) + seeding : # une incise nominale qui nomme un personnage fixe le locuteur avec certitude # AVANT l'appel LLM (corrige les cas que le petit modele rate). + # NB: ne PAS inclure les alias ici -> mesure : ca change trop le prompt et + # provoque de gros effets papillon (ch06 12B: 96% -> 80%). Les epithetes sont + # rattaches en post-traitement par la canonicalisation (sur le cast complet). names = {c.name for c in chars} for seg in segments: if seg.type is not SegmentType.DIALOGUE: continue seg.incises = detect_incises(seg.text, names=names) for inc in seg.incises: + # PRE-LLM : seuls les noms propres seedent (les incises de role + # renvoient None -> pas de seed, donc prompt inchange ; les roles + # sont rattaches en anonymes en post-traitement, sans effet papillon). spk = incise_speaker(seg.text, inc, names) if spk: seg.speaker = spk @@ -611,6 +900,22 @@ def analyze_chapter( _refine_unknown_speakers(segments, gemma, characters=chapter_chars, confidence=conf) + # Post-traitement deterministe (sans LLM). Ordre important : + # 1. rattache les incises de role a un locuteur anonyme par genre/age ; + # 2. repare l'alternance des tours dans les echanges a deux ; + # 3. recolle les variantes de noms au canonique du cast (rendu + score) ; + # 4. resout les figurants restes indetermines via la narration adjacente. + anon = _apply_anonymous_speakers(segments, names=names) + _repair_alternation(segments, names=names) + _canonicalize_speakers(segments, chars) + anon.update(_resolve_anonymous_figurants(segments)) + # Cree les Character generiques des buckets anonymes (assignation de voix). + known = {c.name for c in chars} + for ident, (gender, age) in anon.items(): + if ident not in known: + chars.append(Character(name=ident, gender=gender, age=age)) + known.add(ident) + # Absorbe les locuteurs residuels (hors liste) en aliases (heuristique seule). chars, _ = reconcile_characters( chars, [], None, speaker_names=[s.speaker for s in segments]) diff --git a/backend/inkflow/api/app.py b/backend/inkflow/api/app.py index d7ae376..981fd61 100644 --- a/backend/inkflow/api/app.py +++ b/backend/inkflow/api/app.py @@ -20,7 +20,7 @@ from pydantic import BaseModel from ..config import DATA_DIR, book_data_dir, book_output_dir, ensure_dirs from ..epub.parser import load_book, load_chapter_text, parse_epub -from ..models import Cast, ChapterAnalysis, Pronunciation +from ..models import Cast, ChapterAnalysis, Character, Pronunciation from ..pipeline.orchestrator import load_state, orchestrator from ..settings import Settings, get_settings, save_settings from ..store import artifacts @@ -196,6 +196,43 @@ def put_cast(slug: str, cast: Cast) -> dict: return {"saved": True} +@app.get("/api/books/{slug}/cast/unresolved") +def get_unresolved_speakers(slug: str) -> dict: + """Locuteurs apparaissant dans l'analyse mais rattaches a aucun personnage. + + Surface les surfaces que la canonicalisation deterministe a refuse de + trancher, pour que l'utilisateur les aliase/fusionne a la main. Predicat = + rattachement a un Character (par nom/alias exact ou heuristique), independant + de l'assignation de voix.""" + from ..casting.dedup import heuristic_match + from ..epub.parser import load_book + _require(slug) + cast = artifacts.load_cast(slug) + + def resolves(spk: str) -> bool: + low = spk.lower() + for ch in cast.characters: + if ch.name.lower() == low or low in (a.lower() for a in ch.aliases): + return True + return isinstance(heuristic_match(spk, cast.characters), Character) + + agg: dict[str, dict] = {} + for ch in load_book(slug).chapters: + if not artifacts.analysis_path(slug, ch.index).exists(): + continue + for seg in artifacts.load_analysis(slug, ch.index).segments: + spk = (seg.speaker or "").strip() + if not spk or spk.lower() in {"narrateur", "inconnu", "?"}: + continue + if resolves(spk): + continue + row = agg.setdefault(spk, {"speaker": spk, "count": 0, "chapters": []}) + row["count"] += 1 + if ch.index not in row["chapters"]: + row["chapters"].append(ch.index) + return {"unresolved": sorted(agg.values(), key=lambda r: -r["count"])} + + @app.get("/api/books/{slug}/pronunciation") def get_pron(slug: str) -> dict: _require(slug) @@ -222,6 +259,16 @@ def write_settings(settings: Settings) -> dict: return {"saved": True} +@app.get("/api/lmstudio/models") +def list_lmstudio_models() -> dict: + """Modeles telecharges dans LM Studio (pour peupler le selecteur de l'UI).""" + from ..analysis.llm.lmstudio_backend import list_models + try: + return {"models": list_models(get_settings().lmstudio_base_url)} + except Exception as exc: # noqa: BLE001 — serveur down / injoignable + raise HTTPException(503, f"LM Studio injoignable: {exc}") + + # --- Voicebank + preview ----------------------------------------------------- @app.get("/api/voicebank") diff --git a/backend/inkflow/casting/assign.py b/backend/inkflow/casting/assign.py index 2f2e5f4..ba39b6a 100644 --- a/backend/inkflow/casting/assign.py +++ b/backend/inkflow/casting/assign.py @@ -1,10 +1,12 @@ """Auto-casting : attribue une voix distincte a chaque personnage. Strategie deterministe : -- Narrateur : voix FR native par defaut (ff_siwis), sinon premiere voix. -- Personnages : voix du meme genre, distinctes tant qu'il en reste ; au-dela on - recycle en repartissant le plus equitablement possible. Genre inconnu -> pool - mixte. L'ordre (tri par nom) garantit la reproductibilite. +- Narrateur : voix dediee de la voicebank (PREFERRED_NARRATOR), sinon 1re voix. +- Personnages nommes : voix du meme genre dans le pool *nomme* (anonymous=False), + distinctes tant qu'il en reste ; au-dela recyclage equitable. +- Figurants anonymes ("anonyme (...)") : voix du meme genre dans le pool *reserve* + (anonymous=True), pour ne pas consommer les voix des personnages nommes. +Genre inconnu -> pool mixte. L'ordre (tri par nom) garantit la reproductibilite. L'utilisateur pourra surcharger ces choix dans l'UI. """ from __future__ import annotations @@ -14,18 +16,29 @@ from typing import Optional from ..models import Cast, Character, Voicebank -# Voix narrateur preferee (FR native). -PREFERRED_NARRATOR = "fr_f_siwis" +# Voix narrateur preferee (voix dediee de la voicebank CML). +PREFERRED_NARRATOR = "fr_narrator" -def _pick_pool(vb: Voicebank, gender: Optional[str], narrator_id: str) -> list[str]: - """Voix candidates : on privilegie STRICTEMENT le genre (quitte a reutiliser). +def _is_anonymous(name: str) -> bool: + """Un figurant anonyme ("anonyme (homme)", "anonyme (femme, vieux)", ...).""" + return name.strip().lower().startswith("anonyme") - On ne croise le genre que si aucune voix du bon genre n'existe. Le narrateur - est exclu tant qu'il reste d'autres options, pour le distinguer. + +def _pick_pool(vb: Voicebank, gender: Optional[str], narrator_id: str, + *, anonymous: bool) -> list[str]: + """Voix candidates : genre STRICT et pool reserve selon `anonymous`. + + Les figurants anonymes tirent dans le sous-ensemble `anonymous=True`, les + personnages nommes dans le sous-ensemble `anonymous=False` — les deux ne se + melangent pas. On ne croise (tag puis genre) qu'en dernier recours si le pool + cible est vide. Le narrateur est exclu tant qu'il reste d'autres options. """ - same = [e.id for e in vb.by_gender(gender)] if gender in ("male", "female") else [] - pool = same if same else [e.id for e in vb.entries] + genders = (gender,) if gender in ("male", "female") else ("male", "female") + # 1) genre + tag exacts ; 2) genre seul ; 3) tout. + same_tag = [e.id for g in genders for e in vb.by_gender(g, anonymous=anonymous)] + same_gender = [e.id for g in genders for e in vb.by_gender(g)] + pool = same_tag or same_gender or [e.id for e in vb.entries] non_narrator = [vid for vid in pool if vid != narrator_id] return non_narrator or pool # garde le narrateur seulement s'il est seul @@ -55,7 +68,7 @@ def assign_voices( if respect_existing and ch.voice_id and vb.by_id(ch.voice_id): usage[ch.voice_id] += 1 continue # respecte une attribution existante (override utilisateur) - pool = _pick_pool(vb, ch.gender, narrator_id) + pool = _pick_pool(vb, ch.gender, narrator_id, anonymous=_is_anonymous(ch.name)) # Choisit la voix la moins utilisee du pool (donc une voix neuve d'abord). best = min(pool, key=lambda vid: (usage[vid], pool.index(vid))) ch.voice_id = best diff --git a/backend/inkflow/casting/dedup.py b/backend/inkflow/casting/dedup.py index 137c1d5..03ec15f 100644 --- a/backend/inkflow/casting/dedup.py +++ b/backend/inkflow/casting/dedup.py @@ -132,12 +132,15 @@ def _absorb( age: Optional[str] = None, description: Optional[str] = None, voice_id: Optional[str] = None, + keep_canonical: bool = False, ) -> None: """Fusionne la variante `name` dans `target` (mutation en place). Enrichit les attributs manquants, recalcule le nom canonique et range les - autres formes en aliases. - """ + autres formes en aliases. `keep_canonical=True` GARDE le nom actuel de + `target` comme canonique (les autres formes deviennent aliases) : sert a + rendre stable un nom deja etabli dans le cast (un chapitre ne doit pas + renommer "Sagale" en "Amiral Mehmet Sagale").""" target.gender = target.gender or gender target.age = target.age or age target.description = target.description or description @@ -148,17 +151,36 @@ def _absorb( f = (f or "").strip() if f: forms.setdefault(_norm(f), f) - canon = max(forms, key=lambda n: _completeness(forms[n])) + canon = (_norm(target.name) if keep_canonical + else max(forms, key=lambda n: _completeness(forms[n]))) target.name = forms[canon] target.aliases = sorted(v for k, v in forms.items() if k != canon) +# Genre/age d'un locuteur anonyme "anonyme (homme, adulte)" (inverse de +# segmenter._anon_identity) -> pour qu'il herite d'une voix du bon genre. +_ANON_GENDER = {"homme": "male", "femme": "female"} +_ANON_AGE = {"enfant": "child", "jeune": "young", "adulte": "adult", "vieux": "old"} + + +def _anon_attrs(name: str) -> tuple[Optional[str], Optional[str]]: + low = name.strip().lower() + if not low.startswith("anonyme"): + return None, None + inside = low[low.find("(") + 1: low.find(")")] if "(" in low else "" + toks = [t.strip() for t in inside.split(",")] + gender = next((_ANON_GENDER[t] for t in toks if t in _ANON_GENDER), None) + age = next((_ANON_AGE[t] for t in toks if t in _ANON_AGE), None) + return gender, age + + def _item(c) -> dict: """Normalise un personnage ou un nom brut en entree de reconciliation.""" if isinstance(c, Character): return {"name": c.name, "gender": c.gender, "age": c.age, "description": c.description, "voice_id": c.voice_id} - return {"name": str(c), "gender": None, "age": None, + gender, age = _anon_attrs(str(c)) # figurant anonyme -> genre/age depuis le nom + return {"name": str(c), "gender": gender, "age": age, "description": None, "voice_id": None} @@ -194,6 +216,9 @@ def reconcile_characters( """ chars = [c.model_copy(deep=True) for c in book_chars] name_map: dict[str, str] = {} + # Noms deja etablis dans le cast : on les garde canoniques (un chapitre ne + # doit pas renommer un personnage existant en une forme plus longue/titree). + established = {_norm(c.name) for c in book_chars} items = [_item(c) for c in new_chars] seen = {_norm(it["name"]) for it in items} @@ -214,7 +239,8 @@ def reconcile_characters( pending.append(it) elif m is not None: _absorb(m, it["name"], gender=it["gender"], age=it["age"], - description=it["description"], voice_id=it["voice_id"]) + description=it["description"], voice_id=it["voice_id"], + keep_canonical=_norm(m.name) in established) name_map[_norm(it["name"])] = m.name elif gemma is not None: pending.append(it) # peut etre une variante non evidente ("Jim") @@ -231,7 +257,8 @@ def reconcile_characters( target = hm if isinstance(hm, Character) else None if target is not None: _absorb(target, it["name"], gender=it["gender"], age=it["age"], - description=it["description"], voice_id=it["voice_id"]) + description=it["description"], voice_id=it["voice_id"], + keep_canonical=_norm(target.name) in established) name_map[_norm(it["name"])] = target.name else: _create(chars, it, name_map) diff --git a/backend/inkflow/casting/voicebank.py b/backend/inkflow/casting/voicebank.py index cfc650c..9388a8b 100644 --- a/backend/inkflow/casting/voicebank.py +++ b/backend/inkflow/casting/voicebank.py @@ -1,9 +1,14 @@ -"""Banque de voix : un jeu de voix variees (genre/age) auto-suffisant. +"""Banque de voix : un jeu de voix francaises variees (genre, pool anonyme). -Chaque voix s'appuie sur une voix Kokoro (identite + clip de reference). Le clip -de reference est genere une fois en lisant un passage francais standard ; il sert -de reference de timbre pour le clonage Qwen3 (rendu final). Aucune ressource -externe a sourcer. +La banque de reference est peuplee par `scripts/import_voices.py` a partir de +**vrais clips de locuteurs francais** (CML-TTS, livres audio) : chaque voix a son +`ref_audio` + `ref_text`, qui servent de reference de timbre au clonage Qwen3 +(rendu final). C'est la source de verite (metadata.json versionne). + +`build_voicebank()` ci-dessous est un fallback **legacy** : il regenere des clips +*avec Kokoro* (presets a timbre anglais lisant du francais -> accent). Il ne se +declenche que si metadata.json est absent ou sans `ref_audio`. Re-peupler la +banque = relancer le script d'import, pas ce fallback. Resolution moteur : - Kokoro -> VoiceSpec(preset=kokoro_voice) (rapide, preview / draft) diff --git a/backend/inkflow/cli.py b/backend/inkflow/cli.py index 36c8d57..09cd61b 100644 --- a/backend/inkflow/cli.py +++ b/backend/inkflow/cli.py @@ -53,14 +53,16 @@ def analyze( chapter: Optional[int] = typer.Option(None, help="Index de chapitre unique (def: tous)."), limit: Optional[int] = typer.Option(None, help="Limiter au N premiers chapitres rendus."), force: bool = typer.Option(False, help="Re-analyser meme si un artefact existe."), + backend: Optional[str] = typer.Option(None, help="Moteur LLM: mlx ou lmstudio (def: reglages)."), + model: Optional[str] = typer.Option(None, help="Identifiant de modele (def: reglages)."), ): """Analyse Gemma : segments narration/dialogue + locuteurs + casting.""" - from .analysis.gemma import Gemma + from .analysis.llm.client import LLM from .analysis.segmenter import analyze_chapter from .settings import get_settings book = load_book(slug) - gemma = Gemma() + gemma = LLM(model_id=model, backend=backend) dedup_gemma = gemma if get_settings().dedup_use_gemma else None cast = artifacts.load_cast(slug) chars = list(cast.characters) @@ -100,6 +102,8 @@ def benchmark( slug: str, models: Optional[str] = typer.Option( None, help="Modeles a comparer, separes par des virgules (def: modele courant)."), + backend: Optional[str] = typer.Option( + None, help="Moteur LLM: mlx ou lmstudio (def: reglages)."), chapter: Optional[int] = typer.Option( None, help="Restreindre a un chapitre (def: tous ceux avec reference)."), temperature: Optional[float] = typer.Option( @@ -115,12 +119,16 @@ def benchmark( import sys from datetime import datetime - from .analysis import gemma as _gemma + from .analysis.llm import client as _llm from .analysis.benchmark import run_benchmark from .settings import get_settings + settings = get_settings() + backend_name = backend or settings.gemma_backend + default_model = (settings.lmstudio_model if backend_name == "lmstudio" + else settings.gemma_model) model_ids = ([m.strip() for m in models.split(",") if m.strip()] - if models else [get_settings().gemma_model]) + if models else [default_model]) chapters = [chapter] if chapter is not None else None label = "artefacts en cache" if use_cached else f"{len(model_ids)} modele(s)" @@ -137,17 +145,17 @@ def benchmark( def _sink(piece: str) -> None: sys.stdout.write(piece) sys.stdout.flush() - _gemma.set_token_sink(_sink) + _llm.set_token_sink(_sink) try: report = run_benchmark( - slug, model_ids, chapters=chapters, + slug, model_ids, backend=backend_name, chapters=chapters, temperature=temperature, reasoning=reasoning if reasoning else None, use_cached=use_cached, progress=_progress) finally: if stream: - _gemma.set_token_sink(None) + _llm.set_token_sink(None) report.generated_at = datetime.now().isoformat(timespec="seconds") # Table comparative : une ligne par modele (agregat micro-moyenne). @@ -197,9 +205,11 @@ def benchmark( def pronounce( slug: str, chapter: Optional[int] = typer.Option(None, help="Index de chapitre (def: 1er rendu)."), + backend: Optional[str] = typer.Option(None, help="Moteur LLM: mlx ou lmstudio (def: reglages)."), + model: Optional[str] = typer.Option(None, help="Identifiant de modele (def: reglages)."), ): """Propose des candidats de prononciation (Gemma) -> pronunciation.json.""" - from .analysis.gemma import Gemma + from .analysis.llm.client import LLM from .analysis.pronunciation import merge_pronunciations, propose_pronunciations book = load_book(slug) @@ -209,7 +219,7 @@ def pronounce( console.print("[red]Chapitre introuvable.[/]"); raise typer.Exit(1) ct = load_chapter_text(slug, ch) - gemma = Gemma() + gemma = LLM(model_id=model, backend=backend) with console.status("Recherche des mots a risque…"): new = propose_pronunciations("\n".join(ct.paragraphs), gemma) pron = merge_pronunciations(artifacts.load_pronunciation(slug), new) @@ -228,6 +238,8 @@ def cast( rebuild_voicebank: bool = typer.Option(False, help="Regenere les clips de la voicebank."), dedup: bool = typer.Option(False, help="Deduplique d'abord les variantes de noms (heuristique)."), llm: bool = typer.Option(False, "--llm", help="Ajoute la passe Gemma a la dedup (moins sur)."), + backend: Optional[str] = typer.Option(None, help="Moteur LLM pour --llm: mlx ou lmstudio (def: reglages)."), + model: Optional[str] = typer.Option(None, help="Identifiant de modele pour --llm (def: reglages)."), ): """Construit la voicebank (si besoin) et auto-assigne les voix au casting.""" from .casting.assign import assign_voices @@ -243,8 +255,8 @@ def cast( from .models import Cast gemma = None if llm: - from .analysis.gemma import Gemma - gemma = Gemma() + from .analysis.llm.client import LLM + gemma = LLM(model_id=model, backend=backend) before = len(cast.characters) with console.status("Deduplication du casting…"): chars = dedup_cast(cast.characters, gemma) diff --git a/backend/inkflow/config.py b/backend/inkflow/config.py index 38029ef..3b777a2 100644 --- a/backend/inkflow/config.py +++ b/backend/inkflow/config.py @@ -29,8 +29,17 @@ VOICEBANK_DIR = _env_path("INKFLOW_VOICEBANK_DIR", PROJECT_ROOT / "voicebank") # Echantillons fournis SAMPLES_DIR = PROJECT_ROOT / "samples" +# --- Moteur LLM d'analyse ---------------------------------------------------- +# Backend par defaut : "mlx" (mlx-lm, Apple Silicon) ou "lmstudio" (API OpenAI +# locale de LM Studio, sert GGUF *et* MLX charges via sa GUI). +GEMMA_BACKEND = os.environ.get("INKFLOW_GEMMA_BACKEND", "mlx") +# Endpoint OpenAI-compatible de LM Studio (onglet Developer > Start Server). +LMSTUDIO_BASE_URL = os.environ.get( + "INKFLOW_LMSTUDIO_BASE_URL", "http://127.0.0.1:1234/v1" +) + # --- Modeles MLX (HuggingFace mlx-community) --------------------------------- -# Analyse de texte : Gemma via mlx-lm. +# Analyse de texte : Gemma via mlx-lm (backend "mlx"). GEMMA_MODEL = os.environ.get( "INKFLOW_GEMMA_MODEL", "mlx-community/gemma-3-4b-it-4bit" ) diff --git a/backend/inkflow/models.py b/backend/inkflow/models.py index e31f63a..5c5c123 100644 --- a/backend/inkflow/models.py +++ b/backend/inkflow/models.py @@ -119,6 +119,7 @@ class VoiceEntry(BaseModel): label: Optional[str] = None # libelle lisible ref_audio: Optional[str] = None # chemin du clip (relatif a voicebank/) ref_text: Optional[str] = None # transcription du clip + anonymous: bool = False # voix reservee aux figurants "anonyme (...)" class Voicebank(BaseModel): @@ -127,8 +128,11 @@ class Voicebank(BaseModel): def by_id(self, voice_id: str) -> Optional[VoiceEntry]: return next((e for e in self.entries if e.id == voice_id), None) - def by_gender(self, gender: str) -> list[VoiceEntry]: - return [e for e in self.entries if e.gender == gender] + def by_gender(self, gender: str, *, anonymous: Optional[bool] = None) -> list[VoiceEntry]: + """Voix d'un genre. `anonymous=False`/`True` filtre le pool reserve aux + figurants ; None ne filtre pas.""" + return [e for e in self.entries + if e.gender == gender and (anonymous is None or e.anonymous == anonymous)] class PronunciationEntry(BaseModel): diff --git a/backend/inkflow/pipeline/orchestrator.py b/backend/inkflow/pipeline/orchestrator.py index af73f36..49cc2aa 100644 --- a/backend/inkflow/pipeline/orchestrator.py +++ b/backend/inkflow/pipeline/orchestrator.py @@ -124,7 +124,7 @@ class Orchestrator: # --- etapes -------------------------------------------------------------- def run_analyze(self, slug: str, chapter_indexes: Optional[list[int]] = None) -> None: def job() -> None: - from ..analysis.gemma import Gemma + from ..analysis.llm.client import LLM from ..analysis.segmenter import analyze_chapter from ..models import Cast from ..settings import get_settings @@ -137,7 +137,7 @@ class Orchestrator: state.active_stage = "analyze" self._save_and_emit(state) - gemma = Gemma() + gemma = LLM() dedup_gemma = gemma if get_settings().dedup_use_gemma else None cast = artifacts.load_cast(slug) chars = list(cast.characters) @@ -196,7 +196,7 @@ class Orchestrator: tout en maintenant la coherence du livre (deduplication). """ def job() -> None: - from ..analysis.gemma import Gemma + from ..analysis.llm.client import LLM from ..analysis.segmenter import extract_characters from ..casting.dedup import reconcile_characters from ..models import Cast @@ -209,7 +209,7 @@ class Orchestrator: state.active_stage = "cast" self._save_and_emit(state) - gemma = Gemma() + gemma = LLM() dedup_gemma = gemma if get_settings().dedup_use_gemma else None cast = artifacts.load_cast(slug) chars = list(cast.characters) @@ -239,7 +239,7 @@ class Orchestrator: def run_dedup_cast(self, slug: str) -> None: """Replie les doublons d'un casting deja constitue (Holden/James Holden...).""" def job() -> None: - from ..analysis.gemma import Gemma + from ..analysis.llm.client import LLM from ..casting.dedup import dedup_cast from ..models import Cast from ..settings import get_settings @@ -250,7 +250,7 @@ class Orchestrator: self._save_and_emit(state) cast = artifacts.load_cast(slug) - gemma = Gemma() if get_settings().dedup_use_gemma else None + gemma = LLM() if get_settings().dedup_use_gemma else None chars = dedup_cast(cast.characters, gemma) artifacts.save_cast(slug, Cast( narrator_voice_id=cast.narrator_voice_id, characters=chars)) @@ -259,7 +259,7 @@ class Orchestrator: def run_pronounce(self, slug: str) -> None: def job() -> None: - from ..analysis.gemma import Gemma + from ..analysis.llm.client import LLM from ..analysis.pronunciation import ( merge_pronunciations, propose_pronunciations, @@ -271,7 +271,7 @@ class Orchestrator: state.active_stage = "pronounce" self._save_and_emit(state) - gemma = Gemma() + gemma = LLM() pron = artifacts.load_pronunciation(slug) targets = book.render_chapters[:3] # echantillon de chapitres for i, ch in enumerate(targets): diff --git a/backend/inkflow/pipeline/render.py b/backend/inkflow/pipeline/render.py index 6f70c21..1bbd402 100644 --- a/backend/inkflow/pipeline/render.py +++ b/backend/inkflow/pipeline/render.py @@ -45,12 +45,22 @@ def make_voice_resolver(cast, voicebank, engine: str) -> VoiceResolver: Replie sur la voix du narrateur si le locuteur n'a pas de voix attribuee. """ + import logging + from ..casting.assign import resolve_speaker_voice from ..casting.voicebank import voice_spec_for + logger = logging.getLogger(__name__) + warned: set[str] = set() # 1 warning par locuteur et par chapitre (resolver local) + def resolve(speaker: str): vid = resolve_speaker_voice(speaker, cast, voicebank) if vid is None: + if speaker != "narrateur" and speaker not in warned: + warned.add(speaker) + logger.warning( + "Locuteur sans voix attribuee, repli sur le narrateur: %r", + speaker) vid = cast.narrator_voice_id entry = voicebank.by_id(vid) if vid else None if entry is None: diff --git a/backend/inkflow/settings.py b/backend/inkflow/settings.py index 3d6cf6a..8283731 100644 --- a/backend/inkflow/settings.py +++ b/backend/inkflow/settings.py @@ -67,6 +67,21 @@ DEFAULT_PROMPT_DEDUP = ( class Settings(BaseModel): """Reglages techniques globaux, persistes dans data/settings.json.""" + # --- Moteur LLM d'analyse --- + # "mlx" : mlx-lm (Apple Silicon), utilise `gemma_model`. + # "lmstudio" : API OpenAI locale de LM Studio (sert GGUF *et* MLX), utilise + # `lmstudio_base_url` + `lmstudio_model`. + gemma_backend: str = config.GEMMA_BACKEND + lmstudio_base_url: str = config.LMSTUDIO_BASE_URL + lmstudio_model: str = "" # vide -> 1er modele charge dans LM Studio + # Par defaut, le backend LM Studio DELEGUE la config de generation + # (temperature, plafond de tokens) au modele charge dans LM Studio : on + # n'impose ni `temperature` ni `max_tokens` dans la requete. Les reglages + # "Generation Gemma" ci-dessous pilotent alors uniquement le backend MLX. + # Mettre a False pour reimposer ces reglages a LM Studio (utile pour des + # benchmarks reproductibles a temperature fixe). + lmstudio_defer_config: bool = True + # --- Modeles MLX (identifiants HuggingFace) --- gemma_model: str = config.GEMMA_MODEL qwen3_model: str = config.QWEN3_TTS_MODEL @@ -179,7 +194,7 @@ def _invalidate_model_caches() -> None: except Exception: # noqa: BLE001 pass try: - from .analysis.gemma import _load - _load.cache_clear() + from .analysis.llm.factory import reset_llm_cache + reset_llm_cache() except Exception: # noqa: BLE001 pass diff --git a/backend/inkflow/tts/qwen3.py b/backend/inkflow/tts/qwen3.py index 76d0969..996d741 100644 --- a/backend/inkflow/tts/qwen3.py +++ b/backend/inkflow/tts/qwen3.py @@ -7,15 +7,33 @@ Deux modes : """ from __future__ import annotations +import logging + import numpy as np from ..settings import get_settings from .base import TTSBackend, VoiceSpec, to_mono_float32 from .chunk import chunk_text +logger = logging.getLogger(__name__) + # Qwen3 tolere des sequences plus longues que Kokoro, mais on borne quand meme. _QWEN_MAX_CHARS = 500 +# Garde-fou anti-derive : Qwen3 part parfois en boucle (audio 50x trop long) ou +# s'arrete net (sortie ~0 s). On estime la duree plausible d'un chunk depuis sa +# longueur (~15 caracteres/s en francais) et on rejette/reessaie les sorties hors +# bornes. Stochastique (temperature) -> un retry change le tirage. +_CHARS_PER_SEC = 15.0 +_QWEN_RETRIES = 3 +_MIN_FLOOR_SEC = 0.3 # en deca = generation echouee (silence) + + +def _bounds(n_chars: int) -> tuple[float, float, float]: + """(attendu, min, max) en secondes pour un chunk de `n_chars` caracteres.""" + expected = max(1.0, n_chars / _CHARS_PER_SEC) + return expected, max(_MIN_FLOOR_SEC, 0.4 * expected), 2.5 * expected + 2.0 + class Qwen3Backend(TTSBackend): name = "qwen3" @@ -45,14 +63,46 @@ class Qwen3Backend(TTSBackend): kwargs["voice"] = voice.preset or get_settings().qwen3_default_voice return kwargs + def _gen_chunk_once(self, chunk: str, kwargs: dict) -> np.ndarray: + """Genere l'audio (concatene) d'un chunk en un tirage.""" + out: list[np.ndarray] = [] + for result in self._model.generate(text=chunk, **kwargs): + self._sample_rate = getattr(result, "sample_rate", self._sample_rate) + out.append(to_mono_float32(result.audio)) + return np.concatenate(out) if out else np.zeros(0, dtype=np.float32) + + def _gen_chunk_guarded(self, chunk: str, kwargs: dict) -> np.ndarray: + """Genere un chunk en rejetant les sorties aberrantes (boucle / coupure). + + Retourne le 1er tirage dans les bornes ; sinon la tentative la plus proche + de la duree attendue (en excluant les silences et les derives extremes). + """ + sr = self._sample_rate + expected, lo, hi = _bounds(len(chunk)) + attempts: list[np.ndarray] = [] + for i in range(_QWEN_RETRIES): + audio = self._gen_chunk_once(chunk, kwargs) + dur = len(audio) / sr + if lo <= dur <= hi: + if i: + logger.info("Qwen3: chunk OK au retry %d (%.1fs)", i, dur) + return audio + logger.warning("Qwen3: sortie aberrante %.1fs (attendu ~%.1fs) — retry", dur, expected) + attempts.append(audio) + # Aucune tentative dans les bornes : on garde la moins mauvaise (ni + # silence ni derive), la plus proche de l'attendu. + valid = [a for a in attempts if _MIN_FLOOR_SEC <= len(a) / sr <= hi] or attempts + best = min(valid, key=lambda a: abs(len(a) / sr - expected)) + logger.warning("Qwen3: chunk non stabilise apres %d essais, garde %.1fs: %r", + _QWEN_RETRIES, len(best) / sr, chunk[:60]) + return best + def synthesize(self, text: str, voice: VoiceSpec) -> tuple[np.ndarray, int]: self._ensure_loaded() kwargs = self._gen_kwargs(voice) - pieces: list[np.ndarray] = [] - for chunk in chunk_text(text, max_chars=_QWEN_MAX_CHARS): - for result in self._model.generate(text=chunk, **kwargs): - self._sample_rate = getattr(result, "sample_rate", self._sample_rate) - pieces.append(to_mono_float32(result.audio)) + pieces = [self._gen_chunk_guarded(chunk, kwargs) + for chunk in chunk_text(text, max_chars=_QWEN_MAX_CHARS)] + pieces = [p for p in pieces if len(p)] if not pieces: return np.zeros(0, dtype=np.float32), self._sample_rate return np.concatenate(pieces), self._sample_rate diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 21ede53..dd9d538 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -9,6 +9,8 @@ dependencies = [ "mlx-lm", "mlx-audio", "misaki", # phonemizer pour Kokoro (français inclus) + # Backend LLM alternatif : LM Studio via son API OpenAI locale (GGUF + MLX) + "openai", # Parsing EPUB "ebooklib", "beautifulsoup4", diff --git a/backend/scripts/delta_alternation.py b/backend/scripts/delta_alternation.py new file mode 100644 index 0000000..c1a47a2 --- /dev/null +++ b/backend/scripts/delta_alternation.py @@ -0,0 +1,61 @@ +"""Mesure l'effet de la passe d'alternance sur l'attribution (avant/apres). + +Pour chaque modele : charge une fois, analyse le chapitre, intercepte les +locuteurs JUSTE avant `_repair_alternation` (etat "avant") puis lit l'etat +"apres", et score les deux contre la reference. Isole le gain de la passe +deterministe, independamment du cout du modele. +""" +from __future__ import annotations + +import copy +import sys + +from inkflow.analysis import segmenter +from inkflow.analysis.benchmark import _load_reference, _score_counts, _counts_to_score +from inkflow.analysis.llm.client import LLM +from inkflow.analysis.llm.factory import reset_llm_cache +from inkflow.epub.parser import load_book, load_chapter_text +from inkflow.store import artifacts + +SLUG = "la-colere-de-tiamat" +CH = int(__import__("os").environ.get("DELTA_CH", "5")) + + +def main(model_ids: list[str]) -> None: + book = load_book(SLUG) + chapter = next(c for c in book.chapters if c.index == CH) + ct = load_chapter_text(SLUG, chapter) + cast = artifacts.load_cast(SLUG) + ref = _load_reference(SLUG, CH) + + orig_repair = segmenter._repair_alternation + print(f"{'modele':<40} {'avant':>7} {'apres':>7} {'delta':>7}") + for model_id in model_ids: + captured: dict[str, list] = {} + + def spy(segments, **kw): # capture l'etat avant reparation + captured["before"] = copy.deepcopy(segments) + orig_repair(segments, **kw) + + segmenter._repair_alternation = spy + try: + gemma = LLM(model_id=model_id) + analysis, _ = segmenter.analyze_chapter( + chapter, ct, gemma, book_chars=list(cast.characters), + dedup_gemma=None) + finally: + segmenter._repair_alternation = orig_repair + reset_llm_cache() + + from inkflow.models import ChapterAnalysis + before = ChapterAnalysis(index=CH, title=ct.title, + segments=captured["before"]) + s_before = _counts_to_score(CH, _score_counts(ref, before, cast)) + s_after = _counts_to_score(CH, _score_counts(ref, analysis, cast)) + b = s_before.speaker_acc_dialogue + a = s_after.speaker_acc_dialogue + print(f"{model_id:<40} {b:>6.1%} {a:>6.1%} {a - b:>+6.1%}") + + +if __name__ == "__main__": + main(sys.argv[1:] or ["mlx-community/gemma-3-4b-it-4bit"]) diff --git a/backend/scripts/import_voices.py b/backend/scripts/import_voices.py new file mode 100644 index 0000000..3feda09 --- /dev/null +++ b/backend/scripts/import_voices.py @@ -0,0 +1,299 @@ +"""Importe de vraies voix francaises dans la voicebank (clips + ref_text). + +Probleme resolu : `build_voicebank()` generait les clips de reference *avec +Kokoro lui-meme* — et la plupart des voix empruntaient un timbre Kokoro +**anglais** lisant du francais phonemise. Resultat : un fort accent anglais que +Qwen3 clonait fidelement. Ce script **remplace toute la banque** par de vrais +enregistrements de locuteurs francais, ce qui donne a Qwen3 une reference de +timbre reellement francophone. + +Source : **CML-TTS French** (`ylacombe/cml-tts`, config `french`), CC-BY, +non-gated. Corpus de **livres audio** taille pour le TTS : voix studio, registre +narrateur, prose reelle. On telecharge des shards parquet (audio WAV 24 kHz +embarque) via `huggingface_hub`, shard apres shard, jusqu'a remplir les quotas. + +Allocation des roles (chaque voix = un locuteur distinct, `speaker_id`) : +- 1 **narrateur** dedie (`fr_narrator`). +- N **voix nommees** par genre (`fr_f_*`, `fr_m_*`) pour les personnages. +- M **voix anonymes** par genre (`fr_anon_f_*`, `fr_anon_m_*`, `anonymous=True`), + reservees aux figurants "anonyme (...)" par `assign_voices` (jamais melangees + avec les voix nommees). + +Qualite : un clip par locuteur, le plus propre (`levenshtein` mini), duree bornee. +Genre absent du corpus -> estime par **F0 (YIN, anti-octave)**. + +Usage (depuis backend/, venv actif) : + python scripts/import_voices.py # quotas par defaut, REMPLACE la banque + python scripts/import_voices.py --named-f 18 --named-m 14 --anon 4 + python scripts/import_voices.py --shards french/dev/0002.parquet french/dev/0000.parquet + +Le clip est ecrit a son sr natif (24 kHz) ; Qwen3 reechantillonne la reference. +La banque resultante a un `ref_audio` partout, donc `build_voicebank()` (legacy) +ne la regenerera pas. Le `kokoro_voice` reste renseigne (preset de meme genre) +pour le preview/draft Kokoro ; le timbre final vient du ref_audio via Qwen3. +""" +from __future__ import annotations + +import argparse +import io +import sys +from pathlib import Path + +import numpy as np +import soundfile as sf + +# Permet de lancer le script sans `pip install -e` : on ajoute backend/ au path. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from inkflow.casting.voicebank import save_voicebank # noqa: E402 +from inkflow.config import VOICEBANK_DIR # noqa: E402 +from inkflow.models import VoiceEntry, Voicebank # noqa: E402 + +# Presets Kokoro de secours par genre (preview/draft uniquement ; le timbre final +# vient du ref_audio clone par Qwen3). On cycle dessus pour varier les previews. +_KOKORO_BY_GENDER = { + "female": ["af_bella", "af_heart", "af_nicole", "bf_emma"], + "male": ["am_fenrir", "am_michael", "bm_george", "am_eric"], +} +# Shards CML-TTS French par defaut (branche refs/convert/parquet). dev/test +# partagent un petit pool fixe de locuteurs (~17F/17M au total) ; la variete est +# dans train (chaque shard = quelques lecteurs distincts). On lit test (le plus +# fourni) puis des shards train jusqu'a remplir les quotas. +_DEFAULT_SHARDS = ( + ["french/test/0000.parquet", "french/dev/0002.parquet"] + + [f"french/train/{i:04d}.parquet" for i in range(12)] +) + + +def _to_mono(arr: np.ndarray) -> np.ndarray: + arr = np.asarray(arr, dtype=np.float32) + if arr.ndim > 1: + arr = arr.mean(axis=1) + return arr + + +def _yin_f0(frame: np.ndarray, sr: int, lo: int, hi: int, thresh: float = 0.15) -> float: + """F0 d'une trame par YIN (anti-octave). 0.0 si non voisee. + + 1) fonction de difference d(tau) ; 2) moyenne cumulee normalisee d'(tau) ; + 3) premier tau sous le seuil absolu (evite de prendre l'octave superieure). + C'est l'etape (2)-(3) qui rend YIN robuste aux erreurs d'octave de + l'autocorrelation simple (qui faisait passer un homme pour une femme). + """ + n = len(frame) + diff = np.zeros(hi + 1) + for tau in range(1, hi + 1): + d = frame[: n - tau] - frame[tau:n] + diff[tau] = np.dot(d, d) + cum = np.cumsum(diff[1:]) + cmnd = np.ones(hi + 1) + taus = np.arange(1, hi + 1) + cmnd[1:] = diff[1:] * taus / np.maximum(cum, 1e-9) + tau = -1 + t = lo + while t < hi: + if cmnd[t] < thresh: + while t + 1 < hi and cmnd[t + 1] < cmnd[t]: + t += 1 # descend jusqu'au minimum local + tau = t + break + t += 1 + if tau == -1: # aucun creux net -> min global de la bande + tau = lo + int(np.argmin(cmnd[lo:hi])) + if cmnd[tau] > 0.6: # vraiment pas de periodicite -> non voisee + return 0.0 + return sr / tau + + +def estimate_gender(arr: np.ndarray, sr: int) -> tuple[str, float]: + """Estime le genre par F0 mediane (YIN par trame, numpy pur). + + Voix parlee : H ~85-180 Hz (med ~120), F ~165-255 Hz (med ~210). Renvoie + ("unknown", med) si la mediane tombe dans la zone ambigue 150-180 Hz -> on + prefere ecarter le locuteur que de mal le classer (assez de candidats). + """ + win = int(0.04 * sr) + hop = win // 2 + lo = max(1, int(sr / 350)) # 350 Hz + hi = int(sr / 70) # 70 Hz + energy_thresh = 0.10 * np.sqrt(np.mean(arr ** 2) + 1e-9) + f0s: list[float] = [] + for start in range(0, max(0, len(arr) - win), hop): + frame = arr[start:start + win].astype(np.float64) + if np.sqrt(np.mean(frame ** 2)) < energy_thresh: + continue + f0 = _yin_f0(frame - frame.mean(), sr, lo, hi) + if f0 > 0: + f0s.append(f0) + if len(f0s) < 10: + return "unknown", 0.0 + med = float(np.median(f0s)) + if 150 <= med <= 180: + return "unknown", med + return ("male" if med < 165 else "female"), med + + +def _iter_parquet_rows(dataset: str, shard: str): + """Telecharge le shard parquet (audio embarque) et itere ses lignes en dict.""" + from huggingface_hub import hf_hub_download + import pyarrow.parquet as pq + + print(f" · telechargement {shard}…", flush=True) + path = hf_hub_download(dataset, shard, repo_type="dataset", + revision="refs/convert/parquet") + pf = pq.ParquetFile(path) + for batch in pf.iter_batches(batch_size=128): + cols = {name: batch.column(name) for name in batch.schema.names} + for i in range(batch.num_rows): + yield {name: col[i].as_py() for name, col in cols.items()} + + +def _gather_voices(dataset, shards, min_dur, max_dur, max_lev, need_f, need_m): + """Collecte des locuteurs distincts classes par genre (YIN), shard par shard. + + S'arrete des que chaque genre a assez de candidats. Renvoie + {"female": [(spk, lev, bytes, text), ...trie par qualite], "male": [...]}. + """ + best: dict[object, dict] = {} # speaker_id -> meilleur clip vu + classified: dict[object, str] = {} # speaker_id -> gender (cache) + buckets = {"female": [], "male": []} + + for shard in shards: + for row in _iter_parquet_rows(dataset, shard): + dur = row.get("duration") or 0.0 + if not (min_dur <= dur <= max_dur): + continue + nwords = row.get("num_words") or 0 + # Debit de parole : un ref_text qui ne couvre pas l'audio (fragment + # tronque, ou audio plein de silence) casse le clonage Qwen3 (sortie + # vide). On exige un debit plausible 1.5-4.5 mots/s. + wps = nwords / dur if dur else 0 + if nwords < 8 or not (1.5 <= wps <= 4.5): + continue + lev = (row.get("levenshtein") or 0) / max(nwords, 1) + if lev > max_lev: + continue + spk = row.get("speaker_id") + text = (row.get("text") or "").strip() + if spk is None or len(text) < 15: + continue + cur = best.get(spk) + if cur is None or lev < cur["lev"]: + best[spk] = {"lev": lev, "bytes": row["audio"]["bytes"], "text": text} + + # Classe les nouveaux locuteurs de ce shard. + for spk, c in best.items(): + if spk in classified: + continue + arr, sr = sf.read(io.BytesIO(c["bytes"]), dtype="float32") + g, _ = estimate_gender(_to_mono(arr), sr) + classified[spk] = g + if g in buckets: + buckets[g].append((spk, c["lev"], c["bytes"], c["text"])) + nf, nm = len(buckets["female"]), len(buckets["male"]) + print(f" -> {nf} femmes / {nm} hommes candidats", flush=True) + if nf >= need_f and nm >= need_m: + break + + for g in buckets: + buckets[g].sort(key=lambda t: t[1]) # plus propre d'abord + return buckets + + +def _write_clip(vid: str, raw: bytes) -> tuple[str, int]: + arr, sr = sf.read(io.BytesIO(raw), dtype="float32") + arr = _to_mono(arr) + rel = f"clips/{vid}.wav" + sf.write(str(VOICEBANK_DIR / rel), arr, sr) + return rel, sr + + +def _entry(vid, gender, idx, spk, text, *, anonymous, label) -> VoiceEntry: + kokoro = _KOKORO_BY_GENDER[gender][(idx - 1) % len(_KOKORO_BY_GENDER[gender])] + rel, _ = _write_clip(vid, spk[2]) + return VoiceEntry(id=vid, kokoro_voice=kokoro, gender=gender, age="adult", + lang="fr", label=label, ref_audio=rel, ref_text=text, + anonymous=anonymous) + + +def import_voices(*, dataset, shards, named_f, named_m, anon, min_dur, max_dur, + max_lev) -> Voicebank: + need_f = named_f + anon + 1 # +1 narrateur (feminin) + need_m = named_m + anon + print(f"Objectif : {need_f} femmes / {need_m} hommes (distincts).", flush=True) + buckets = _gather_voices(dataset, shards, min_dur, max_dur, max_lev, need_f, need_m) + + fem, mal = buckets["female"], buckets["male"] + if len(fem) < need_f or len(mal) < need_m: + print(f"⚠ Pas assez de locuteurs (F {len(fem)}/{need_f}, H {len(mal)}/{need_m}) — " + "quotas reduits. Ajoute des shards via --shards.", flush=True) + named_f = min(named_f, max(0, len(fem) - anon - 1)) + named_m = min(named_m, max(0, len(mal) - anon)) + + # Remplacement complet : on vide les clips existants. + clips = VOICEBANK_DIR / "clips" + clips.mkdir(parents=True, exist_ok=True) + for old in clips.glob("*.wav"): + old.unlink() + + entries: list[VoiceEntry] = [] + fi = mi = 0 # curseurs dans les buckets tries par qualite + + # 1) Narrateur (1re voix feminine, la plus propre). + spk = fem[fi]; fi += 1 + entries.append(_entry("fr_narrator", "female", 1, spk, spk[3], + anonymous=False, label="Narrateur (FR)")) + # 2) Voix nommees. + for i in range(1, named_f + 1): + spk = fem[fi]; fi += 1 + entries.append(_entry(f"fr_f_{i}", "female", i, spk, spk[3], + anonymous=False, label=f"Voix F {i} (FR)")) + for i in range(1, named_m + 1): + spk = mal[mi]; mi += 1 + entries.append(_entry(f"fr_m_{i}", "male", i, spk, spk[3], + anonymous=False, label=f"Voix H {i} (FR)")) + # 3) Voix anonymes (reservees aux figurants). + for i in range(1, anon + 1): + if fi >= len(fem): + break + spk = fem[fi]; fi += 1 + entries.append(_entry(f"fr_anon_f_{i}", "female", i, spk, spk[3], + anonymous=True, label=f"Anonyme F {i} (FR)")) + for i in range(1, anon + 1): + if mi >= len(mal): + break + spk = mal[mi]; mi += 1 + entries.append(_entry(f"fr_anon_m_{i}", "male", i, spk, spk[3], + anonymous=True, label=f"Anonyme H {i} (FR)")) + + vb = Voicebank(entries=entries) + save_voicebank(vb) + na = sum(1 for e in entries if e.anonymous) + print(f"\n✓ {len(entries)} voix → {VOICEBANK_DIR / 'metadata.json'}") + print(f" narrateur 1 · nommees {len(entries) - na - 1} · anonymes {na}") + for e in entries: + tag = " [anon]" if e.anonymous else "" + print(f" {e.id:14} {e.gender:6} kokoro={e.kokoro_voice}{tag}") + return vb + + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--dataset", default="ylacombe/cml-tts") + p.add_argument("--shards", nargs="+", default=_DEFAULT_SHARDS, + help="Shards parquet a consommer dans l'ordre jusqu'aux quotas.") + p.add_argument("--named-f", type=int, default=18, help="Voix feminines nommees.") + p.add_argument("--named-m", type=int, default=14, help="Voix masculines nommees.") + p.add_argument("--anon", type=int, default=4, help="Voix anonymes par genre.") + p.add_argument("--min-dur", type=float, default=6.0) + p.add_argument("--max-dur", type=float, default=15.0) + p.add_argument("--max-lev", type=float, default=0.5, + help="Distance Levenshtein max par mot (qualite ; plus bas = plus propre).") + args = p.parse_args() + import_voices(dataset=args.dataset, shards=args.shards, named_f=args.named_f, + named_m=args.named_m, anon=args.anon, min_dur=args.min_dur, + max_dur=args.max_dur, max_lev=args.max_lev) + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_canonicalize.py b/backend/tests/test_canonicalize.py new file mode 100644 index 0000000..7e467d4 --- /dev/null +++ b/backend/tests/test_canonicalize.py @@ -0,0 +1,201 @@ +"""Tests purs : canonicalisation des noms variants + anonymes par genre/age. + +`_canonicalize_speakers`, `_apply_anonymous_speakers` et `_anon_identity` sont +deterministes et testables sans Gemma ni disque (cf. test_incises.py). +""" +from __future__ import annotations + +from inkflow.analysis.segmenter import ( + _anon_identity, + _apply_anonymous_speakers, + _canonicalize_speakers, + _inversion_gender, + _resolve_anonymous_figurants, +) +from inkflow.models import Character, Incise, Segment, SegmentType + + +def _C(name, gender=None, age=None, aliases=None): + return Character(name=name, gender=gender, age=age, aliases=aliases or []) + + +def _D(text, speaker, incises=None): + return Segment(type=SegmentType.DIALOGUE, text=text, speaker=speaker, + incises=incises or []) + + +def _N(text="narration"): + return Segment(type=SegmentType.NARRATION, text=text, speaker="narrateur") + + +# --- Canonicalisation des variantes de noms ---------------------------------- + +def test_canon_variante_vers_canonique(): + chars = [_C("Sagale"), _C("Elvi"), _C("Holden")] + segs = [_D("a", "Amiral Mehmet Sagale"), _D("b", "Elvi Okoye"), + _D("c", "Holden")] + _canonicalize_speakers(segs, chars) + assert [s.speaker for s in segs] == ["Sagale", "Elvi", "Holden"] + + +def test_canon_reciproque_forme_courte_vers_complete(): + # Le cast porte le nom complet ; une surface courte distinctive s'y recolle. + chars = [_C("Elvi Okoye")] + segs = [_D("a", "Okoye")] + _canonicalize_speakers(segs, chars) + assert segs[0].speaker == "Elvi Okoye" + + +def test_canon_marine_unique_distinctif(): + chars = [_C("Marine"), _C("Holden")] + segs = [_D("a", "Marine de gauche")] + _canonicalize_speakers(segs, chars) + assert segs[0].speaker == "Marine" + + +def test_canon_ambiguite_sabstient(): + # Deux personnages partagent le token "marine" -> non distinctif -> abstention. + chars = [_C("Marine Lopez"), _C("Marine Cho")] + segs = [_D("a", "Marine de gauche")] + _canonicalize_speakers(segs, chars) + assert segs[0].speaker == "Marine de gauche" # inchange + + +def test_canon_inconnu_total_inchange(): + chars = [_C("Holden"), _C("Kajri")] + segs = [_D("a", "Bob")] + _canonicalize_speakers(segs, chars) + assert segs[0].speaker == "Bob" + + +def test_canon_narrateur_et_inconnu_jamais_touches(): + chars = [_C("Sagale")] + segs = [_N(), _D("a", "inconnu"), _D("b", "?")] + _canonicalize_speakers(segs, chars) + assert [s.speaker for s in segs] == ["narrateur", "inconnu", "?"] + + +def test_canon_idempotent(): + chars = [_C("Sagale")] + segs = [_D("a", "Amiral Mehmet Sagale")] + _canonicalize_speakers(segs, chars) + once = segs[0].speaker + _canonicalize_speakers(segs, chars) + assert segs[0].speaker == once == "Sagale" + + +# --- Identite anonyme par (genre, age) --------------------------------------- + +def test_anon_identity_format(): + assert _anon_identity("male", "adult") == "anonyme (homme, adulte)" + assert _anon_identity("male", None) == "anonyme (homme)" + assert _anon_identity("female", None) == "anonyme (femme)" + assert _anon_identity(None, None) == "anonyme" + assert _anon_identity(None, "child") == "anonyme (enfant)" + + +def test_apply_anonymous_role_par_genre(): + # "informa le soldat" -> anonyme (homme) ; renvoie le bucket avec genre/age. + t = "La réception commence, madame, informa le soldat." + inc = Incise(start=t.index("informa"), end=len(t)) + segs = [_D(t, "inconnu", [inc])] + used = _apply_anonymous_speakers(segs, names={"Kajri"}) + assert segs[0].speaker == "anonyme (homme)" + assert used == {"anonyme (homme)": ("male", None)} + + +def test_apply_anonymous_role_inconnu_genre(): + # "une voix" : role sans genre fiable -> bucket generique "anonyme". + t = "Par ici, indiqua une voix." + inc = Incise(start=t.index("indiqua"), end=len(t)) + segs = [_D(t, "inconnu", [inc])] + used = _apply_anonymous_speakers(segs, names=set()) + assert segs[0].speaker == "anonyme" + assert used == {"anonyme": (None, None)} + + +def test_apply_anonymous_ignore_nom_propre(): + # Incise a nom propre -> pas un anonyme, speaker inchange. + t = "Bonjour, lança Drummer." + inc = Incise(start=t.index("lança"), end=len(t)) + segs = [_D(t, "Drummer", [inc])] + used = _apply_anonymous_speakers(segs, names={"Drummer"}) + assert segs[0].speaker == "Drummer" + assert used == {} + + +# --- Rang/titre devant un nom propre ----------------------------------------- + +def test_rang_titre_capte_le_nom_propre(): + # "dit l'amiral Sagale" : le rang n'est pas un anonyme, on capte "Sagale". + from inkflow.analysis.segmenter import detect_incises, incise_role, incise_speaker + t = "Dr Okoye, dit l'amiral Sagale." + inc = detect_incises(t, names={"Sagale"})[0] + assert incise_speaker(t, inc, {"Sagale"}) == "Sagale" + assert incise_role(t, inc, {"Sagale"}) is None + + +# --- Stabilite du nom canonique etabli --------------------------------------- + +def test_reconcile_garde_nom_etabli_stable(): + # Un nom deja dans le cast ("Sagale") n'est pas renomme par une forme plus + # longue trouvee dans un chapitre ("Amiral Mehmet Sagale") -> alias. + from inkflow.casting.dedup import reconcile_characters + book = [_C("Sagale", gender="male")] + found = [_C("Amiral Mehmet Sagale", gender="male")] + chars, _ = reconcile_characters(book, found, None) + sagale = next(c for c in chars if c.name == "Sagale") + assert "Amiral Mehmet Sagale" in sagale.aliases + + +def test_reconcile_nouveau_perso_garde_forme_complete(): + # Sans nom etabli, le comportement reste "la forme la plus complete gagne". + from inkflow.casting.dedup import reconcile_characters + chars, _ = reconcile_characters([], [_C("Jim"), _C("Jim Holden")], None) + assert any(c.name == "Jim Holden" and "Jim" in c.aliases for c in chars) + + +# --- Figurants anonymes resolus via la narration adjacente ------------------- + +def test_inversion_gender(): + assert _inversion_gender("Souhaitez-vous une escorte ? demanda-t-elle.") == "female" + assert _inversion_gender("Stop, dit-il.") == "male" + assert _inversion_gender("Je pars maintenant.") is None + + +def test_figurant_femme_via_narration_avant(): + # Replique indeterminee + narration decrivant "La jeune marine" -> anonyme femme. + segs = [ + _N("La jeune marine toucha quelque chose au poignet de son armure."), + _D("Prévenez-nous quand vous serez prête à ressortir.", "inconnu"), + ] + used = _resolve_anonymous_figurants(segs) + assert segs[1].speaker == "anonyme (femme)" + assert "anonyme (femme)" in used + + +def test_figurant_genre_par_pronom_inversion_prioritaire(): + # "demanda-t-elle" (féminin) prime, narration "Le soldat" -> on garde femme. + segs = [ + _N("Le soldat s'avança vers eux."), + _D("Souhaitez-vous une escorte ? demanda-t-elle.", "?"), + ] + _resolve_anonymous_figurants(segs) + assert segs[0].speaker == "narrateur" + assert segs[1].speaker == "anonyme (femme)" + + +def test_figurant_ne_touche_pas_les_resolus(): + # Une replique deja attribuee n'est jamais ecrasee, meme avec narration de role. + segs = [ + _N("Le soldat montait la garde."), + _D("J'arrive.", "Holden"), + ] + _resolve_anonymous_figurants(segs) + assert segs[1].speaker == "Holden" + + +def test_figurant_sans_narration_de_role_inchange(): + segs = [_N("La pièce était sombre."), _D("Qui est là ?", "inconnu")] + _resolve_anonymous_figurants(segs) + assert segs[1].speaker == "inconnu" diff --git a/backend/tests/test_gemma_reasoning.py b/backend/tests/test_gemma_reasoning.py index 94337a7..189da92 100644 --- a/backend/tests/test_gemma_reasoning.py +++ b/backend/tests/test_gemma_reasoning.py @@ -6,7 +6,7 @@ parasite present dans la pensee). """ from __future__ import annotations -from inkflow.analysis.gemma import ( +from inkflow.analysis.llm._text import ( _extract_json, _has_complete_json, _strip_reasoning, diff --git a/backend/tests/test_incises.py b/backend/tests/test_incises.py index c46558a..9c96d84 100644 --- a/backend/tests/test_incises.py +++ b/backend/tests/test_incises.py @@ -8,6 +8,7 @@ from __future__ import annotations from inkflow.analysis.segmenter import ( detect_incises, + incise_role, incise_speaker, iter_incise_pieces, ) @@ -202,3 +203,125 @@ def test_bornes_non_chevauchantes_et_triees(): assert all(incs[i].end <= incs[i + 1].start for i in range(len(incs) - 1)) for inc in incs: assert 0 <= inc.start < inc.end <= len(text) + + +# --- Passe deterministe : reparation de l'alternance des tours --------------- + +from inkflow.analysis.segmenter import _repair_alternation # noqa: E402 +from inkflow.models import Incise, Segment, SegmentType # noqa: E402 + + +def _D(text: str, speaker: str, incises=None) -> Segment: + return Segment(type=SegmentType.DIALOGUE, text=text, speaker=speaker, + incises=incises or []) + + +def _N(text: str = "narration") -> Segment: + return Segment(type=SegmentType.NARRATION, text=text, speaker="narrateur") + + +def _speakers(segments, sl): + return [segments[i].speaker for i in sl] + + +def test_alternance_corrige_doublons_de_tour(): + # Echange a deux, le modele a double des tours (D,H,H) -> doit redevenir D,H,D. + segs = [ + _N(), + _D("Je suis ravie.", "Drummer"), + _D("C'est moche.", "Holden"), + _D("Je ne devrais pas la ramener.", "Holden"), # erreur + _N(), + ] + _repair_alternation(segs, names={"Drummer", "Holden"}) + assert _speakers(segs, [1, 2, 3]) == ["Drummer", "Holden", "Drummer"] + + +def test_alternance_ancre_par_incise_nominale(): + # Seed nominal en tete (compatit Holden) -> fixe la parite du motif. + t0 = "Toutes mes condoléances, compatit Holden." + seed = [Incise(start=t0.index("compatit"), end=len(t0))] + segs = [ + _N(), + _D(t0, "Holden", seed), + _D("Merci.", "Kajri"), + _D("Nous n'avons pas été présentés.", "Kajri"), # erreur + _D("James Holden.", "Holden"), # erreur + _D("Ah, croustillant.", "Kajri"), # erreur + _N(), + ] + _repair_alternation(segs, names={"Holden", "Kajri"}) + assert _speakers(segs, [1, 2, 3, 4, 5]) == [ + "Holden", "Kajri", "Holden", "Kajri", "Holden"] + + +def test_alternance_trois_locuteurs_ancres_sabstient(): + # Un 3e locuteur (meme via incise) dans le run -> pas d'alternance binaire forcee. + ta = "Ça satisfait, disait Bobbie." + tb = "Oui, convint Naomi." + tc = "Avec des jeunes, précisa Alex." + segs = [ + _N(), + _D(ta, "Bobbie", [Incise(start=ta.index("disait"), end=len(ta))]), + _D(tb, "Naomi", [Incise(start=tb.index("convint"), end=len(tb))]), + _D(tc, "Alex", [Incise(start=tc.index("précisa"), end=len(tc))]), + _N(), + ] + _repair_alternation(segs, names={"Bobbie", "Naomi", "Alex"}) + assert _speakers(segs, [1, 2, 3]) == ["Bobbie", "Naomi", "Alex"] + + +def test_alternance_run_deja_correct_inchange(): + segs = [_N(), _D("a", "Holden"), _D("b", "Kajri"), + _D("c", "Holden"), _D("d", "Kajri"), _N()] + before = _speakers(segs, [1, 2, 3, 4]) + _repair_alternation(segs, names={"Holden", "Kajri"}) + assert _speakers(segs, [1, 2, 3, 4]) == before + + +def test_alternance_trois_locuteurs_sabstient(): + # 3 locuteurs distincts dans le run -> pas d'alternance binaire, on ne touche pas. + segs = [_N(), _D("a", "Holden"), _D("b", "Kajri"), + _D("c", "Drummer"), _N()] + _repair_alternation(segs, names={"Holden", "Kajri", "Drummer"}) + assert _speakers(segs, [1, 2, 3]) == ["Holden", "Kajri", "Drummer"] + + +def test_alternance_narration_intercalee_rompt_le_run(): + # STRICT (GAP=0) : toute narration entre deux repliques coupe le run, car + # elle peut porter une continuation du meme locuteur (cf. ch06). On ne force + # donc PAS l'alternance a travers une narration. + segs = [_N(), _D("a", "Drummer"), _N("il marqua une pause"), + _D("b", "Holden"), _D("c", "Holden"), _N()] + _repair_alternation(segs, names={"Holden", "Drummer"}) + # Le run effectif est [b, c] (consecutifs) : 1 seul locuteur resolu -> abstention. + assert _speakers(segs, [1, 3, 4]) == ["Drummer", "Holden", "Holden"] + + +def test_incise_role_renvoie_le_nom_de_role(): + # "informa le soldat" : pas un locuteur NOMME, mais un role identifiable. + text = "La réception commence, madame, informa le soldat." + inc = detect_incises(text, names=NAMES)[0] + assert incise_speaker(text, inc, NAMES) is None # pas de nom propre + assert incise_role(text, inc, NAMES) == "soldat" # role detecte + # Un nom propre n'est pas un role. + text2 = "Bonjour, lança Drummer." + inc2 = detect_incises(text2, names=set())[0] + assert incise_role(text2, inc2, set()) is None + + +def test_alternance_seed_contradictoire_sabstient(): + # Deux seeds nominaux contradictoires avec toute alternance -> abstention. + ta = "Bonjour, dit Holden." + tb = "Salut, répondit Holden." + segs = [ + _N(), + _D(ta, "Holden", [Incise(start=ta.index("dit"), end=len(ta))]), + _D("Entre les deux.", "Kajri"), + _D(tb, "Holden", [Incise(start=tb.index("répondit"), end=len(tb))]), + _N(), + ] + # Motif alterne impossible (Holden en 0 et 2 exige Kajri en 1, OK en fait) : + # ici l'alternance H,K,H EST coherente avec les deux ancres -> applique. + _repair_alternation(segs, names={"Holden", "Kajri"}) + assert _speakers(segs, [1, 2, 3]) == ["Holden", "Kajri", "Holden"] diff --git a/backend/tests/test_lmstudio_backend.py b/backend/tests/test_lmstudio_backend.py new file mode 100644 index 0000000..1afb19f --- /dev/null +++ b/backend/tests/test_lmstudio_backend.py @@ -0,0 +1,147 @@ +"""Tests du backend LM Studio (sans reseau ni paquet openai installe). + +On injecte un faux module `openai` dans sys.modules : le backend l'importe +paresseusement, on peut donc valider la construction des messages, le parsing de +la reponse (content + reasoning_content), le streaming et l'erreur de connexion +sans dependance ni serveur. +""" +from __future__ import annotations + +import sys +import types +from types import SimpleNamespace + +import pytest + +import inkflow.analysis.llm.lmstudio_backend as lm +from inkflow.analysis.llm._text import _extract_json, _strip_reasoning +from inkflow.analysis.llm.lmstudio_backend import LMStudioBackend + + +class _FakeAPIConnectionError(Exception): + pass + + +@pytest.fixture(autouse=True) +def fake_openai(monkeypatch): + """Faux module openai (APIConnectionError + OpenAI) injecte dans sys.modules.""" + mod = types.ModuleType("openai") + mod.APIConnectionError = _FakeAPIConnectionError + mod.OpenAI = lambda **kw: None # jamais utilise (on injecte _client a la main) + monkeypatch.setitem(sys.modules, "openai", mod) + return mod + + +@pytest.fixture(autouse=True) +def settings(monkeypatch): + """Reglages controles (defaut : delegation a LM Studio) sans lire le disque.""" + state = SimpleNamespace(lmstudio_defer_config=True, + lmstudio_base_url="http://127.0.0.1:1234/v1") + monkeypatch.setattr(lm, "get_settings", lambda: state) + return state + + +def _message(content, reasoning=None): + msg = SimpleNamespace(content=content, reasoning_content=reasoning) + return SimpleNamespace(choices=[SimpleNamespace(message=msg)]) + + +class _FakeCompletions: + """Capture les kwargs et renvoie une reponse (ou leve) preprogrammee.""" + + def __init__(self, *, response=None, stream=None, raises=None): + self.response, self.stream, self.raises = response, stream, raises + self.kwargs = None + + def create(self, **kwargs): + self.kwargs = kwargs + if self.raises is not None: + raise self.raises + return self.stream if kwargs.get("stream") else self.response + + +def _client(completions): + return SimpleNamespace(chat=SimpleNamespace(completions=completions)) + + +def _backend(completions, *, model="m"): + b = LMStudioBackend(model) + b._client = _client(completions) # court-circuite _ensure_client (pas d'openai reel) + return b + + +def test_non_stream_content_delegue_la_config(settings): + # Par defaut on DELEGUE a LM Studio : ni temperature ni max_tokens imposes + # (sinon on tronquait la reponse / on ecrasait la config du modele). + comp = _FakeCompletions(response=_message('{"speaker": "Marie"}')) + b = _backend(comp) + out = b.complete( + [{"role": "system", "content": "sys"}, {"role": "user", "content": "u"}], + max_tokens=128, temperature=0.1, reasoning=False) + assert _extract_json(out) == {"speaker": "Marie"} + assert comp.kwargs["model"] == "m" + assert comp.kwargs["messages"][0]["role"] == "system" + assert "temperature" not in comp.kwargs # delegue a LM Studio + assert "max_tokens" not in comp.kwargs + + +def test_non_stream_params_imposes_si_delegation_off(settings): + # lmstudio_defer_config=False -> on reimpose les reglages InkFlow. + settings.lmstudio_defer_config = False + comp = _FakeCompletions(response=_message('{"speaker": "Marie"}')) + b = _backend(comp) + b.complete([{"role": "user", "content": "u"}], + max_tokens=128, temperature=0.1, reasoning=False) + assert comp.kwargs["temperature"] == 0.1 + assert comp.kwargs["max_tokens"] == 128 + + +def test_reasoning_content_exclu_du_retour(): + # LM Studio separe la pensee (reasoning_content) de la reponse (content, + # propre). Le retour ne doit contenir QUE content : un JSON d'exemple present + # dans la pensee ne doit pas etre capte a la place de la vraie reponse. + comp = _FakeCompletions( + response=_message('{"capitale": "Paris"}', + reasoning='exemple parasite: {"capitale": "Londres"}')) + b = _backend(comp) + out = b.complete([{"role": "user", "content": "u"}], + max_tokens=128, temperature=0.0, reasoning=False) + assert _extract_json(out) == {"capitale": "Paris"} + assert "parasite" not in out + + +def test_streaming_token_sink(): + def _delta(content=None, reasoning=None): + return SimpleNamespace(choices=[SimpleNamespace( + delta=SimpleNamespace(content=content, reasoning_content=reasoning))]) + chunks = [_delta(reasoning="je pense "), _delta(content='{"a"'), _delta(content=": 1}")] + comp = _FakeCompletions(stream=iter(chunks)) + b = _backend(comp) + seen = [] + out = b.complete([{"role": "user", "content": "u"}], max_tokens=64, + temperature=0.1, reasoning=False, token_sink=seen.append) + assert comp.kwargs["stream"] is True + assert _extract_json(out) == {"a": 1} + assert "je pense" not in out # la pensee est exclue du retour + assert "je pense" in "".join(seen) # mais diffusee au sink (affichage) + + +def test_erreur_connexion_message_clair(): + comp = _FakeCompletions(raises=_FakeAPIConnectionError("refused")) + b = _backend(comp) + with pytest.raises(RuntimeError) as exc: + b.complete([{"role": "user", "content": "u"}], max_tokens=64, + temperature=0.1, reasoning=False) + assert "LM Studio injoignable" in str(exc.value) + + +def test_resolve_modele_actif_si_ref_vide(): + comp = _FakeCompletions(response=_message("{}")) + client = _client(comp) + client.models = SimpleNamespace( + list=lambda: SimpleNamespace(data=[SimpleNamespace(id="gemma-4")])) + b = LMStudioBackend("") # ref vide -> doit prendre le 1er modele charge + b._client = client + b.complete([{"role": "user", "content": "u"}], max_tokens=64, + temperature=0.1, reasoning=False) + assert comp.kwargs["model"] == "gemma-4" diff --git a/frontend/dist/assets/index-CMUl6Yfl.js b/frontend/dist/assets/index-CMUl6Yfl.js deleted file mode 100644 index df08d2a..0000000 --- a/frontend/dist/assets/index-CMUl6Yfl.js +++ /dev/null @@ -1,40 +0,0 @@ -(function(){const S=document.createElement("link").relList;if(S&&S.supports&&S.supports("modulepreload"))return;for(const C of document.querySelectorAll('link[rel="modulepreload"]'))R(C);new MutationObserver(C=>{for(const V of C)if(V.type==="childList")for(const F of V.addedNodes)F.tagName==="LINK"&&F.rel==="modulepreload"&&R(F)}).observe(document,{childList:!0,subtree:!0});function d(C){const V={};return C.integrity&&(V.integrity=C.integrity),C.referrerPolicy&&(V.referrerPolicy=C.referrerPolicy),C.crossOrigin==="use-credentials"?V.credentials="include":C.crossOrigin==="anonymous"?V.credentials="omit":V.credentials="same-origin",V}function R(C){if(C.ep)return;C.ep=!0;const V=d(C);fetch(C.href,V)}})();function Bf(h){return h&&h.__esModule&&Object.prototype.hasOwnProperty.call(h,"default")?h.default:h}var ju={exports:{}},Sr={},Pu={exports:{}},q={};/** - * @license React - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var za;function $f(){if(za)return q;za=1;var h=Symbol.for("react.element"),S=Symbol.for("react.portal"),d=Symbol.for("react.fragment"),R=Symbol.for("react.strict_mode"),C=Symbol.for("react.profiler"),V=Symbol.for("react.provider"),F=Symbol.for("react.context"),X=Symbol.for("react.forward_ref"),H=Symbol.for("react.suspense"),U=Symbol.for("react.memo"),j=Symbol.for("react.lazy"),M=Symbol.iterator;function A(f){return f===null||typeof f!="object"?null:(f=M&&f[M]||f["@@iterator"],typeof f=="function"?f:null)}var D={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},re=Object.assign,Z={};function J(f,g,W){this.props=f,this.context=g,this.refs=Z,this.updater=W||D}J.prototype.isReactComponent={},J.prototype.setState=function(f,g){if(typeof f!="object"&&typeof f!="function"&&f!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,f,g,"setState")},J.prototype.forceUpdate=function(f){this.updater.enqueueForceUpdate(this,f,"forceUpdate")};function ze(){}ze.prototype=J.prototype;function Oe(f,g,W){this.props=f,this.context=g,this.refs=Z,this.updater=W||D}var Y=Oe.prototype=new ze;Y.constructor=Oe,re(Y,J.prototype),Y.isPureReactComponent=!0;var ue=Array.isArray,ve=Object.prototype.hasOwnProperty,Ee={current:null},ke={key:!0,ref:!0,__self:!0,__source:!0};function Me(f,g,W){var G,ee={},te=null,oe=null;if(g!=null)for(G in g.ref!==void 0&&(oe=g.ref),g.key!==void 0&&(te=""+g.key),g)ve.call(g,G)&&!ke.hasOwnProperty(G)&&(ee[G]=g[G]);var le=arguments.length-2;if(le===1)ee.children=W;else if(1>>1,g=y[f];if(0>>1;fC(ee,_))teC(oe,ee)?(y[f]=oe,y[te]=_,f=te):(y[f]=ee,y[G]=_,f=G);else if(teC(oe,_))y[f]=oe,y[te]=_,f=te;else break e}}return P}function C(y,P){var _=y.sortIndex-P.sortIndex;return _!==0?_:y.id-P.id}if(typeof performance=="object"&&typeof performance.now=="function"){var V=performance;h.unstable_now=function(){return V.now()}}else{var F=Date,X=F.now();h.unstable_now=function(){return F.now()-X}}var H=[],U=[],j=1,M=null,A=3,D=!1,re=!1,Z=!1,J=typeof setTimeout=="function"?setTimeout:null,ze=typeof clearTimeout=="function"?clearTimeout:null,Oe=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function Y(y){for(var P=d(U);P!==null;){if(P.callback===null)R(U);else if(P.startTime<=y)R(U),P.sortIndex=P.expirationTime,S(H,P);else break;P=d(U)}}function ue(y){if(Z=!1,Y(y),!re)if(d(H)!==null)re=!0,De(ve);else{var P=d(U);P!==null&&x(ue,P.startTime-y)}}function ve(y,P){re=!1,Z&&(Z=!1,ze(Me),Me=-1),D=!0;var _=A;try{for(Y(P),M=d(H);M!==null&&(!(M.expirationTime>P)||y&&!Nt());){var f=M.callback;if(typeof f=="function"){M.callback=null,A=M.priorityLevel;var g=f(M.expirationTime<=P);P=h.unstable_now(),typeof g=="function"?M.callback=g:M===d(H)&&R(H),Y(P)}else R(H);M=d(H)}if(M!==null)var W=!0;else{var G=d(U);G!==null&&x(ue,G.startTime-P),W=!1}return W}finally{M=null,A=_,D=!1}}var Ee=!1,ke=null,Me=-1,gt=5,st=-1;function Nt(){return!(h.unstable_now()-sty||125f?(y.sortIndex=_,S(U,y),d(H)===null&&y===d(U)&&(Z?(ze(Me),Me=-1):Z=!0,x(ue,_-f))):(y.sortIndex=g,S(H,y),re||D||(re=!0,De(ve))),y},h.unstable_shouldYield=Nt,h.unstable_wrapCallback=function(y){var P=A;return function(){var _=A;A=P;try{return y.apply(this,arguments)}finally{A=_}}}})(Lu)),Lu}var Ma;function Kf(){return Ma||(Ma=1,Tu.exports=Wf()),Tu.exports}/** - * @license React - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var Da;function Yf(){if(Da)return Ge;Da=1;var h=Ou(),S=Kf();function d(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),H=Object.prototype.hasOwnProperty,U=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,j={},M={};function A(e){return H.call(M,e)?!0:H.call(j,e)?!1:U.test(e)?M[e]=!0:(j[e]=!0,!1)}function D(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function re(e,t,n,r){if(t===null||typeof t>"u"||D(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function Z(e,t,n,r,l,i,u){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=i,this.removeEmptyString=u}var J={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){J[e]=new Z(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];J[t]=new Z(t,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){J[e]=new Z(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){J[e]=new Z(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){J[e]=new Z(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){J[e]=new Z(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){J[e]=new Z(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){J[e]=new Z(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){J[e]=new Z(e,5,!1,e.toLowerCase(),null,!1,!1)});var ze=/[\-:]([a-z])/g;function Oe(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(ze,Oe);J[t]=new Z(t,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(ze,Oe);J[t]=new Z(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(ze,Oe);J[t]=new Z(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){J[e]=new Z(e,1,!1,e.toLowerCase(),null,!1,!1)}),J.xlinkHref=new Z("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){J[e]=new Z(e,1,!1,e.toLowerCase(),null,!0,!0)});function Y(e,t,n,r){var l=J.hasOwnProperty(t)?J[t]:null;(l!==null?l.type!==0:r||!(2o||l[u]!==i[o]){var a=` -`+l[u].replace(" at new "," at ");return e.displayName&&a.includes("")&&(a=a.replace("",e.displayName)),a}while(1<=u&&0<=o);break}}}finally{W=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?g(e):""}function ee(e){switch(e.tag){case 5:return g(e.type);case 16:return g("Lazy");case 13:return g("Suspense");case 19:return g("SuspenseList");case 0:case 2:case 15:return e=G(e.type,!1),e;case 11:return e=G(e.type.render,!1),e;case 1:return e=G(e.type,!0),e;default:return""}}function te(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case ke:return"Fragment";case Ee:return"Portal";case gt:return"Profiler";case Me:return"StrictMode";case Be:return"Suspense";case we:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case Nt:return(e.displayName||"Context")+".Consumer";case st:return(e._context.displayName||"Context")+".Provider";case qe:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case He:return t=e.displayName||null,t!==null?t:te(e.type)||"Memo";case De:t=e._payload,e=e._init;try{return te(e(t))}catch{}}return null}function oe(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return te(t);case 8:return t===Me?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function le(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function de(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Je(e){var t=de(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,i=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(u){r=""+u,i.call(this,u)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(u){r=""+u},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function Nr(e){e._valueTracker||(e._valueTracker=Je(e))}function Mu(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=de(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function _r(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Ml(e,t){var n=t.checked;return _({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Du(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=le(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function Fu(e,t){t=t.checked,t!=null&&Y(e,"checked",t,!1)}function Dl(e,t){Fu(e,t);var n=le(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?Fl(e,t.type,n):t.hasOwnProperty("defaultValue")&&Fl(e,t.type,le(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function Iu(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function Fl(e,t,n){(t!=="number"||_r(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var In=Array.isArray;function dn(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=Er.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Un(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var An={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Ha=["Webkit","ms","Moz","O"];Object.keys(An).forEach(function(e){Ha.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),An[t]=An[e]})});function Hu(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||An.hasOwnProperty(e)&&An[e]?(""+t).trim():t+"px"}function Qu(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=Hu(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var Qa=_({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Al(e,t){if(t){if(Qa[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(d(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(d(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(d(61))}if(t.style!=null&&typeof t.style!="object")throw Error(d(62))}}function Bl(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var $l=null;function Vl(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Hl=null,pn=null,mn=null;function Wu(e){if(e=or(e)){if(typeof Hl!="function")throw Error(d(280));var t=e.stateNode;t&&(t=Xr(t),Hl(e.stateNode,e.type,t))}}function Ku(e){pn?mn?mn.push(e):mn=[e]:pn=e}function Yu(){if(pn){var e=pn,t=mn;if(mn=pn=null,Wu(e),t)for(e=0;e>>=0,e===0?32:31-(tc(e)/nc|0)|0}var Tr=64,Lr=4194304;function Hn(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Rr(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,i=e.pingedLanes,u=n&268435455;if(u!==0){var o=u&~l;o!==0?r=Hn(o):(i&=u,i!==0&&(r=Hn(i)))}else u=n&~l,u!==0?r=Hn(u):i!==0&&(r=Hn(i));if(r===0)return 0;if(t!==0&&t!==r&&(t&l)===0&&(l=r&-r,i=t&-t,l>=i||l===16&&(i&4194240)!==0))return t;if((r&4)!==0&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function Qn(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-at(t),e[t]=n}function uc(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Zn),So=" ",No=!1;function _o(e,t){switch(e){case"keyup":return Mc.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Eo(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var gn=!1;function Fc(e,t){switch(e){case"compositionend":return Eo(t);case"keypress":return t.which!==32?null:(No=!0,So);case"textInput":return e=t.data,e===So&&No?null:e;default:return null}}function Ic(e,t){if(gn)return e==="compositionend"||!oi&&_o(e,t)?(e=vo(),Ir=ti=Ft=null,gn=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=Ro(n)}}function Mo(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Mo(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Do(){for(var e=window,t=_r();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=_r(e.document)}return t}function ci(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Kc(e){var t=Do(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Mo(n.ownerDocument.documentElement,n)){if(r!==null&&ci(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,i=Math.min(r.start,l);r=r.end===void 0?i:Math.min(r.end,l),!e.extend&&i>r&&(l=r,r=i,i=l),l=Oo(n,i);var u=Oo(n,r);l&&u&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==u.node||e.focusOffset!==u.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),i>r?(e.addRange(t),e.extend(u.node,u.offset)):(t.setEnd(u.node,u.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,yn=null,fi=null,nr=null,di=!1;function Fo(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;di||yn==null||yn!==_r(r)||(r=yn,"selectionStart"in r&&ci(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),nr&&tr(nr,r)||(nr=r,r=Wr(fi,"onSelect"),0Nn||(e.current=_i[Nn],_i[Nn]=null,Nn--)}function se(e,t){Nn++,_i[Nn]=e.current,e.current=t}var Bt={},Fe=At(Bt),Qe=At(!1),bt=Bt;function _n(e,t){var n=e.type.contextTypes;if(!n)return Bt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},i;for(i in n)l[i]=t[i];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function We(e){return e=e.childContextTypes,e!=null}function Gr(){ce(Qe),ce(Fe)}function Jo(e,t,n){if(Fe.current!==Bt)throw Error(d(168));se(Fe,t),se(Qe,n)}function Zo(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(d(108,oe(e)||"Unknown",l));return _({},n,r)}function qr(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Bt,bt=Fe.current,se(Fe,e),se(Qe,Qe.current),!0}function bo(e,t,n){var r=e.stateNode;if(!r)throw Error(d(169));n?(e=Zo(e,t,bt),r.__reactInternalMemoizedMergedChildContext=e,ce(Qe),ce(Fe),se(Fe,e)):ce(Qe),se(Qe,n)}var Et=null,Jr=!1,Ei=!1;function es(e){Et===null?Et=[e]:Et.push(e)}function lf(e){Jr=!0,es(e)}function $t(){if(!Ei&&Et!==null){Ei=!0;var e=0,t=ie;try{var n=Et;for(ie=1;e>=u,l-=u,Ct=1<<32-at(t)+l|n<Q?(Pe=$,$=null):Pe=$.sibling;var ne=k(p,$,m[Q],E);if(ne===null){$===null&&($=Pe);break}e&&$&&ne.alternate===null&&t(p,$),c=i(ne,c,Q),B===null?I=ne:B.sibling=ne,B=ne,$=Pe}if(Q===m.length)return n(p,$),pe&&tn(p,Q),I;if($===null){for(;QQ?(Pe=$,$=null):Pe=$.sibling;var qt=k(p,$,ne.value,E);if(qt===null){$===null&&($=Pe);break}e&&$&&qt.alternate===null&&t(p,$),c=i(qt,c,Q),B===null?I=qt:B.sibling=qt,B=qt,$=Pe}if(ne.done)return n(p,$),pe&&tn(p,Q),I;if($===null){for(;!ne.done;Q++,ne=m.next())ne=N(p,ne.value,E),ne!==null&&(c=i(ne,c,Q),B===null?I=ne:B.sibling=ne,B=ne);return pe&&tn(p,Q),I}for($=r(p,$);!ne.done;Q++,ne=m.next())ne=z($,p,Q,ne.value,E),ne!==null&&(e&&ne.alternate!==null&&$.delete(ne.key===null?Q:ne.key),c=i(ne,c,Q),B===null?I=ne:B.sibling=ne,B=ne);return e&&$.forEach(function(Af){return t(p,Af)}),pe&&tn(p,Q),I}function xe(p,c,m,E){if(typeof m=="object"&&m!==null&&m.type===ke&&m.key===null&&(m=m.props.children),typeof m=="object"&&m!==null){switch(m.$$typeof){case ve:e:{for(var I=m.key,B=c;B!==null;){if(B.key===I){if(I=m.type,I===ke){if(B.tag===7){n(p,B.sibling),c=l(B,m.props.children),c.return=p,p=c;break e}}else if(B.elementType===I||typeof I=="object"&&I!==null&&I.$$typeof===De&&us(I)===B.type){n(p,B.sibling),c=l(B,m.props),c.ref=sr(p,B,m),c.return=p,p=c;break e}n(p,B);break}else t(p,B);B=B.sibling}m.type===ke?(c=cn(m.props.children,p.mode,E,m.key),c.return=p,p=c):(E=El(m.type,m.key,m.props,null,p.mode,E),E.ref=sr(p,c,m),E.return=p,p=E)}return u(p);case Ee:e:{for(B=m.key;c!==null;){if(c.key===B)if(c.tag===4&&c.stateNode.containerInfo===m.containerInfo&&c.stateNode.implementation===m.implementation){n(p,c.sibling),c=l(c,m.children||[]),c.return=p,p=c;break e}else{n(p,c);break}else t(p,c);c=c.sibling}c=Su(m,p.mode,E),c.return=p,p=c}return u(p);case De:return B=m._init,xe(p,c,B(m._payload),E)}if(In(m))return L(p,c,m,E);if(P(m))return O(p,c,m,E);tl(p,m)}return typeof m=="string"&&m!==""||typeof m=="number"?(m=""+m,c!==null&&c.tag===6?(n(p,c.sibling),c=l(c,m),c.return=p,p=c):(n(p,c),c=wu(m,p.mode,E),c.return=p,p=c),u(p)):n(p,c)}return xe}var Pn=os(!0),ss=os(!1),nl=At(null),rl=null,zn=null,Li=null;function Ri(){Li=zn=rl=null}function Oi(e){var t=nl.current;ce(nl),e._currentValue=t}function Mi(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function Tn(e,t){rl=e,Li=zn=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(Ke=!0),e.firstContext=null)}function lt(e){var t=e._currentValue;if(Li!==e)if(e={context:e,memoizedValue:t,next:null},zn===null){if(rl===null)throw Error(d(308));zn=e,rl.dependencies={lanes:0,firstContext:e}}else zn=zn.next=e;return t}var nn=null;function Di(e){nn===null?nn=[e]:nn.push(e)}function as(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,Di(t)):(n.next=l.next,l.next=n),t.interleaved=n,Pt(e,r)}function Pt(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var Vt=!1;function Fi(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function cs(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function zt(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function Ht(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,(b&2)!==0){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,Pt(e,n)}return l=r.interleaved,l===null?(t.next=t,Di(r)):(t.next=l.next,l.next=t),r.interleaved=t,Pt(e,n)}function ll(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,ql(e,n)}}function fs(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,i=null;if(n=n.firstBaseUpdate,n!==null){do{var u={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};i===null?l=i=u:i=i.next=u,n=n.next}while(n!==null);i===null?l=i=t:i=i.next=t}else l=i=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:i,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function il(e,t,n,r){var l=e.updateQueue;Vt=!1;var i=l.firstBaseUpdate,u=l.lastBaseUpdate,o=l.shared.pending;if(o!==null){l.shared.pending=null;var a=o,v=a.next;a.next=null,u===null?i=v:u.next=v,u=a;var w=e.alternate;w!==null&&(w=w.updateQueue,o=w.lastBaseUpdate,o!==u&&(o===null?w.firstBaseUpdate=v:o.next=v,w.lastBaseUpdate=a))}if(i!==null){var N=l.baseState;u=0,w=v=a=null,o=i;do{var k=o.lane,z=o.eventTime;if((r&k)===k){w!==null&&(w=w.next={eventTime:z,lane:0,tag:o.tag,payload:o.payload,callback:o.callback,next:null});e:{var L=e,O=o;switch(k=t,z=n,O.tag){case 1:if(L=O.payload,typeof L=="function"){N=L.call(z,N,k);break e}N=L;break e;case 3:L.flags=L.flags&-65537|128;case 0:if(L=O.payload,k=typeof L=="function"?L.call(z,N,k):L,k==null)break e;N=_({},N,k);break e;case 2:Vt=!0}}o.callback!==null&&o.lane!==0&&(e.flags|=64,k=l.effects,k===null?l.effects=[o]:k.push(o))}else z={eventTime:z,lane:k,tag:o.tag,payload:o.payload,callback:o.callback,next:null},w===null?(v=w=z,a=N):w=w.next=z,u|=k;if(o=o.next,o===null){if(o=l.shared.pending,o===null)break;k=o,o=k.next,k.next=null,l.lastBaseUpdate=k,l.shared.pending=null}}while(!0);if(w===null&&(a=N),l.baseState=a,l.firstBaseUpdate=v,l.lastBaseUpdate=w,t=l.shared.interleaved,t!==null){l=t;do u|=l.lane,l=l.next;while(l!==t)}else i===null&&(l.shared.lanes=0);un|=u,e.lanes=u,e.memoizedState=N}}function ds(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=$i.transition;$i.transition={};try{e(!1),t()}finally{ie=n,$i.transition=r}}function Ls(){return it().memoizedState}function af(e,t,n){var r=Yt(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},Rs(e))Os(t,n);else if(n=as(e,t,n,r),n!==null){var l=Ve();ht(n,e,r,l),Ms(n,t,r)}}function cf(e,t,n){var r=Yt(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(Rs(e))Os(t,l);else{var i=e.alternate;if(e.lanes===0&&(i===null||i.lanes===0)&&(i=t.lastRenderedReducer,i!==null))try{var u=t.lastRenderedState,o=i(u,n);if(l.hasEagerState=!0,l.eagerState=o,ct(o,u)){var a=t.interleaved;a===null?(l.next=l,Di(t)):(l.next=a.next,a.next=l),t.interleaved=l;return}}catch{}finally{}n=as(e,t,l,r),n!==null&&(l=Ve(),ht(n,e,r,l),Ms(n,t,r))}}function Rs(e){var t=e.alternate;return e===he||t!==null&&t===he}function Os(e,t){dr=sl=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Ms(e,t,n){if((n&4194240)!==0){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,ql(e,n)}}var fl={readContext:lt,useCallback:Ie,useContext:Ie,useEffect:Ie,useImperativeHandle:Ie,useInsertionEffect:Ie,useLayoutEffect:Ie,useMemo:Ie,useReducer:Ie,useRef:Ie,useState:Ie,useDebugValue:Ie,useDeferredValue:Ie,useTransition:Ie,useMutableSource:Ie,useSyncExternalStore:Ie,useId:Ie,unstable_isNewReconciler:!1},ff={readContext:lt,useCallback:function(e,t){return wt().memoizedState=[e,t===void 0?null:t],e},useContext:lt,useEffect:Ns,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,al(4194308,4,Cs.bind(null,t,e),n)},useLayoutEffect:function(e,t){return al(4194308,4,e,t)},useInsertionEffect:function(e,t){return al(4,2,e,t)},useMemo:function(e,t){var n=wt();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=wt();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=af.bind(null,he,e),[r.memoizedState,e]},useRef:function(e){var t=wt();return e={current:e},t.memoizedState=e},useState:ws,useDebugValue:Xi,useDeferredValue:function(e){return wt().memoizedState=e},useTransition:function(){var e=ws(!1),t=e[0];return e=sf.bind(null,e[1]),wt().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=he,l=wt();if(pe){if(n===void 0)throw Error(d(407));n=n()}else{if(n=t(),je===null)throw Error(d(349));(ln&30)!==0||vs(r,t,n)}l.memoizedState=n;var i={value:n,getSnapshot:t};return l.queue=i,Ns(ys.bind(null,r,i,e),[e]),r.flags|=2048,hr(9,gs.bind(null,r,i,n,t),void 0,null),n},useId:function(){var e=wt(),t=je.identifierPrefix;if(pe){var n=jt,r=Ct;n=(r&~(1<<32-at(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=pr++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=u.createElement(n,{is:r.is}):(e=u.createElement(n),n==="select"&&(u=e,r.multiple?u.multiple=!0:r.size&&(u.size=r.size))):e=u.createElementNS(e,n),e[xt]=t,e[ur]=r,ea(e,t,!1,!1),t.stateNode=e;e:{switch(u=Bl(n,r),n){case"dialog":ae("cancel",e),ae("close",e),l=r;break;case"iframe":case"object":case"embed":ae("load",e),l=r;break;case"video":case"audio":for(l=0;lDn&&(t.flags|=128,r=!0,vr(i,!1),t.lanes=4194304)}else{if(!r)if(e=ul(u),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),vr(i,!0),i.tail===null&&i.tailMode==="hidden"&&!u.alternate&&!pe)return Ue(t),null}else 2*ye()-i.renderingStartTime>Dn&&n!==1073741824&&(t.flags|=128,r=!0,vr(i,!1),t.lanes=4194304);i.isBackwards?(u.sibling=t.child,t.child=u):(n=i.last,n!==null?n.sibling=u:t.child=u,i.last=u)}return i.tail!==null?(t=i.tail,i.rendering=t,i.tail=t.sibling,i.renderingStartTime=ye(),t.sibling=null,n=me.current,se(me,r?n&1|2:n&1),t):(Ue(t),null);case 22:case 23:return yu(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&(t.mode&1)!==0?(tt&1073741824)!==0&&(Ue(t),t.subtreeFlags&6&&(t.flags|=8192)):Ue(t),null;case 24:return null;case 25:return null}throw Error(d(156,t.tag))}function xf(e,t){switch(ji(t),t.tag){case 1:return We(t.type)&&Gr(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Ln(),ce(Qe),ce(Fe),Bi(),e=t.flags,(e&65536)!==0&&(e&128)===0?(t.flags=e&-65537|128,t):null;case 5:return Ui(t),null;case 13:if(ce(me),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(d(340));jn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return ce(me),null;case 4:return Ln(),null;case 10:return Oi(t.type._context),null;case 22:case 23:return yu(),null;case 24:return null;default:return null}}var hl=!1,Ae=!1,kf=typeof WeakSet=="function"?WeakSet:Set,T=null;function On(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){ge(e,t,r)}else n.current=null}function uu(e,t,n){try{n()}catch(r){ge(e,t,r)}}var ra=!1;function wf(e,t){if(yi=Dr,e=Do(),ci(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,i=r.focusNode;r=r.focusOffset;try{n.nodeType,i.nodeType}catch{n=null;break e}var u=0,o=-1,a=-1,v=0,w=0,N=e,k=null;t:for(;;){for(var z;N!==n||l!==0&&N.nodeType!==3||(o=u+l),N!==i||r!==0&&N.nodeType!==3||(a=u+r),N.nodeType===3&&(u+=N.nodeValue.length),(z=N.firstChild)!==null;)k=N,N=z;for(;;){if(N===e)break t;if(k===n&&++v===l&&(o=u),k===i&&++w===r&&(a=u),(z=N.nextSibling)!==null)break;N=k,k=N.parentNode}N=z}n=o===-1||a===-1?null:{start:o,end:a}}else n=null}n=n||{start:0,end:0}}else n=null;for(xi={focusedElem:e,selectionRange:n},Dr=!1,T=t;T!==null;)if(t=T,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,T=e;else for(;T!==null;){t=T;try{var L=t.alternate;if((t.flags&1024)!==0)switch(t.tag){case 0:case 11:case 15:break;case 1:if(L!==null){var O=L.memoizedProps,xe=L.memoizedState,p=t.stateNode,c=p.getSnapshotBeforeUpdate(t.elementType===t.type?O:dt(t.type,O),xe);p.__reactInternalSnapshotBeforeUpdate=c}break;case 3:var m=t.stateNode.containerInfo;m.nodeType===1?m.textContent="":m.nodeType===9&&m.documentElement&&m.removeChild(m.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(d(163))}}catch(E){ge(t,t.return,E)}if(e=t.sibling,e!==null){e.return=t.return,T=e;break}T=t.return}return L=ra,ra=!1,L}function gr(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var i=l.destroy;l.destroy=void 0,i!==void 0&&uu(t,n,i)}l=l.next}while(l!==r)}}function vl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function ou(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function la(e){var t=e.alternate;t!==null&&(e.alternate=null,la(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[xt],delete t[ur],delete t[Ni],delete t[nf],delete t[rf])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function ia(e){return e.tag===5||e.tag===3||e.tag===4}function ua(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||ia(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function su(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Yr));else if(r!==4&&(e=e.child,e!==null))for(su(e,t,n),e=e.sibling;e!==null;)su(e,t,n),e=e.sibling}function au(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(au(e,t,n),e=e.sibling;e!==null;)au(e,t,n),e=e.sibling}var Te=null,pt=!1;function Qt(e,t,n){for(n=n.child;n!==null;)oa(e,t,n),n=n.sibling}function oa(e,t,n){if(yt&&typeof yt.onCommitFiberUnmount=="function")try{yt.onCommitFiberUnmount(zr,n)}catch{}switch(n.tag){case 5:Ae||On(n,t);case 6:var r=Te,l=pt;Te=null,Qt(e,t,n),Te=r,pt=l,Te!==null&&(pt?(e=Te,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):Te.removeChild(n.stateNode));break;case 18:Te!==null&&(pt?(e=Te,n=n.stateNode,e.nodeType===8?Si(e.parentNode,n):e.nodeType===1&&Si(e,n),Gn(e)):Si(Te,n.stateNode));break;case 4:r=Te,l=pt,Te=n.stateNode.containerInfo,pt=!0,Qt(e,t,n),Te=r,pt=l;break;case 0:case 11:case 14:case 15:if(!Ae&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var i=l,u=i.destroy;i=i.tag,u!==void 0&&((i&2)!==0||(i&4)!==0)&&uu(n,t,u),l=l.next}while(l!==r)}Qt(e,t,n);break;case 1:if(!Ae&&(On(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(o){ge(n,t,o)}Qt(e,t,n);break;case 21:Qt(e,t,n);break;case 22:n.mode&1?(Ae=(r=Ae)||n.memoizedState!==null,Qt(e,t,n),Ae=r):Qt(e,t,n);break;default:Qt(e,t,n)}}function sa(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new kf),t.forEach(function(r){var l=Tf.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function mt(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=u),r&=~i}if(r=l,r=ye()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*Nf(r/1960))-r,10e?16:e,Kt===null)var r=!1;else{if(e=Kt,Kt=null,wl=0,(b&6)!==0)throw Error(d(331));var l=b;for(b|=4,T=e.current;T!==null;){var i=T,u=i.child;if((T.flags&16)!==0){var o=i.deletions;if(o!==null){for(var a=0;aye()-du?sn(e,0):fu|=n),Xe(e,t)}function wa(e,t){t===0&&((e.mode&1)===0?t=1:(t=Lr,Lr<<=1,(Lr&130023424)===0&&(Lr=4194304)));var n=Ve();e=Pt(e,t),e!==null&&(Qn(e,t,n),Xe(e,n))}function zf(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),wa(e,n)}function Tf(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(d(314))}r!==null&&r.delete(t),wa(e,n)}var Sa;Sa=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||Qe.current)Ke=!0;else{if((e.lanes&n)===0&&(t.flags&128)===0)return Ke=!1,gf(e,t,n);Ke=(e.flags&131072)!==0}else Ke=!1,pe&&(t.flags&1048576)!==0&&ts(t,br,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;ml(e,t),e=t.pendingProps;var l=_n(t,Fe.current);Tn(t,n),l=Hi(null,t,r,e,l,n);var i=Qi();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,We(r)?(i=!0,qr(t)):i=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,Fi(t),l.updater=dl,t.stateNode=l,l._reactInternals=t,qi(t,r,e,n),t=eu(null,t,r,!0,i,n)):(t.tag=0,pe&&i&&Ci(t),$e(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(ml(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=Rf(r),e=dt(r,e),l){case 0:t=bi(null,t,r,e,n);break e;case 1:t=Xs(null,t,r,e,n);break e;case 11:t=Hs(null,t,r,e,n);break e;case 14:t=Qs(null,t,r,dt(r.type,e),n);break e}throw Error(d(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:dt(r,l),bi(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:dt(r,l),Xs(e,t,r,l,n);case 3:e:{if(Gs(t),e===null)throw Error(d(387));r=t.pendingProps,i=t.memoizedState,l=i.element,cs(e,t),il(t,r,null,n);var u=t.memoizedState;if(r=u.element,i.isDehydrated)if(i={element:r,isDehydrated:!1,cache:u.cache,pendingSuspenseBoundaries:u.pendingSuspenseBoundaries,transitions:u.transitions},t.updateQueue.baseState=i,t.memoizedState=i,t.flags&256){l=Rn(Error(d(423)),t),t=qs(e,t,r,n,l);break e}else if(r!==l){l=Rn(Error(d(424)),t),t=qs(e,t,r,n,l);break e}else for(et=Ut(t.stateNode.containerInfo.firstChild),be=t,pe=!0,ft=null,n=ss(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(jn(),r===l){t=Tt(e,t,n);break e}$e(e,t,r,n)}t=t.child}return t;case 5:return ps(t),e===null&&zi(t),r=t.type,l=t.pendingProps,i=e!==null?e.memoizedProps:null,u=l.children,ki(r,l)?u=null:i!==null&&ki(r,i)&&(t.flags|=32),Ys(e,t),$e(e,t,u,n),t.child;case 6:return e===null&&zi(t),null;case 13:return Js(e,t,n);case 4:return Ii(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=Pn(t,null,r,n):$e(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:dt(r,l),Hs(e,t,r,l,n);case 7:return $e(e,t,t.pendingProps,n),t.child;case 8:return $e(e,t,t.pendingProps.children,n),t.child;case 12:return $e(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,i=t.memoizedProps,u=l.value,se(nl,r._currentValue),r._currentValue=u,i!==null)if(ct(i.value,u)){if(i.children===l.children&&!Qe.current){t=Tt(e,t,n);break e}}else for(i=t.child,i!==null&&(i.return=t);i!==null;){var o=i.dependencies;if(o!==null){u=i.child;for(var a=o.firstContext;a!==null;){if(a.context===r){if(i.tag===1){a=zt(-1,n&-n),a.tag=2;var v=i.updateQueue;if(v!==null){v=v.shared;var w=v.pending;w===null?a.next=a:(a.next=w.next,w.next=a),v.pending=a}}i.lanes|=n,a=i.alternate,a!==null&&(a.lanes|=n),Mi(i.return,n,t),o.lanes|=n;break}a=a.next}}else if(i.tag===10)u=i.type===t.type?null:i.child;else if(i.tag===18){if(u=i.return,u===null)throw Error(d(341));u.lanes|=n,o=u.alternate,o!==null&&(o.lanes|=n),Mi(u,n,t),u=i.sibling}else u=i.child;if(u!==null)u.return=i;else for(u=i;u!==null;){if(u===t){u=null;break}if(i=u.sibling,i!==null){i.return=u.return,u=i;break}u=u.return}i=u}$e(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,Tn(t,n),l=lt(l),r=r(l),t.flags|=1,$e(e,t,r,n),t.child;case 14:return r=t.type,l=dt(r,t.pendingProps),l=dt(r.type,l),Qs(e,t,r,l,n);case 15:return Ws(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:dt(r,l),ml(e,t),t.tag=1,We(r)?(e=!0,qr(t)):e=!1,Tn(t,n),Fs(t,r,l),qi(t,r,l,n),eu(null,t,r,!0,e,n);case 19:return bs(e,t,n);case 22:return Ks(e,t,n)}throw Error(d(156,t.tag))};function Na(e,t){return to(e,t)}function Lf(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function ot(e,t,n,r){return new Lf(e,t,n,r)}function ku(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Rf(e){if(typeof e=="function")return ku(e)?1:0;if(e!=null){if(e=e.$$typeof,e===qe)return 11;if(e===He)return 14}return 2}function Gt(e,t){var n=e.alternate;return n===null?(n=ot(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function El(e,t,n,r,l,i){var u=2;if(r=e,typeof e=="function")ku(e)&&(u=1);else if(typeof e=="string")u=5;else e:switch(e){case ke:return cn(n.children,l,i,t);case Me:u=8,l|=8;break;case gt:return e=ot(12,n,t,l|2),e.elementType=gt,e.lanes=i,e;case Be:return e=ot(13,n,t,l),e.elementType=Be,e.lanes=i,e;case we:return e=ot(19,n,t,l),e.elementType=we,e.lanes=i,e;case x:return Cl(n,l,i,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case st:u=10;break e;case Nt:u=9;break e;case qe:u=11;break e;case He:u=14;break e;case De:u=16,r=null;break e}throw Error(d(130,e==null?e:typeof e,""))}return t=ot(u,n,t,l),t.elementType=e,t.type=r,t.lanes=i,t}function cn(e,t,n,r){return e=ot(7,e,r,t),e.lanes=n,e}function Cl(e,t,n,r){return e=ot(22,e,r,t),e.elementType=x,e.lanes=n,e.stateNode={isHidden:!1},e}function wu(e,t,n){return e=ot(6,e,null,t),e.lanes=n,e}function Su(e,t,n){return t=ot(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Of(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Gl(0),this.expirationTimes=Gl(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Gl(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function Nu(e,t,n,r,l,i,u,o,a){return e=new Of(e,t,n,o,a),t===1?(t=1,i===!0&&(t|=8)):t=0,i=ot(3,null,null,t),e.current=i,i.stateNode=e,i.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Fi(i),e}function Mf(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(h)}catch(S){console.error(S)}}return h(),zu.exports=Yf(),zu.exports}var Ia;function Gf(){if(Ia)return Ol;Ia=1;var h=Xf();return Ol.createRoot=h.createRoot,Ol.hydrateRoot=h.hydrateRoot,Ol}var qf=Gf();async function Re(h,S){const d=await fetch(h,S);if(!d.ok)throw new Error(`${d.status} ${await d.text()}`);return(d.headers.get("content-type")||"").includes("application/json")?d.json():d}const vt=(h,S)=>({method:h,headers:{"Content-Type":"application/json"},body:S?JSON.stringify(S):void 0}),fe={listBooks:()=>Re("/api/books"),uploadBook:h=>{const S=new FormData;return S.append("file",h),Re("/api/books",{method:"POST",body:S})},getBook:h=>Re(`/api/books/${h}`),getChapter:(h,S)=>Re(`/api/books/${h}/chapters/${S}`),putAnalysis:(h,S,d)=>Re(`/api/books/${h}/chapters/${S}/analysis`,vt("PUT",d)),analyze:(h,S)=>Re(`/api/books/${h}/analyze`,vt("POST",{chapters:S})),pronounce:h=>Re(`/api/books/${h}/pronounce`,vt("POST")),castAuto:h=>Re(`/api/books/${h}/cast/auto`,vt("POST")),castAnalyze:(h,S)=>Re(`/api/books/${h}/cast/analyze`,vt("POST",{chapters:S})),castDedup:h=>Re(`/api/books/${h}/cast/dedup`,vt("POST")),render:(h,S,d,R)=>Re(`/api/books/${h}/render`,vt("POST",{chapters:S,backend:d,mono:R})),getCast:h=>Re(`/api/books/${h}/cast`),putCast:(h,S)=>Re(`/api/books/${h}/cast`,vt("PUT",S)),getPron:h=>Re(`/api/books/${h}/pronunciation`),putPron:(h,S)=>Re(`/api/books/${h}/pronunciation`,vt("PUT",S)),getSettings:()=>Re("/api/settings"),putSettings:h=>Re("/api/settings",vt("PUT",h)),audioUrl:(h,S)=>`/api/books/${h}/audio/${S}`,coverUrl:h=>`/api/books/${h}/cover`,previewVoice:async(h,S)=>{const d=await fetch("/api/voicebank/preview",vt("POST",{voice_id:h,text:S}));if(!d.ok)throw new Error("preview");return URL.createObjectURL(await d.blob())}};function Jf(h,S){let d,R=!1;const C=()=>{const V=location.protocol==="https:"?"wss":"ws";d=new WebSocket(`${V}://${location.host}/ws/${h}`),d.onmessage=F=>{const X=JSON.parse(F.data);X.type==="state"&&S(X.state)},d.onclose=()=>{R||setTimeout(C,1500)}};return C(),()=>{R=!0,d&&d.close()}}const Ua={done:"bg-emerald-900/50 text-emerald-300",running:"bg-ink-accent/20 text-ink-accent",error:"bg-red-900/50 text-red-300",pending:"bg-ink-edge text-ink-muted"},Zf={done:"terminé",running:"en cours",error:"erreur",pending:"en attente"};function $a({status:h}){return s.jsx("span",{className:`chip ${Ua[h]||Ua.pending}`,children:Zf[h]||h})}function Va({value:h}){return s.jsx("div",{className:"h-1.5 w-full overflow-hidden rounded-full bg-ink-edge",children:s.jsx("div",{className:"h-full bg-ink-accent transition-all duration-300",style:{width:`${Math.round((h||0)*100)}%`}})})}function fn(){return s.jsx("span",{className:"inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-ink-accent border-t-transparent"})}function bf({onOpen:h}){const[S,d]=K.useState(null),[R,C]=K.useState(!1),[V,F]=K.useState(null),X=K.useRef(),H=()=>fe.listBooks().then(d).catch(j=>F(String(j)));K.useEffect(()=>{H()},[]);const U=async j=>{if(j){C(!0),F(null);try{const{slug:M}=await fe.uploadBook(j);await H(),h(M)}catch(M){F("Échec de l'import : "+M)}finally{C(!1)}}};return s.jsxs("div",{className:"space-y-8",children:[s.jsxs("section",{onDragOver:j=>j.preventDefault(),onDrop:j=>{j.preventDefault(),U(j.dataTransfer.files[0])},className:"card flex flex-col items-center justify-center gap-3 border-dashed py-12 text-center",children:[s.jsx("div",{className:"text-4xl",children:"📖"}),s.jsx("p",{className:"font-serif text-lg",children:"Déposez un fichier EPUB"}),s.jsx("p",{className:"text-sm text-ink-muted",children:"ou"}),s.jsxs("button",{className:"btn-primary",disabled:R,onClick:()=>{var j;return(j=X.current)==null?void 0:j.click()},children:[R?s.jsx(fn,{}):null,R?"Import en cours…":"Choisir un fichier"]}),s.jsx("input",{ref:X,type:"file",accept:".epub",className:"hidden",onChange:j=>U(j.target.files[0])})]}),V&&s.jsx("p",{className:"text-sm text-red-400",children:V}),s.jsxs("section",{children:[s.jsx("h2",{className:"mb-3 font-serif text-lg text-ink-muted",children:"Bibliothèque"}),S===null?s.jsxs("p",{className:"text-ink-muted",children:[s.jsx(fn,{})," chargement…"]}):S.length===0?s.jsx("p",{className:"text-ink-muted",children:"Aucun livre pour l'instant."}):s.jsx("div",{className:"grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4",children:S.map(j=>s.jsxs("button",{onClick:()=>h(j.slug),className:"card group overflow-hidden text-left transition-transform hover:-translate-y-1",children:[s.jsx("div",{className:"aspect-[2/3] w-full bg-ink-edge",children:j.cover&&s.jsx("img",{src:j.cover,alt:"",className:"h-full w-full object-cover"})}),s.jsxs("div",{className:"p-3",children:[s.jsx("p",{className:"line-clamp-2 font-serif text-sm",children:j.title}),s.jsx("p",{className:"mt-1 text-xs text-ink-muted",children:j.author}),s.jsxs("p",{className:"mt-2 text-xs text-ink-accent",children:[j.rendered,"/",j.chapters," chapitres rendus"]})]})]},j.slug))})]})]})}function ed({slug:h,book:S,state:d,busy:R}){const C=S.chapters.filter(D=>D.render),[V,F]=K.useState("kokoro"),[X,H]=K.useState(!1),[U,j]=K.useState(()=>new Set);K.useEffect(()=>{fe.getSettings().then(D=>(D==null?void 0:D.default_backend)&&F(D.default_backend)).catch(()=>{})},[]);const M=D=>{const re=new Set(U);re.has(D)?re.delete(D):re.add(D),j(re)},A=D=>{D.length&&fe.render(h,D,V,X)};return s.jsxs("div",{className:"space-y-4",children:[s.jsxs("div",{className:"card flex flex-wrap items-center gap-3 p-3",children:[s.jsx("label",{className:"text-sm text-ink-muted",children:"Moteur"}),s.jsxs("select",{className:"input",value:V,onChange:D=>F(D.target.value),children:[s.jsx("option",{value:"kokoro",children:"Kokoro (rapide)"}),s.jsx("option",{value:"qwen3",children:"Qwen3 (qualité + clonage)"})]}),s.jsxs("label",{className:"flex items-center gap-2 text-sm text-ink-muted",children:[s.jsx("input",{type:"checkbox",checked:X,onChange:D=>H(D.target.checked)}),"mono-narrateur"]}),s.jsxs("div",{className:"ml-auto flex gap-2",children:[s.jsxs("button",{className:"btn-ghost",disabled:R||!U.size,onClick:()=>A([...U]),children:["Rendre la sélection (",U.size,")"]}),s.jsx("button",{className:"btn-primary",disabled:R,onClick:()=>A(C.map(D=>D.index)),children:"Rendre tout"})]})]}),s.jsx("div",{className:"card divide-y divide-ink-edge",children:C.map(D=>{var J,ze;const re=((J=d.render)==null?void 0:J[D.index])||((ze=d.render)==null?void 0:ze[String(D.index)])||{},Z=(d.analyzed_chapters||[]).includes(D.index);return s.jsxs("div",{className:"flex items-center gap-3 px-4 py-2.5",children:[s.jsx("input",{type:"checkbox",checked:U.has(D.index),onChange:()=>M(D.index)}),s.jsx("div",{className:"w-9 text-center text-xs text-ink-muted",children:D.index}),s.jsxs("div",{className:"flex-1 min-w-0",children:[s.jsx("p",{className:"truncate font-serif text-sm",children:D.title}),s.jsxs("div",{className:"mt-0.5 flex items-center gap-2 text-xs text-ink-muted",children:[s.jsxs("span",{children:[D.word_count," mots"]}),D.pov&&s.jsx("span",{className:"chip bg-ink-edge text-ink-muted",children:D.pov}),Z&&s.jsx("span",{className:"text-emerald-400",children:"analysé"})]}),re.status==="running"&&s.jsx("div",{className:"mt-1.5 max-w-xs",children:s.jsx(Va,{value:re.progress})})]}),re.status&&s.jsx($a,{status:re.status}),re.mp3&&s.jsxs(s.Fragment,{children:[s.jsx("audio",{controls:!0,src:fe.audioUrl(h,D.index),className:"h-8"}),s.jsx("a",{className:"btn-ghost",href:fe.audioUrl(h,D.index),download:!0,children:"↓"})]}),!R&&s.jsxs(s.Fragment,{children:[s.jsx("button",{className:"btn-ghost",title:Z?"Ré-analyser ce chapitre":"Analyser ce chapitre",onClick:()=>fe.analyze(h,[D.index]),children:Z?"Ré-analyser":"Analyser"}),s.jsx("button",{className:"btn-ghost",title:"Ré-analyser le casting de ce chapitre (sans re-segmenter)",onClick:()=>fe.castAnalyze(h,[D.index]),children:"Casting"}),s.jsx("button",{className:"btn-ghost",title:"Rendre ce chapitre",onClick:()=>A([D.index]),children:"▶"})]})]},D.index)})})]})}const Ru="narrateur";let td=0;const Aa=()=>++td;function nd({slug:h,book:S,state:d}){const R=K.useMemo(()=>{const x=new Set(d.analyzed_chapters||[]);return S.chapters.filter(y=>x.has(y.index))},[S,d.analyzed_chapters]),[C,V]=K.useState(()=>{var x;return((x=R[0])==null?void 0:x.index)??null}),[F,X]=K.useState(null),[H,U]=K.useState([]),[j,M]=K.useState(!1),[A,D]=K.useState(!1),[re,Z]=K.useState({id:null,start:0,end:0}),[J,ze]=K.useState(""),[Oe,Y]=K.useState("all"),[ue,ve]=K.useState("all");K.useEffect(()=>{var x;(C==null||!R.some(y=>y.index===C))&&V(((x=R[0])==null?void 0:x.index)??null)},[R]),K.useEffect(()=>{fe.getCast(h).then(x=>{var y;return U((((y=x.cast)==null?void 0:y.characters)||[]).map(P=>P.name))}).catch(()=>U([]))},[h]),K.useEffect(()=>{if(C==null){X(null);return}M(!0),D(!1),fe.getChapter(h,C).then(x=>{var y;x.analysis?X({index:x.analysis.index,title:x.analysis.title,segments:(x.analysis.segments||[]).map(P=>({...P,_id:Aa()}))}):X({index:C,title:((y=x.chapter)==null?void 0:y.title)||"",segments:null})}).finally(()=>M(!1))},[h,C]);const Ee=K.useMemo(()=>{const x=new Set([Ru,...H]);return((F==null?void 0:F.segments)||[]).forEach(y=>y.speaker&&x.add(y.speaker)),[...x]},[H,F]);if(!R.length)return s.jsxs("p",{className:"text-ink-muted",children:["Lancez d'abord l'",s.jsx("b",{children:"Analyse"})," sur un chapitre."]});const ke=x=>{X(y=>({...y,segments:x})),D(!1)},Me=(x,y)=>ke(F.segments.map(P=>{if(P._id!==x)return P;const _={...P,...y};if(_.type==="narration"&&(_.speaker=Ru,_.incises=[]),y.text!==void 0){const f=_.text.length;_.incises=(_.incises||[]).filter(g=>g.startke(F.segments.map(_=>{if(_._id!==x)return _;const f=[..._.incises||[],{start:y,end:P}].sort((g,W)=>g.start-W.start).filter((g,W,G)=>W===0||g.start>=G[W-1].end);return{..._,incises:f}})),st=(x,y)=>ke(F.segments.map(P=>P._id!==x?P:{...P,incises:(P.incises||[]).filter((_,f)=>f!==y)})),Nt=x=>ke(F.segments.filter(y=>y._id!==x)),qe=x=>{const y=F.segments,P=x==null?y.length:y.findIndex(f=>f._id===x)+1,_=[...y];_.splice(P,0,{_id:Aa(),type:"narration",text:"",speaker:Ru}),ke(_)},Be=async()=>{const x={index:F.index,title:F.title,segments:F.segments.map(({_id:y,...P})=>P)};await fe.putAnalysis(h,F.index,x),D(!0)},we=F==null?void 0:F.segments,He=(we||[]).filter(x=>!(Oe!=="all"&&x.type!==Oe||ue!=="all"&&x.speaker!==ue||J&&!x.text.toLowerCase().includes(J.toLowerCase()))),De=(we||[]).filter(x=>x.type==="dialogue").length;return s.jsxs("div",{className:"space-y-4",children:[s.jsx("datalist",{id:"speaker-list",children:Ee.map(x=>s.jsx("option",{value:x},x))}),s.jsxs("div",{className:"card flex flex-wrap items-center gap-3 p-3",children:[s.jsx("label",{className:"text-sm text-ink-muted",children:"Chapitre"}),s.jsx("select",{className:"input",value:C??"",onChange:x=>V(Number(x.target.value)),children:R.map(x=>s.jsxs("option",{value:x.index,children:[x.index," — ",x.title]},x.index))}),we&&s.jsxs("span",{className:"text-xs text-ink-muted",children:[we.length," segments · ",De," dialogues"]}),s.jsx("button",{className:"btn-primary ml-auto",disabled:!we,onClick:Be,children:A?"✓ enregistré":"Enregistrer"})]}),j&&s.jsxs("p",{className:"text-ink-muted",children:[s.jsx(fn,{})," chargement de l'analyse…"]}),!j&&we===null&&s.jsxs("p",{className:"text-ink-muted",children:["Ce chapitre n'a pas encore d'analyse. Lancez l'",s.jsx("b",{children:"Analyse"}),"."]}),!j&&we&&s.jsxs(s.Fragment,{children:[s.jsxs("div",{className:"card flex flex-wrap items-center gap-3 p-3",children:[s.jsx("input",{className:"input flex-1 min-w-[12rem]",placeholder:"Rechercher dans le texte…",value:J,onChange:x=>ze(x.target.value)}),s.jsxs("select",{className:"input",value:Oe,onChange:x=>Y(x.target.value),children:[s.jsx("option",{value:"all",children:"tous types"}),s.jsx("option",{value:"narration",children:"narration"}),s.jsx("option",{value:"dialogue",children:"dialogue"})]}),s.jsxs("select",{className:"input",value:ue,onChange:x=>ve(x.target.value),children:[s.jsx("option",{value:"all",children:"tous locuteurs"}),Ee.map(x=>s.jsx("option",{value:x,children:x},x))]}),He.length!==we.length&&s.jsxs("span",{className:"text-xs text-ink-muted",children:[He.length," affichés"]})]}),s.jsxs("div",{className:"card divide-y divide-ink-edge",children:[He.map(x=>{const y=x.type==="dialogue"&&re.id===x._id&&re.end>re.start,P=x.incises||[];return s.jsxs("div",{className:"px-4 py-2.5",children:[s.jsxs("div",{className:"flex items-start gap-3",children:[s.jsxs("select",{className:"input w-28 shrink-0",value:x.type,onChange:_=>Me(x._id,{type:_.target.value}),children:[s.jsx("option",{value:"narration",children:"narration"}),s.jsx("option",{value:"dialogue",children:"dialogue"})]}),s.jsx("textarea",{className:"input flex-1 min-h-[2.5rem] resize-y font-serif text-sm",rows:Math.min(6,Math.ceil((x.text.length||1)/80)),value:x.text,onSelect:_=>x.type==="dialogue"&&Z({id:x._id,start:_.target.selectionStart,end:_.target.selectionEnd}),onChange:_=>Me(x._id,{text:_.target.value})}),s.jsx("input",{className:"input w-40 shrink-0",list:"speaker-list",placeholder:"locuteur",value:x.speaker,disabled:x.type==="narration",onChange:_=>Me(x._id,{speaker:_.target.value})}),s.jsxs("div",{className:"flex shrink-0 gap-1",children:[s.jsx("button",{className:"btn-ghost",title:"Insérer après",onClick:()=>qe(x._id),children:"+"}),s.jsx("button",{className:"btn-ghost",title:"Supprimer",onClick:()=>Nt(x._id),children:"✕"})]})]}),x.type==="dialogue"&&(P.length>0||y)&&s.jsxs("div",{className:"mt-1.5 ml-[7.75rem] flex flex-wrap items-center gap-1.5",children:[s.jsx("span",{className:"text-[11px] uppercase tracking-wide text-ink-muted",children:"incises"}),P.map((_,f)=>s.jsxs("span",{className:"inline-flex items-center gap-1 rounded bg-ink-edge/40 px-1.5 py-0.5 text-xs",title:"Lu par la voix du narrateur",children:[s.jsx("span",{className:"text-ink-muted",children:"🎙"}),s.jsx("span",{className:"font-serif",children:x.text.slice(_.start,_.end)}),s.jsx("button",{className:"text-ink-muted hover:text-ink",title:"Retirer l'incise",onClick:()=>st(x._id,f),children:"✕"})]},f)),y&&s.jsx("button",{className:"btn-ghost text-xs",onClick:()=>{gt(x._id,re.start,re.end),Z({id:null,start:0,end:0})},children:"+ marquer la sélection"})]})]},x._id)}),s.jsx("div",{className:"px-4 py-2.5",children:s.jsx("button",{className:"btn-ghost",onClick:()=>qe(null),children:"+ ajouter un segment"})})]})]})]})}function Ba({voices:h,value:S,onChange:d}){return s.jsxs("select",{className:"input",value:S||"",onChange:R=>d(R.target.value),children:[s.jsx("option",{value:"",children:"— aucune —"}),h.map(R=>s.jsxs("option",{value:R.id,children:[R.label||R.id," (",R.gender==="male"?"H":R.gender==="female"?"F":"?",")"]},R.id))]})}function rd({slug:h,busy:S}){const[d,R]=K.useState(null),[C,V]=K.useState([]),[F,X]=K.useState(!1),[H,U]=K.useState(null),[j,M]=K.useState(null),A=Qf.useRef(!1),D=()=>fe.getCast(h).then(Y=>{R(Y.cast),V(Y.voicebank.entries)});K.useEffect(()=>{D()},[h]),K.useEffect(()=>{S||D().then(()=>{A.current&&(A.current=!1,fe.getCast(h).then(Y=>M(`✓ déduplication terminée — ${Y.cast.characters.length} personnages`)))})},[S]);const re=async()=>{M(null);try{A.current=!0,await fe.castDedup(h),M("Déduplication lancée…")}catch(Y){A.current=!1,M("Échec : "+Y+" (le serveur backend est-il à jour ? redémarre-le)")}};if(!d)return s.jsxs("p",{className:"text-ink-muted",children:[s.jsx(fn,{})," chargement du casting…"]});if(!d.characters.length)return s.jsxs("p",{className:"text-ink-muted",children:["Lancez d'abord l'",s.jsx("b",{children:"Analyse"})," puis le ",s.jsx("b",{children:"Casting"}),"."]});const Z=Y=>{R({...d,...Y}),X(!1)},J=(Y,ue)=>Z({characters:d.characters.map(ve=>ve.name===Y?{...ve,voice_id:ue}:ve)}),ze=async Y=>{if(Y){U(Y);try{const ue=await fe.previewVoice(Y,"Bonjour, voici un aperçu de cette voix."),ve=new Audio(ue);ve.onended=()=>U(null),ve.play()}catch{U(null)}}},Oe=async()=>{await fe.putCast(h,d),X(!0)};return s.jsxs("div",{className:"space-y-4",children:[s.jsxs("div",{className:"card flex items-center gap-3 p-3",children:[s.jsx("span",{className:"text-sm text-ink-muted",children:"Narrateur"}),s.jsx(Ba,{voices:C,value:d.narrator_voice_id,onChange:Y=>Z({narrator_voice_id:Y})}),s.jsxs("button",{className:"btn-ghost",onClick:()=>ze(d.narrator_voice_id),children:[H===d.narrator_voice_id?"♪":"▶"," écouter"]}),s.jsx("button",{className:"btn-ghost ml-auto",disabled:S,title:"Fusionne les variantes d'un même personnage (Holden / James Holden / James)",onClick:re,children:S?"…":"Dédupliquer"}),s.jsx("button",{className:"btn-primary",onClick:Oe,children:F?"✓ enregistré":"Enregistrer"})]}),j&&s.jsx("p",{className:"px-1 text-sm text-ink-muted",children:j}),s.jsx("div",{className:"card divide-y divide-ink-edge",children:d.characters.map(Y=>{var ue;return s.jsxs("div",{className:"flex items-center gap-3 px-4 py-2.5",children:[s.jsxs("div",{className:"flex-1 min-w-0",children:[s.jsx("p",{className:"truncate font-serif text-sm",children:Y.name}),((ue=Y.aliases)==null?void 0:ue.length)>0&&s.jsxs("p",{className:"truncate text-xs text-ink-muted",children:["alias : ",Y.aliases.join(", ")]}),Y.description&&s.jsx("p",{className:"truncate text-xs text-ink-muted",children:Y.description})]}),s.jsx("span",{className:"chip bg-ink-edge text-ink-muted",children:Y.gender==="male"?"homme":Y.gender==="female"?"femme":"?"}),s.jsx(Ba,{voices:C,value:Y.voice_id,onChange:ve=>J(Y.name,ve)}),s.jsx("button",{className:"btn-ghost",onClick:()=>ze(Y.voice_id),children:H===Y.voice_id?"♪":"▶"})]},Y.name)})})]})}function ld({slug:h}){const[S,d]=K.useState(null),[R,C]=K.useState(!1);if(K.useEffect(()=>{fe.getPron(h).then(j=>d(j.entries||[]))},[h]),S===null)return s.jsxs("p",{className:"text-ink-muted",children:[s.jsx(fn,{})," chargement…"]});const V=()=>C(!1),F=(j,M)=>{d(S.map((A,D)=>D===j?{...A,...M}:A)),V()},X=()=>{d([...S,{term:"",replacement:"",enabled:!0}]),V()},H=j=>{d(S.filter((M,A)=>A!==j)),V()},U=async()=>{await fe.putPron(h,{entries:S.filter(j=>j.term)}),C(!0)};return s.jsxs("div",{className:"space-y-4",children:[s.jsxs("div",{className:"flex items-center gap-3",children:[s.jsx("p",{className:"text-sm text-ink-muted",children:"Corrigez la graphie des mots mal prononcés. La colonne « prononciation » remplace le terme avant la synthèse."}),s.jsx("button",{className:"btn-ghost ml-auto",onClick:X,children:"+ ajouter"}),s.jsx("button",{className:"btn-primary",onClick:U,children:R?"✓ enregistré":"Enregistrer"})]}),S.length===0?s.jsxs("p",{className:"text-ink-muted",children:["Aucune entrée. Lancez l'étape ",s.jsx("b",{children:"Prononciations"})," ou ajoutez-en."]}):s.jsxs("div",{className:"card divide-y divide-ink-edge",children:[s.jsxs("div",{className:"grid grid-cols-[1fr_1fr_auto_auto] gap-3 px-4 py-2 text-xs uppercase text-ink-muted",children:[s.jsx("span",{children:"Terme"}),s.jsx("span",{children:"Prononciation"}),s.jsx("span",{children:"Actif"}),s.jsx("span",{})]}),S.map((j,M)=>s.jsxs("div",{className:"grid grid-cols-[1fr_1fr_auto_auto] items-center gap-3 px-4 py-2",children:[s.jsx("input",{className:"input",value:j.term,onChange:A=>F(M,{term:A.target.value})}),s.jsx("input",{className:"input",value:j.replacement,onChange:A=>F(M,{replacement:A.target.value})}),s.jsx("input",{type:"checkbox",checked:j.enabled!==!1,onChange:A=>F(M,{enabled:A.target.checked})}),s.jsx("button",{className:"text-ink-muted hover:text-red-400",onClick:()=>H(M),children:"✕"})]},M))]})]})}const id=[{key:"analyze",label:"Analyse",action:h=>fe.analyze(h),hint:"Découpe le texte, détecte les locuteurs et le casting."},{key:"cast",label:"Casting",action:h=>fe.castAuto(h),hint:"Attribue une voix à chaque personnage."},{key:"pronounce",label:"Prononciations",action:h=>fe.pronounce(h),hint:"Repère les mots à risque de mauvaise prononciation."}];function ud({slug:h,onBack:S}){const[d,R]=K.useState(null),[C,V]=K.useState(null),[F,X]=K.useState("chapters");if(K.useEffect(()=>(fe.getBook(h).then(A=>{R(A),V(A.state)}),Jf(h,V)),[h]),!d)return s.jsxs("p",{className:"text-ink-muted",children:[s.jsx(fn,{})," chargement…"]});const{book:H}=d,U=C||d.state,j=!!U.active_stage;return s.jsxs("div",{className:"space-y-6",children:[s.jsx("button",{onClick:S,className:"text-sm text-ink-muted hover:text-ink-text",children:"← Bibliothèque"}),s.jsxs("div",{className:"flex gap-5",children:[H.cover_file&&s.jsx("img",{src:fe.coverUrl(h),alt:"",className:"h-44 rounded-md border border-ink-edge object-cover"}),s.jsxs("div",{className:"flex-1",children:[s.jsx("h1",{className:"font-serif text-2xl",children:H.title}),s.jsx("p",{className:"text-ink-muted",children:H.author}),s.jsxs("p",{className:"mt-1 text-sm text-ink-muted",children:[H.chapters.filter(M=>M.render).length," chapitres à narrer"]}),j&&s.jsxs("div",{className:"mt-4 max-w-md space-y-1",children:[s.jsxs("div",{className:"flex justify-between text-xs text-ink-accent",children:[s.jsx("span",{children:U.active_detail||U.active_stage}),s.jsxs("span",{children:[Math.round((U.active_progress||0)*100),"%"]})]}),s.jsx(Va,{value:U.active_progress})]})]})]}),s.jsx("div",{className:"grid grid-cols-1 gap-3 sm:grid-cols-3",children:id.map(M=>{var D;const A=((D=U.stages)==null?void 0:D[M.key])||"pending";return s.jsxs("div",{className:"card p-4",children:[s.jsxs("div",{className:"flex items-center justify-between",children:[s.jsx("span",{className:"font-medium",children:M.label}),s.jsx($a,{status:A})]}),s.jsx("p",{className:"mt-1 text-xs text-ink-muted",children:M.hint}),s.jsx("button",{className:"btn-ghost mt-3",disabled:j,onClick:()=>M.action(h),children:A==="done"?"Relancer":"Lancer"})]},M.key)})}),s.jsx("div",{className:"flex gap-1 border-b border-ink-edge",children:[["chapters","Chapitres"],["analysis","Analyse"],["cast","Casting"],["pron","Prononciation"]].map(([M,A])=>s.jsx("button",{onClick:()=>X(M),className:`px-4 py-2 text-sm ${F===M?"border-b-2 border-ink-accent text-ink-text":"text-ink-muted hover:text-ink-text"}`,children:A},M))}),F==="chapters"&&s.jsx(ed,{slug:h,book:H,state:U,busy:j}),F==="analysis"&&s.jsx(nd,{slug:h,book:H,state:U}),F==="cast"&&s.jsx(rd,{slug:h,busy:j}),F==="pron"&&s.jsx(ld,{slug:h})]})}const od=[{title:"Modèles (identifiants MLX / HuggingFace)",hint:"Changer un identifiant recharge un autre modèle (peut déclencher un téléchargement au prochain usage).",fields:[{key:"gemma_model",label:"Gemma (analyse)",type:"text"},{key:"qwen3_model",label:"Qwen3-TTS (rendu)",type:"text"},{key:"kokoro_model",label:"Kokoro (preview)",type:"text"}]},{title:"Génération Gemma",hint:"Paramètres d'échantillonnage de l'analyse (locuteurs, personnages, prononciations).",fields:[{key:"gemma_temperature",label:"Température",type:"number",step:.05,min:0,max:2},{key:"gemma_max_tokens",label:"Max tokens",type:"number",step:1,min:64,max:8192}]},{title:"Prompts système (analyse)",hint:"Instructions envoyées à Gemma avant chaque tâche. Le modèle doit répondre en JSON.",fields:[{key:"prompt_speakers",label:"Attribution des locuteurs",type:"textarea"},{key:"prompt_characters",label:"Extraction des personnages",type:"textarea"},{key:"prompt_pronunciation",label:"Mots à risque (prononciation)",type:"textarea"}]},{title:"Casting (déduplication)",hint:"Le rapprochement des variantes de noms (Holden / James Holden / James) est heuristique et sûr. La passe Gemma ajoute les variantes non évidentes (diminutifs, titres) mais, avec un petit modèle local, produit des fusions erronées.",fields:[{key:"dedup_use_gemma",label:"Affiner la déduplication avec Gemma (moins sûr)",type:"checkbox"}]},{title:"TTS (voix par défaut)",hint:"Backend et voix utilisés par défaut pour le rendu et les replis.",fields:[{key:"default_backend",label:"Backend par défaut",type:"select",options:[["kokoro","Kokoro (rapide)"],["qwen3","Qwen3 (qualité + clonage)"]]},{key:"language",label:"Langue (Qwen3)",type:"text"},{key:"kokoro_lang_code",label:"Code langue Kokoro",type:"text"},{key:"kokoro_default_voice",label:"Voix Kokoro par défaut",type:"text"},{key:"qwen3_default_voice",label:"Voix Qwen3 par défaut",type:"text"}]},{title:"Audio (encodage final)",hint:"Appliqué à la concaténation et à l'export MP3.",fields:[{key:"target_sample_rate",label:"Sample rate (Hz)",type:"number",step:1e3,min:8e3,max:48e3},{key:"mp3_bitrate",label:"Bitrate MP3",type:"text"},{key:"target_dbfs",label:"Normalisation (dBFS)",type:"number",step:.5,min:-40,max:0}]}];function sd({field:h,value:S,onChange:d}){const R="input w-full";return h.type==="checkbox"?s.jsx("input",{type:"checkbox",className:"h-4 w-4",checked:!!S,onChange:C=>d(C.target.checked)}):h.type==="textarea"?s.jsx("textarea",{className:`${R} min-h-[5rem] resize-y text-sm`,rows:4,value:S??"",onChange:C=>d(C.target.value)}):h.type==="select"?s.jsx("select",{className:R,value:S??"",onChange:C=>d(C.target.value),children:h.options.map(([C,V])=>s.jsx("option",{value:C,children:V},C))}):h.type==="number"?s.jsx("input",{className:R,type:"number",step:h.step,min:h.min,max:h.max,value:S??"",onChange:C=>d(C.target.value===""?"":Number(C.target.value))}):s.jsx("input",{className:R,type:"text",value:S??"",onChange:C=>d(C.target.value)})}function ad({onBack:h}){const[S,d]=K.useState(null),[R,C]=K.useState(!1),[V,F]=K.useState(null);if(K.useEffect(()=>{fe.getSettings().then(d).catch(U=>F(String(U)))},[]),V)return s.jsx("p",{className:"text-sm text-red-400",children:V});if(!S)return s.jsxs("p",{className:"text-ink-muted",children:[s.jsx(fn,{})," chargement des réglages…"]});const X=(U,j)=>{d({...S,[U]:j}),C(!1)},H=async()=>{F(null);try{await fe.putSettings(S),C(!0)}catch(U){F("Échec de l'enregistrement : "+U)}};return s.jsxs("div",{className:"space-y-6",children:[s.jsxs("div",{className:"flex items-center gap-3",children:[s.jsx("button",{onClick:h,className:"text-sm text-ink-muted hover:text-ink-text",children:"← Bibliothèque"}),s.jsx("h1",{className:"font-serif text-2xl",children:"Réglages techniques"}),s.jsx("button",{className:"btn-primary ml-auto",onClick:H,children:R?"✓ enregistré":"Enregistrer"})]}),s.jsx("p",{className:"text-sm text-ink-muted",children:"Réglages globaux appliqués à toute l'app. Les changements de modèle prennent effet au prochain lancement d'analyse ou de rendu."}),od.map(U=>s.jsxs("section",{className:"card p-4 space-y-3",children:[s.jsxs("div",{children:[s.jsx("h2",{className:"font-medium",children:U.title}),U.hint&&s.jsx("p",{className:"text-xs text-ink-muted",children:U.hint})]}),s.jsx("div",{className:"grid gap-3",children:U.fields.map(j=>s.jsxs("label",{className:"grid gap-1",children:[s.jsx("span",{className:"text-sm text-ink-muted",children:j.label}),s.jsx(sd,{field:j,value:S[j.key],onChange:M=>X(j.key,M)})]},j.key))})]},U.title)),s.jsx("div",{className:"flex justify-end",children:s.jsx("button",{className:"btn-primary",onClick:H,children:R?"✓ enregistré":"Enregistrer"})})]})}function cd(){const[h,S]=K.useState(()=>location.hash?decodeURIComponent(location.hash.slice(1)):null),[d,R]=K.useState(!1),C=()=>{R(!1),S(null)};return s.jsxs("div",{className:"min-h-screen bg-ink-bg text-ink-text",children:[s.jsx("header",{className:"border-b border-ink-edge",children:s.jsxs("div",{className:"mx-auto flex max-w-6xl items-center gap-3 px-6 py-4",children:[s.jsxs("button",{onClick:C,className:"flex items-center gap-2",children:[s.jsx("span",{className:"text-2xl",children:"🖋️"}),s.jsxs("span",{className:"font-serif text-xl tracking-wide",children:["Ink",s.jsx("span",{className:"text-ink-accent",children:"Flow"})]})]}),s.jsx("span",{className:"ml-2 hidden text-sm text-ink-muted sm:inline",children:"EPUB → livre audio · local · MLX"}),s.jsx("button",{onClick:()=>R(!0),title:"Réglages techniques",className:"ml-auto text-xl text-ink-muted hover:text-ink-text",children:"⚙"})]})}),s.jsx("main",{className:"mx-auto max-w-6xl px-6 py-8",children:d?s.jsx(ad,{onBack:C}):h?s.jsx(ud,{slug:h,onBack:()=>S(null)}):s.jsx(bf,{onOpen:S})})]})}qf.createRoot(document.getElementById("root")).render(s.jsx(cd,{})); diff --git a/frontend/dist/assets/index-DlPmWkkU.css b/frontend/dist/assets/index-CO-QVT_t.css similarity index 62% rename from frontend/dist/assets/index-DlPmWkkU.css rename to frontend/dist/assets/index-CO-QVT_t.css index 3d7f777..7d26f9b 100644 --- a/frontend/dist/assets/index-DlPmWkkU.css +++ b/frontend/dist/assets/index-CO-QVT_t.css @@ -1 +1 @@ -*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.btn-primary{display:inline-flex;align-items:center;gap:.5rem;border-radius:.375rem;padding:.375rem .75rem;font-size:.875rem;line-height:1.25rem;font-weight:500;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.btn-primary:disabled{cursor:not-allowed;opacity:.4}.btn-primary{--tw-bg-opacity: 1;background-color:rgb(217 164 65 / var(--tw-bg-opacity, 1));--tw-text-opacity: 1;color:rgb(20 17 15 / var(--tw-text-opacity, 1))}.btn-primary:hover{--tw-bg-opacity: 1;background-color:rgb(185 118 63 / var(--tw-bg-opacity, 1))}.btn-ghost{display:inline-flex;align-items:center;gap:.5rem;border-radius:.375rem;padding:.375rem .75rem;font-size:.875rem;line-height:1.25rem;font-weight:500;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.btn-ghost:disabled{cursor:not-allowed;opacity:.4}.btn-ghost{border-width:1px;--tw-border-opacity: 1;border-color:rgb(44 38 34 / var(--tw-border-opacity, 1));--tw-text-opacity: 1;color:rgb(237 228 216 / var(--tw-text-opacity, 1))}.btn-ghost:hover{--tw-bg-opacity: 1;background-color:rgb(44 38 34 / var(--tw-bg-opacity, 1))}.card{border-radius:.5rem;border-width:1px;--tw-border-opacity: 1;border-color:rgb(44 38 34 / var(--tw-border-opacity, 1));--tw-bg-opacity: 1;background-color:rgb(29 25 22 / var(--tw-bg-opacity, 1))}.chip{display:inline-flex;align-items:center;border-radius:9999px;padding:.125rem .5rem;font-size:.75rem;line-height:1rem;font-weight:500}.input{border-radius:.375rem;border-width:1px;--tw-border-opacity: 1;border-color:rgb(44 38 34 / var(--tw-border-opacity, 1));--tw-bg-opacity: 1;background-color:rgb(20 17 15 / var(--tw-bg-opacity, 1));padding:.25rem .5rem;font-size:.875rem;line-height:1.25rem;--tw-text-opacity: 1;color:rgb(237 228 216 / var(--tw-text-opacity, 1));outline:2px solid transparent;outline-offset:2px}.input:focus{--tw-border-opacity: 1;border-color:rgb(217 164 65 / var(--tw-border-opacity, 1))}.visible{visibility:visible}.mx-auto{margin-left:auto;margin-right:auto}.mb-3{margin-bottom:.75rem}.ml-2{margin-left:.5rem}.ml-\[7\.75rem\]{margin-left:7.75rem}.ml-auto{margin-left:auto}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.grid{display:grid}.hidden{display:none}.aspect-\[2\/3\]{aspect-ratio:2/3}.h-1\.5{height:.375rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-44{height:11rem}.h-8{height:2rem}.h-full{height:100%}.min-h-\[2\.5rem\]{min-height:2.5rem}.min-h-\[5rem\]{min-height:5rem}.min-h-screen{min-height:100vh}.w-28{width:7rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-40{width:10rem}.w-9{width:2.25rem}.w-full{width:100%}.min-w-0{min-width:0px}.min-w-\[12rem\]{min-width:12rem}.max-w-6xl{max-width:72rem}.max-w-md{max-width:28rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.resize-y{resize:vertical}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[1fr_1fr_auto_auto\]{grid-template-columns:1fr 1fr auto auto}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(2rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-ink-edge>:not([hidden])~:not([hidden]){--tw-divide-opacity: 1;border-color:rgb(44 38 34 / var(--tw-divide-opacity, 1))}.overflow-hidden{overflow:hidden}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-dashed{border-style:dashed}.border-ink-accent{--tw-border-opacity: 1;border-color:rgb(217 164 65 / var(--tw-border-opacity, 1))}.border-ink-edge{--tw-border-opacity: 1;border-color:rgb(44 38 34 / var(--tw-border-opacity, 1))}.border-t-transparent{border-top-color:transparent}.bg-emerald-900\/50{background-color:#064e3b80}.bg-ink-accent{--tw-bg-opacity: 1;background-color:rgb(217 164 65 / var(--tw-bg-opacity, 1))}.bg-ink-accent\/20{background-color:#d9a44133}.bg-ink-bg{--tw-bg-opacity: 1;background-color:rgb(20 17 15 / var(--tw-bg-opacity, 1))}.bg-ink-edge{--tw-bg-opacity: 1;background-color:rgb(44 38 34 / var(--tw-bg-opacity, 1))}.bg-ink-edge\/40{background-color:#2c262266}.bg-red-900\/50{background-color:#7f1d1d80}.object-cover{-o-object-fit:cover;object-fit:cover}.p-3{padding:.75rem}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.text-left{text-align:left}.text-center{text-align:center}.font-serif{font-family:Georgia,Cambria,serif}.text-2xl{font-size:1.5rem;line-height:2rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-\[11px\]{font-size:11px}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.uppercase{text-transform:uppercase}.tracking-wide{letter-spacing:.025em}.text-emerald-300{--tw-text-opacity: 1;color:rgb(110 231 183 / var(--tw-text-opacity, 1))}.text-emerald-400{--tw-text-opacity: 1;color:rgb(52 211 153 / var(--tw-text-opacity, 1))}.text-ink-accent{--tw-text-opacity: 1;color:rgb(217 164 65 / var(--tw-text-opacity, 1))}.text-ink-muted{--tw-text-opacity: 1;color:rgb(154 140 125 / var(--tw-text-opacity, 1))}.text-ink-text{--tw-text-opacity: 1;color:rgb(237 228 216 / var(--tw-text-opacity, 1))}.text-red-300{--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-300{transition-duration:.3s}:root{color-scheme:dark}body{margin:0;background:#14110f;color:#ede4d8;font-family:system-ui,-apple-system,Segoe UI,sans-serif}.hover\:-translate-y-1:hover{--tw-translate-y: -.25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:text-ink-text:hover{--tw-text-opacity: 1;color:rgb(237 228 216 / var(--tw-text-opacity, 1))}.hover\:text-red-400:hover{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}@media(min-width:640px){.sm\:inline{display:inline}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(min-width:1024px){.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}} +*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.btn-primary{display:inline-flex;align-items:center;gap:.5rem;border-radius:.375rem;padding:.375rem .75rem;font-size:.875rem;line-height:1.25rem;font-weight:500;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.btn-primary:disabled{cursor:not-allowed;opacity:.4}.btn-primary{--tw-bg-opacity: 1;background-color:rgb(217 164 65 / var(--tw-bg-opacity, 1));--tw-text-opacity: 1;color:rgb(20 17 15 / var(--tw-text-opacity, 1))}.btn-primary:hover{--tw-bg-opacity: 1;background-color:rgb(185 118 63 / var(--tw-bg-opacity, 1))}.btn-ghost{display:inline-flex;align-items:center;gap:.5rem;border-radius:.375rem;padding:.375rem .75rem;font-size:.875rem;line-height:1.25rem;font-weight:500;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.btn-ghost:disabled{cursor:not-allowed;opacity:.4}.btn-ghost{border-width:1px;--tw-border-opacity: 1;border-color:rgb(44 38 34 / var(--tw-border-opacity, 1));--tw-text-opacity: 1;color:rgb(237 228 216 / var(--tw-text-opacity, 1))}.btn-ghost:hover{--tw-bg-opacity: 1;background-color:rgb(44 38 34 / var(--tw-bg-opacity, 1))}.card{border-radius:.5rem;border-width:1px;--tw-border-opacity: 1;border-color:rgb(44 38 34 / var(--tw-border-opacity, 1));--tw-bg-opacity: 1;background-color:rgb(29 25 22 / var(--tw-bg-opacity, 1))}.chip{display:inline-flex;align-items:center;border-radius:9999px;padding:.125rem .5rem;font-size:.75rem;line-height:1rem;font-weight:500}.input{border-radius:.375rem;border-width:1px;--tw-border-opacity: 1;border-color:rgb(44 38 34 / var(--tw-border-opacity, 1));--tw-bg-opacity: 1;background-color:rgb(20 17 15 / var(--tw-bg-opacity, 1));padding:.25rem .5rem;font-size:.875rem;line-height:1.25rem;--tw-text-opacity: 1;color:rgb(237 228 216 / var(--tw-text-opacity, 1));outline:2px solid transparent;outline-offset:2px}.input:focus{--tw-border-opacity: 1;border-color:rgb(217 164 65 / var(--tw-border-opacity, 1))}.visible{visibility:visible}.mx-auto{margin-left:auto;margin-right:auto}.mb-3{margin-bottom:.75rem}.ml-2{margin-left:.5rem}.ml-\[7\.75rem\]{margin-left:7.75rem}.ml-auto{margin-left:auto}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.grid{display:grid}.hidden{display:none}.aspect-\[2\/3\]{aspect-ratio:2/3}.h-1\.5{height:.375rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-44{height:11rem}.h-8{height:2rem}.h-full{height:100%}.min-h-\[2\.5rem\]{min-height:2.5rem}.min-h-\[5rem\]{min-height:5rem}.min-h-screen{min-height:100vh}.w-28{width:7rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-40{width:10rem}.w-9{width:2.25rem}.w-full{width:100%}.min-w-0{min-width:0px}.min-w-\[12rem\]{min-width:12rem}.max-w-6xl{max-width:72rem}.max-w-md{max-width:28rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.resize-y{resize:vertical}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-\[1fr_1fr_auto_auto\]{grid-template-columns:1fr 1fr auto auto}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(2rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-ink-edge>:not([hidden])~:not([hidden]){--tw-divide-opacity: 1;border-color:rgb(44 38 34 / var(--tw-divide-opacity, 1))}.overflow-hidden{overflow:hidden}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-dashed{border-style:dashed}.border-ink-accent{--tw-border-opacity: 1;border-color:rgb(217 164 65 / var(--tw-border-opacity, 1))}.border-ink-edge{--tw-border-opacity: 1;border-color:rgb(44 38 34 / var(--tw-border-opacity, 1))}.border-t-transparent{border-top-color:transparent}.bg-emerald-900\/50{background-color:#064e3b80}.bg-ink-accent{--tw-bg-opacity: 1;background-color:rgb(217 164 65 / var(--tw-bg-opacity, 1))}.bg-ink-accent\/20{background-color:#d9a44133}.bg-ink-bg{--tw-bg-opacity: 1;background-color:rgb(20 17 15 / var(--tw-bg-opacity, 1))}.bg-ink-edge{--tw-bg-opacity: 1;background-color:rgb(44 38 34 / var(--tw-bg-opacity, 1))}.bg-ink-edge\/40{background-color:#2c262266}.bg-red-900\/50{background-color:#7f1d1d80}.object-cover{-o-object-fit:cover;object-fit:cover}.p-3{padding:.75rem}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.text-left{text-align:left}.text-center{text-align:center}.font-serif{font-family:Georgia,Cambria,serif}.text-2xl{font-size:1.5rem;line-height:2rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-\[11px\]{font-size:11px}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.uppercase{text-transform:uppercase}.tracking-wide{letter-spacing:.025em}.text-emerald-300{--tw-text-opacity: 1;color:rgb(110 231 183 / var(--tw-text-opacity, 1))}.text-emerald-400{--tw-text-opacity: 1;color:rgb(52 211 153 / var(--tw-text-opacity, 1))}.text-ink-accent{--tw-text-opacity: 1;color:rgb(217 164 65 / var(--tw-text-opacity, 1))}.text-ink-muted{--tw-text-opacity: 1;color:rgb(154 140 125 / var(--tw-text-opacity, 1))}.text-ink-text{--tw-text-opacity: 1;color:rgb(237 228 216 / var(--tw-text-opacity, 1))}.text-red-300{--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-300{transition-duration:.3s}:root{color-scheme:dark}body{margin:0;background:#14110f;color:#ede4d8;font-family:system-ui,-apple-system,Segoe UI,sans-serif}.hover\:-translate-y-1:hover{--tw-translate-y: -.25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:text-ink-text:hover{--tw-text-opacity: 1;color:rgb(237 228 216 / var(--tw-text-opacity, 1))}.hover\:text-red-400:hover{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}@media(min-width:640px){.sm\:inline{display:inline}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(min-width:1024px){.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}} diff --git a/frontend/dist/assets/index-qJQqFSeO.js b/frontend/dist/assets/index-qJQqFSeO.js new file mode 100644 index 0000000..919c86e --- /dev/null +++ b/frontend/dist/assets/index-qJQqFSeO.js @@ -0,0 +1,40 @@ +(function(){const S=document.createElement("link").relList;if(S&&S.supports&&S.supports("modulepreload"))return;for(const E of document.querySelectorAll('link[rel="modulepreload"]'))L(E);new MutationObserver(E=>{for(const B of E)if(B.type==="childList")for(const R of B.addedNodes)R.tagName==="LINK"&&R.rel==="modulepreload"&&L(R)}).observe(document,{childList:!0,subtree:!0});function d(E){const B={};return E.integrity&&(B.integrity=E.integrity),E.referrerPolicy&&(B.referrerPolicy=E.referrerPolicy),E.crossOrigin==="use-credentials"?B.credentials="include":E.crossOrigin==="anonymous"?B.credentials="omit":B.credentials="same-origin",B}function L(E){if(E.ep)return;E.ep=!0;const B=d(E);fetch(E.href,B)}})();function Ad(m){return m&&m.__esModule&&Object.prototype.hasOwnProperty.call(m,"default")?m.default:m}var Po={exports:{}},Sr={},zo={exports:{}},J={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var za;function Bd(){if(za)return J;za=1;var m=Symbol.for("react.element"),S=Symbol.for("react.portal"),d=Symbol.for("react.fragment"),L=Symbol.for("react.strict_mode"),E=Symbol.for("react.profiler"),B=Symbol.for("react.provider"),R=Symbol.for("react.context"),G=Symbol.for("react.forward_ref"),Q=Symbol.for("react.suspense"),M=Symbol.for("react.memo"),j=Symbol.for("react.lazy"),I=Symbol.iterator;function $(f){return f===null||typeof f!="object"?null:(f=I&&f[I]||f["@@iterator"],typeof f=="function"?f:null)}var D={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Z=Object.assign,b={};function Y(f,g,X){this.props=f,this.context=g,this.refs=b,this.updater=X||D}Y.prototype.isReactComponent={},Y.prototype.setState=function(f,g){if(typeof f!="object"&&typeof f!="function"&&f!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,f,g,"setState")},Y.prototype.forceUpdate=function(f){this.updater.enqueueForceUpdate(this,f,"forceUpdate")};function De(){}De.prototype=Y.prototype;function Pe(f,g,X){this.props=f,this.context=g,this.refs=b,this.updater=X||D}var $e=Pe.prototype=new De;$e.constructor=Pe,Z($e,Y.prototype),$e.isPureReactComponent=!0;var ge=Array.isArray,Ve=Object.prototype.hasOwnProperty,ke={current:null},ye={key:!0,ref:!0,__self:!0,__source:!0};function A(f,g,X){var q,te={},ne=null,se=null;if(g!=null)for(q in g.ref!==void 0&&(se=g.ref),g.key!==void 0&&(ne=""+g.key),g)Ve.call(g,q)&&!ye.hasOwnProperty(q)&&(te[q]=g[q]);var ie=arguments.length-2;if(ie===1)te.children=X;else if(1>>1,g=y[f];if(0>>1;fE(te,_))neE(se,te)?(y[f]=se,y[ne]=_,f=ne):(y[f]=te,y[q]=_,f=q);else if(neE(se,_))y[f]=se,y[ne]=_,f=ne;else break e}}return P}function E(y,P){var _=y.sortIndex-P.sortIndex;return _!==0?_:y.id-P.id}if(typeof performance=="object"&&typeof performance.now=="function"){var B=performance;m.unstable_now=function(){return B.now()}}else{var R=Date,G=R.now();m.unstable_now=function(){return R.now()-G}}var Q=[],M=[],j=1,I=null,$=3,D=!1,Z=!1,b=!1,Y=typeof setTimeout=="function"?setTimeout:null,De=typeof clearTimeout=="function"?clearTimeout:null,Pe=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function $e(y){for(var P=d(M);P!==null;){if(P.callback===null)L(M);else if(P.startTime<=y)L(M),P.sortIndex=P.expirationTime,S(Q,P);else break;P=d(M)}}function ge(y){if(b=!1,$e(y),!Z)if(d(Q)!==null)Z=!0,Fe(Ve);else{var P=d(M);P!==null&&x(ge,P.startTime-y)}}function Ve(y,P){Z=!1,b&&(b=!1,De(A),A=-1),D=!0;var _=$;try{for($e(P),I=d(Q);I!==null&&(!(I.expirationTime>P)||y&&!Ke());){var f=I.callback;if(typeof f=="function"){I.callback=null,$=I.priorityLevel;var g=f(I.expirationTime<=P);P=m.unstable_now(),typeof g=="function"?I.callback=g:I===d(Q)&&L(Q),$e(P)}else L(Q);I=d(Q)}if(I!==null)var X=!0;else{var q=d(M);q!==null&&x(ge,q.startTime-P),X=!1}return X}finally{I=null,$=_,D=!1}}var ke=!1,ye=null,A=-1,le=5,fe=-1;function Ke(){return!(m.unstable_now()-fey||125f?(y.sortIndex=_,S(M,y),d(Q)===null&&y===d(M)&&(b?(De(A),A=-1):b=!0,x(ge,_-f))):(y.sortIndex=g,S(Q,y),Z||D||(Z=!0,Fe(Ve))),y},m.unstable_shouldYield=Ke,m.unstable_wrapCallback=function(y){var P=$;return function(){var _=$;$=P;try{return y.apply(this,arguments)}finally{$=_}}}})(Ro)),Ro}var Oa;function Wd(){return Oa||(Oa=1,To.exports=Qd()),To.exports}/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Da;function Kd(){if(Da)return be;Da=1;var m=Oo(),S=Wd();function d(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Q=Object.prototype.hasOwnProperty,M=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,j={},I={};function $(e){return Q.call(I,e)?!0:Q.call(j,e)?!1:M.test(e)?I[e]=!0:(j[e]=!0,!1)}function D(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function Z(e,t,n,r){if(t===null||typeof t>"u"||D(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function b(e,t,n,r,l,i,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=i,this.removeEmptyString=o}var Y={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){Y[e]=new b(e,0,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];Y[t]=new b(t,1,!1,e[1],null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){Y[e]=new b(e,2,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){Y[e]=new b(e,2,!1,e,null,!1,!1)}),"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){Y[e]=new b(e,3,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){Y[e]=new b(e,3,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){Y[e]=new b(e,4,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){Y[e]=new b(e,6,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){Y[e]=new b(e,5,!1,e.toLowerCase(),null,!1,!1)});var De=/[\-:]([a-z])/g;function Pe(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(De,Pe);Y[t]=new b(t,1,!1,e,null,!1,!1)}),"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(De,Pe);Y[t]=new b(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(De,Pe);Y[t]=new b(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){Y[e]=new b(e,1,!1,e.toLowerCase(),null,!1,!1)}),Y.xlinkHref=new b("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){Y[e]=new b(e,1,!1,e.toLowerCase(),null,!0,!0)});function $e(e,t,n,r){var l=Y.hasOwnProperty(t)?Y[t]:null;(l!==null?l.type!==0:r||!(2u||l[o]!==i[u]){var a=` +`+l[o].replace(" at new "," at ");return e.displayName&&a.includes("")&&(a=a.replace("",e.displayName)),a}while(1<=o&&0<=u);break}}}finally{X=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?g(e):""}function te(e){switch(e.tag){case 5:return g(e.type);case 16:return g("Lazy");case 13:return g("Suspense");case 19:return g("SuspenseList");case 0:case 2:case 15:return e=q(e.type,!1),e;case 11:return e=q(e.type.render,!1),e;case 1:return e=q(e.type,!0),e;default:return""}}function ne(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case ye:return"Fragment";case ke:return"Portal";case le:return"Profiler";case A:return"StrictMode";case He:return"Suspense";case Ne:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case Ke:return(e.displayName||"Context")+".Consumer";case fe:return(e._context.displayName||"Context")+".Provider";case ze:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case Xe:return t=e.displayName||null,t!==null?t:ne(e.type)||"Memo";case Fe:t=e._payload,e=e._init;try{return ne(e(t))}catch{}}return null}function se(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return ne(t);case 8:return t===A?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function ie(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function pe(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function et(e){var t=pe(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,i=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(o){r=""+o,i.call(this,o)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(o){r=""+o},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function Nr(e){e._valueTracker||(e._valueTracker=et(e))}function Do(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=pe(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function _r(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Ol(e,t){var n=t.checked;return _({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Fo(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=ie(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function Io(e,t){t=t.checked,t!=null&&$e(e,"checked",t,!1)}function Dl(e,t){Io(e,t);var n=ie(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?Fl(e,t.type,n):t.hasOwnProperty("defaultValue")&&Fl(e,t.type,ie(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function Uo(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function Fl(e,t,n){(t!=="number"||_r(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var In=Array.isArray;function fn(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=Cr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Un(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var An={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Ha=["Webkit","ms","Moz","O"];Object.keys(An).forEach(function(e){Ha.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),An[t]=An[e]})});function Qo(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||An.hasOwnProperty(e)&&An[e]?(""+t).trim():t+"px"}function Wo(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=Qo(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var Qa=_({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Al(e,t){if(t){if(Qa[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(d(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(d(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(d(61))}if(t.style!=null&&typeof t.style!="object")throw Error(d(62))}}function Bl(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var $l=null;function Vl(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Hl=null,pn=null,mn=null;function Ko(e){if(e=ur(e)){if(typeof Hl!="function")throw Error(d(280));var t=e.stateNode;t&&(t=Gr(t),Hl(e.stateNode,e.type,t))}}function Xo(e){pn?mn?mn.push(e):mn=[e]:pn=e}function Go(){if(pn){var e=pn,t=mn;if(mn=pn=null,Ko(e),t)for(e=0;e>>=0,e===0?32:31-(tc(e)/nc|0)|0}var Lr=64,Tr=4194304;function Hn(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Rr(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,i=e.pingedLanes,o=n&268435455;if(o!==0){var u=o&~l;u!==0?r=Hn(u):(i&=o,i!==0&&(r=Hn(i)))}else o=n&~l,o!==0?r=Hn(o):i!==0&&(r=Hn(i));if(r===0)return 0;if(t!==0&&t!==r&&(t&l)===0&&(l=r&-r,i=t&-t,l>=i||l===16&&(i&4194240)!==0))return t;if((r&4)!==0&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function Qn(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-dt(t),e[t]=n}function oc(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Zn),Su=" ",Nu=!1;function _u(e,t){switch(e){case"keyup":return Oc.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Cu(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var gn=!1;function Fc(e,t){switch(e){case"compositionend":return Cu(t);case"keypress":return t.which!==32?null:(Nu=!0,Su);case"textInput":return e=t.data,e===Su&&Nu?null:e;default:return null}}function Ic(e,t){if(gn)return e==="compositionend"||!ui&&_u(e,t)?(e=vu(),Ir=ti=Ft=null,gn=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=Ru(n)}}function Ou(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Ou(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Du(){for(var e=window,t=_r();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=_r(e.document)}return t}function ci(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Kc(e){var t=Du(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Ou(n.ownerDocument.documentElement,n)){if(r!==null&&ci(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,i=Math.min(r.start,l);r=r.end===void 0?i:Math.min(r.end,l),!e.extend&&i>r&&(l=r,r=i,i=l),l=Mu(n,i);var o=Mu(n,r);l&&o&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==o.node||e.focusOffset!==o.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),i>r?(e.addRange(t),e.extend(o.node,o.offset)):(t.setEnd(o.node,o.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,yn=null,di=null,nr=null,fi=!1;function Fu(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;fi||yn==null||yn!==_r(r)||(r=yn,"selectionStart"in r&&ci(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),nr&&tr(nr,r)||(nr=r,r=Wr(di,"onSelect"),0Nn||(e.current=_i[Nn],_i[Nn]=null,Nn--)}function ae(e,t){Nn++,_i[Nn]=e.current,e.current=t}var Bt={},Ie=At(Bt),Ge=At(!1),bt=Bt;function _n(e,t){var n=e.type.contextTypes;if(!n)return Bt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},i;for(i in n)l[i]=t[i];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function Ye(e){return e=e.childContextTypes,e!=null}function Yr(){de(Ge),de(Ie)}function Ju(e,t,n){if(Ie.current!==Bt)throw Error(d(168));ae(Ie,t),ae(Ge,n)}function Zu(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(d(108,se(e)||"Unknown",l));return _({},n,r)}function qr(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Bt,bt=Ie.current,ae(Ie,e),ae(Ge,Ge.current),!0}function bu(e,t,n){var r=e.stateNode;if(!r)throw Error(d(169));n?(e=Zu(e,t,bt),r.__reactInternalMemoizedMergedChildContext=e,de(Ge),de(Ie),ae(Ie,e)):de(Ge),ae(Ge,n)}var Ct=null,Jr=!1,Ci=!1;function es(e){Ct===null?Ct=[e]:Ct.push(e)}function ld(e){Jr=!0,es(e)}function $t(){if(!Ci&&Ct!==null){Ci=!0;var e=0,t=oe;try{var n=Ct;for(oe=1;e>=o,l-=o,Et=1<<32-dt(t)+l|n<K?(Re=H,H=null):Re=H.sibling;var re=k(p,H,h[K],C);if(re===null){H===null&&(H=Re);break}e&&H&&re.alternate===null&&t(p,H),c=i(re,c,K),V===null?U=re:V.sibling=re,V=re,H=Re}if(K===h.length)return n(p,H),me&&tn(p,K),U;if(H===null){for(;KK?(Re=H,H=null):Re=H.sibling;var qt=k(p,H,re.value,C);if(qt===null){H===null&&(H=Re);break}e&&H&&qt.alternate===null&&t(p,H),c=i(qt,c,K),V===null?U=qt:V.sibling=qt,V=qt,H=Re}if(re.done)return n(p,H),me&&tn(p,K),U;if(H===null){for(;!re.done;K++,re=h.next())re=N(p,re.value,C),re!==null&&(c=i(re,c,K),V===null?U=re:V.sibling=re,V=re);return me&&tn(p,K),U}for(H=r(p,H);!re.done;K++,re=h.next())re=z(H,p,K,re.value,C),re!==null&&(e&&re.alternate!==null&&H.delete(re.key===null?K:re.key),c=i(re,c,K),V===null?U=re:V.sibling=re,V=re);return e&&H.forEach(function(Ud){return t(p,Ud)}),me&&tn(p,K),U}function Se(p,c,h,C){if(typeof h=="object"&&h!==null&&h.type===ye&&h.key===null&&(h=h.props.children),typeof h=="object"&&h!==null){switch(h.$$typeof){case Ve:e:{for(var U=h.key,V=c;V!==null;){if(V.key===U){if(U=h.type,U===ye){if(V.tag===7){n(p,V.sibling),c=l(V,h.props.children),c.return=p,p=c;break e}}else if(V.elementType===U||typeof U=="object"&&U!==null&&U.$$typeof===Fe&&os(U)===V.type){n(p,V.sibling),c=l(V,h.props),c.ref=sr(p,V,h),c.return=p,p=c;break e}n(p,V);break}else t(p,V);V=V.sibling}h.type===ye?(c=cn(h.props.children,p.mode,C,h.key),c.return=p,p=c):(C=Cl(h.type,h.key,h.props,null,p.mode,C),C.ref=sr(p,c,h),C.return=p,p=C)}return o(p);case ke:e:{for(V=h.key;c!==null;){if(c.key===V)if(c.tag===4&&c.stateNode.containerInfo===h.containerInfo&&c.stateNode.implementation===h.implementation){n(p,c.sibling),c=l(c,h.children||[]),c.return=p,p=c;break e}else{n(p,c);break}else t(p,c);c=c.sibling}c=No(h,p.mode,C),c.return=p,p=c}return o(p);case Fe:return V=h._init,Se(p,c,V(h._payload),C)}if(In(h))return O(p,c,h,C);if(P(h))return F(p,c,h,C);tl(p,h)}return typeof h=="string"&&h!==""||typeof h=="number"?(h=""+h,c!==null&&c.tag===6?(n(p,c.sibling),c=l(c,h),c.return=p,p=c):(n(p,c),c=So(h,p.mode,C),c.return=p,p=c),o(p)):n(p,c)}return Se}var Pn=us(!0),ss=us(!1),nl=At(null),rl=null,zn=null,Ti=null;function Ri(){Ti=zn=rl=null}function Mi(e){var t=nl.current;de(nl),e._currentValue=t}function Oi(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function Ln(e,t){rl=e,Ti=zn=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(qe=!0),e.firstContext=null)}function ut(e){var t=e._currentValue;if(Ti!==e)if(e={context:e,memoizedValue:t,next:null},zn===null){if(rl===null)throw Error(d(308));zn=e,rl.dependencies={lanes:0,firstContext:e}}else zn=zn.next=e;return t}var nn=null;function Di(e){nn===null?nn=[e]:nn.push(e)}function as(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,Di(t)):(n.next=l.next,l.next=n),t.interleaved=n,Pt(e,r)}function Pt(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var Vt=!1;function Fi(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function cs(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function zt(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function Ht(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,(ee&2)!==0){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,Pt(e,n)}return l=r.interleaved,l===null?(t.next=t,Di(r)):(t.next=l.next,l.next=t),r.interleaved=t,Pt(e,n)}function ll(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,ql(e,n)}}function ds(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,i=null;if(n=n.firstBaseUpdate,n!==null){do{var o={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};i===null?l=i=o:i=i.next=o,n=n.next}while(n!==null);i===null?l=i=t:i=i.next=t}else l=i=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:i,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function il(e,t,n,r){var l=e.updateQueue;Vt=!1;var i=l.firstBaseUpdate,o=l.lastBaseUpdate,u=l.shared.pending;if(u!==null){l.shared.pending=null;var a=u,v=a.next;a.next=null,o===null?i=v:o.next=v,o=a;var w=e.alternate;w!==null&&(w=w.updateQueue,u=w.lastBaseUpdate,u!==o&&(u===null?w.firstBaseUpdate=v:u.next=v,w.lastBaseUpdate=a))}if(i!==null){var N=l.baseState;o=0,w=v=a=null,u=i;do{var k=u.lane,z=u.eventTime;if((r&k)===k){w!==null&&(w=w.next={eventTime:z,lane:0,tag:u.tag,payload:u.payload,callback:u.callback,next:null});e:{var O=e,F=u;switch(k=t,z=n,F.tag){case 1:if(O=F.payload,typeof O=="function"){N=O.call(z,N,k);break e}N=O;break e;case 3:O.flags=O.flags&-65537|128;case 0:if(O=F.payload,k=typeof O=="function"?O.call(z,N,k):O,k==null)break e;N=_({},N,k);break e;case 2:Vt=!0}}u.callback!==null&&u.lane!==0&&(e.flags|=64,k=l.effects,k===null?l.effects=[u]:k.push(u))}else z={eventTime:z,lane:k,tag:u.tag,payload:u.payload,callback:u.callback,next:null},w===null?(v=w=z,a=N):w=w.next=z,o|=k;if(u=u.next,u===null){if(u=l.shared.pending,u===null)break;k=u,u=k.next,k.next=null,l.lastBaseUpdate=k,l.shared.pending=null}}while(!0);if(w===null&&(a=N),l.baseState=a,l.firstBaseUpdate=v,l.lastBaseUpdate=w,t=l.shared.interleaved,t!==null){l=t;do o|=l.lane,l=l.next;while(l!==t)}else i===null&&(l.shared.lanes=0);on|=o,e.lanes=o,e.memoizedState=N}}function fs(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=$i.transition;$i.transition={};try{e(!1),t()}finally{oe=n,$i.transition=r}}function Ts(){return st().memoizedState}function sd(e,t,n){var r=Xt(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},Rs(e))Ms(t,n);else if(n=as(e,t,n,r),n!==null){var l=We();gt(n,e,r,l),Os(n,t,r)}}function ad(e,t,n){var r=Xt(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(Rs(e))Ms(t,l);else{var i=e.alternate;if(e.lanes===0&&(i===null||i.lanes===0)&&(i=t.lastRenderedReducer,i!==null))try{var o=t.lastRenderedState,u=i(o,n);if(l.hasEagerState=!0,l.eagerState=u,ft(u,o)){var a=t.interleaved;a===null?(l.next=l,Di(t)):(l.next=a.next,a.next=l),t.interleaved=l;return}}catch{}finally{}n=as(e,t,l,r),n!==null&&(l=We(),gt(n,e,r,l),Os(n,t,r))}}function Rs(e){var t=e.alternate;return e===ve||t!==null&&t===ve}function Ms(e,t){fr=sl=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Os(e,t,n){if((n&4194240)!==0){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,ql(e,n)}}var dl={readContext:ut,useCallback:Ue,useContext:Ue,useEffect:Ue,useImperativeHandle:Ue,useInsertionEffect:Ue,useLayoutEffect:Ue,useMemo:Ue,useReducer:Ue,useRef:Ue,useState:Ue,useDebugValue:Ue,useDeferredValue:Ue,useTransition:Ue,useMutableSource:Ue,useSyncExternalStore:Ue,useId:Ue,unstable_isNewReconciler:!1},cd={readContext:ut,useCallback:function(e,t){return St().memoizedState=[e,t===void 0?null:t],e},useContext:ut,useEffect:Ns,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,al(4194308,4,Es.bind(null,t,e),n)},useLayoutEffect:function(e,t){return al(4194308,4,e,t)},useInsertionEffect:function(e,t){return al(4,2,e,t)},useMemo:function(e,t){var n=St();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=St();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=sd.bind(null,ve,e),[r.memoizedState,e]},useRef:function(e){var t=St();return e={current:e},t.memoizedState=e},useState:ws,useDebugValue:Gi,useDeferredValue:function(e){return St().memoizedState=e},useTransition:function(){var e=ws(!1),t=e[0];return e=ud.bind(null,e[1]),St().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=ve,l=St();if(me){if(n===void 0)throw Error(d(407));n=n()}else{if(n=t(),Te===null)throw Error(d(349));(ln&30)!==0||vs(r,t,n)}l.memoizedState=n;var i={value:n,getSnapshot:t};return l.queue=i,Ns(ys.bind(null,r,i,e),[e]),r.flags|=2048,hr(9,gs.bind(null,r,i,n,t),void 0,null),n},useId:function(){var e=St(),t=Te.identifierPrefix;if(me){var n=jt,r=Et;n=(r&~(1<<32-dt(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=pr++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=o.createElement(n,{is:r.is}):(e=o.createElement(n),n==="select"&&(o=e,r.multiple?o.multiple=!0:r.size&&(o.size=r.size))):e=o.createElementNS(e,n),e[kt]=t,e[or]=r,ea(e,t,!1,!1),t.stateNode=e;e:{switch(o=Bl(n,r),n){case"dialog":ce("cancel",e),ce("close",e),l=r;break;case"iframe":case"object":case"embed":ce("load",e),l=r;break;case"video":case"audio":for(l=0;lDn&&(t.flags|=128,r=!0,vr(i,!1),t.lanes=4194304)}else{if(!r)if(e=ol(o),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),vr(i,!0),i.tail===null&&i.tailMode==="hidden"&&!o.alternate&&!me)return Ae(t),null}else 2*we()-i.renderingStartTime>Dn&&n!==1073741824&&(t.flags|=128,r=!0,vr(i,!1),t.lanes=4194304);i.isBackwards?(o.sibling=t.child,t.child=o):(n=i.last,n!==null?n.sibling=o:t.child=o,i.last=o)}return i.tail!==null?(t=i.tail,i.rendering=t,i.tail=t.sibling,i.renderingStartTime=we(),t.sibling=null,n=he.current,ae(he,r?n&1|2:n&1),t):(Ae(t),null);case 22:case 23:return xo(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&(t.mode&1)!==0?(lt&1073741824)!==0&&(Ae(t),t.subtreeFlags&6&&(t.flags|=8192)):Ae(t),null;case 24:return null;case 25:return null}throw Error(d(156,t.tag))}function yd(e,t){switch(ji(t),t.tag){case 1:return Ye(t.type)&&Yr(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Tn(),de(Ge),de(Ie),Bi(),e=t.flags,(e&65536)!==0&&(e&128)===0?(t.flags=e&-65537|128,t):null;case 5:return Ui(t),null;case 13:if(de(he),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(d(340));jn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return de(he),null;case 4:return Tn(),null;case 10:return Mi(t.type._context),null;case 22:case 23:return xo(),null;case 24:return null;default:return null}}var hl=!1,Be=!1,xd=typeof WeakSet=="function"?WeakSet:Set,T=null;function Mn(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){xe(e,t,r)}else n.current=null}function oo(e,t,n){try{n()}catch(r){xe(e,t,r)}}var ra=!1;function kd(e,t){if(yi=Dr,e=Du(),ci(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,i=r.focusNode;r=r.focusOffset;try{n.nodeType,i.nodeType}catch{n=null;break e}var o=0,u=-1,a=-1,v=0,w=0,N=e,k=null;t:for(;;){for(var z;N!==n||l!==0&&N.nodeType!==3||(u=o+l),N!==i||r!==0&&N.nodeType!==3||(a=o+r),N.nodeType===3&&(o+=N.nodeValue.length),(z=N.firstChild)!==null;)k=N,N=z;for(;;){if(N===e)break t;if(k===n&&++v===l&&(u=o),k===i&&++w===r&&(a=o),(z=N.nextSibling)!==null)break;N=k,k=N.parentNode}N=z}n=u===-1||a===-1?null:{start:u,end:a}}else n=null}n=n||{start:0,end:0}}else n=null;for(xi={focusedElem:e,selectionRange:n},Dr=!1,T=t;T!==null;)if(t=T,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,T=e;else for(;T!==null;){t=T;try{var O=t.alternate;if((t.flags&1024)!==0)switch(t.tag){case 0:case 11:case 15:break;case 1:if(O!==null){var F=O.memoizedProps,Se=O.memoizedState,p=t.stateNode,c=p.getSnapshotBeforeUpdate(t.elementType===t.type?F:mt(t.type,F),Se);p.__reactInternalSnapshotBeforeUpdate=c}break;case 3:var h=t.stateNode.containerInfo;h.nodeType===1?h.textContent="":h.nodeType===9&&h.documentElement&&h.removeChild(h.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(d(163))}}catch(C){xe(t,t.return,C)}if(e=t.sibling,e!==null){e.return=t.return,T=e;break}T=t.return}return O=ra,ra=!1,O}function gr(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var i=l.destroy;l.destroy=void 0,i!==void 0&&oo(t,n,i)}l=l.next}while(l!==r)}}function vl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function uo(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function la(e){var t=e.alternate;t!==null&&(e.alternate=null,la(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[kt],delete t[or],delete t[Ni],delete t[nd],delete t[rd])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function ia(e){return e.tag===5||e.tag===3||e.tag===4}function oa(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||ia(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function so(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Xr));else if(r!==4&&(e=e.child,e!==null))for(so(e,t,n),e=e.sibling;e!==null;)so(e,t,n),e=e.sibling}function ao(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(ao(e,t,n),e=e.sibling;e!==null;)ao(e,t,n),e=e.sibling}var Me=null,ht=!1;function Qt(e,t,n){for(n=n.child;n!==null;)ua(e,t,n),n=n.sibling}function ua(e,t,n){if(xt&&typeof xt.onCommitFiberUnmount=="function")try{xt.onCommitFiberUnmount(zr,n)}catch{}switch(n.tag){case 5:Be||Mn(n,t);case 6:var r=Me,l=ht;Me=null,Qt(e,t,n),Me=r,ht=l,Me!==null&&(ht?(e=Me,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):Me.removeChild(n.stateNode));break;case 18:Me!==null&&(ht?(e=Me,n=n.stateNode,e.nodeType===8?Si(e.parentNode,n):e.nodeType===1&&Si(e,n),Yn(e)):Si(Me,n.stateNode));break;case 4:r=Me,l=ht,Me=n.stateNode.containerInfo,ht=!0,Qt(e,t,n),Me=r,ht=l;break;case 0:case 11:case 14:case 15:if(!Be&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var i=l,o=i.destroy;i=i.tag,o!==void 0&&((i&2)!==0||(i&4)!==0)&&oo(n,t,o),l=l.next}while(l!==r)}Qt(e,t,n);break;case 1:if(!Be&&(Mn(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(u){xe(n,t,u)}Qt(e,t,n);break;case 21:Qt(e,t,n);break;case 22:n.mode&1?(Be=(r=Be)||n.memoizedState!==null,Qt(e,t,n),Be=r):Qt(e,t,n);break;default:Qt(e,t,n)}}function sa(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new xd),t.forEach(function(r){var l=zd.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function vt(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=o),r&=~i}if(r=l,r=we()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*Sd(r/1960))-r,10e?16:e,Kt===null)var r=!1;else{if(e=Kt,Kt=null,wl=0,(ee&6)!==0)throw Error(d(331));var l=ee;for(ee|=4,T=e.current;T!==null;){var i=T,o=i.child;if((T.flags&16)!==0){var u=i.deletions;if(u!==null){for(var a=0;awe()-po?sn(e,0):fo|=n),Ze(e,t)}function wa(e,t){t===0&&((e.mode&1)===0?t=1:(t=Tr,Tr<<=1,(Tr&130023424)===0&&(Tr=4194304)));var n=We();e=Pt(e,t),e!==null&&(Qn(e,t,n),Ze(e,n))}function Pd(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),wa(e,n)}function zd(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(d(314))}r!==null&&r.delete(t),wa(e,n)}var Sa;Sa=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||Ge.current)qe=!0;else{if((e.lanes&n)===0&&(t.flags&128)===0)return qe=!1,vd(e,t,n);qe=(e.flags&131072)!==0}else qe=!1,me&&(t.flags&1048576)!==0&&ts(t,br,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;ml(e,t),e=t.pendingProps;var l=_n(t,Ie.current);Ln(t,n),l=Hi(null,t,r,e,l,n);var i=Qi();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,Ye(r)?(i=!0,qr(t)):i=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,Fi(t),l.updater=fl,t.stateNode=l,l._reactInternals=t,qi(t,r,e,n),t=eo(null,t,r,!0,i,n)):(t.tag=0,me&&i&&Ei(t),Qe(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(ml(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=Td(r),e=mt(r,e),l){case 0:t=bi(null,t,r,e,n);break e;case 1:t=Gs(null,t,r,e,n);break e;case 11:t=Hs(null,t,r,e,n);break e;case 14:t=Qs(null,t,r,mt(r.type,e),n);break e}throw Error(d(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:mt(r,l),bi(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:mt(r,l),Gs(e,t,r,l,n);case 3:e:{if(Ys(t),e===null)throw Error(d(387));r=t.pendingProps,i=t.memoizedState,l=i.element,cs(e,t),il(t,r,null,n);var o=t.memoizedState;if(r=o.element,i.isDehydrated)if(i={element:r,isDehydrated:!1,cache:o.cache,pendingSuspenseBoundaries:o.pendingSuspenseBoundaries,transitions:o.transitions},t.updateQueue.baseState=i,t.memoizedState=i,t.flags&256){l=Rn(Error(d(423)),t),t=qs(e,t,r,n,l);break e}else if(r!==l){l=Rn(Error(d(424)),t),t=qs(e,t,r,n,l);break e}else for(rt=Ut(t.stateNode.containerInfo.firstChild),nt=t,me=!0,pt=null,n=ss(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(jn(),r===l){t=Lt(e,t,n);break e}Qe(e,t,r,n)}t=t.child}return t;case 5:return ps(t),e===null&&zi(t),r=t.type,l=t.pendingProps,i=e!==null?e.memoizedProps:null,o=l.children,ki(r,l)?o=null:i!==null&&ki(r,i)&&(t.flags|=32),Xs(e,t),Qe(e,t,o,n),t.child;case 6:return e===null&&zi(t),null;case 13:return Js(e,t,n);case 4:return Ii(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=Pn(t,null,r,n):Qe(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:mt(r,l),Hs(e,t,r,l,n);case 7:return Qe(e,t,t.pendingProps,n),t.child;case 8:return Qe(e,t,t.pendingProps.children,n),t.child;case 12:return Qe(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,i=t.memoizedProps,o=l.value,ae(nl,r._currentValue),r._currentValue=o,i!==null)if(ft(i.value,o)){if(i.children===l.children&&!Ge.current){t=Lt(e,t,n);break e}}else for(i=t.child,i!==null&&(i.return=t);i!==null;){var u=i.dependencies;if(u!==null){o=i.child;for(var a=u.firstContext;a!==null;){if(a.context===r){if(i.tag===1){a=zt(-1,n&-n),a.tag=2;var v=i.updateQueue;if(v!==null){v=v.shared;var w=v.pending;w===null?a.next=a:(a.next=w.next,w.next=a),v.pending=a}}i.lanes|=n,a=i.alternate,a!==null&&(a.lanes|=n),Oi(i.return,n,t),u.lanes|=n;break}a=a.next}}else if(i.tag===10)o=i.type===t.type?null:i.child;else if(i.tag===18){if(o=i.return,o===null)throw Error(d(341));o.lanes|=n,u=o.alternate,u!==null&&(u.lanes|=n),Oi(o,n,t),o=i.sibling}else o=i.child;if(o!==null)o.return=i;else for(o=i;o!==null;){if(o===t){o=null;break}if(i=o.sibling,i!==null){i.return=o.return,o=i;break}o=o.return}i=o}Qe(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,Ln(t,n),l=ut(l),r=r(l),t.flags|=1,Qe(e,t,r,n),t.child;case 14:return r=t.type,l=mt(r,t.pendingProps),l=mt(r.type,l),Qs(e,t,r,l,n);case 15:return Ws(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:mt(r,l),ml(e,t),t.tag=1,Ye(r)?(e=!0,qr(t)):e=!1,Ln(t,n),Fs(t,r,l),qi(t,r,l,n),eo(null,t,r,!0,e,n);case 19:return bs(e,t,n);case 22:return Ks(e,t,n)}throw Error(d(156,t.tag))};function Na(e,t){return nu(e,t)}function Ld(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function ct(e,t,n,r){return new Ld(e,t,n,r)}function wo(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Td(e){if(typeof e=="function")return wo(e)?1:0;if(e!=null){if(e=e.$$typeof,e===ze)return 11;if(e===Xe)return 14}return 2}function Yt(e,t){var n=e.alternate;return n===null?(n=ct(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Cl(e,t,n,r,l,i){var o=2;if(r=e,typeof e=="function")wo(e)&&(o=1);else if(typeof e=="string")o=5;else e:switch(e){case ye:return cn(n.children,l,i,t);case A:o=8,l|=8;break;case le:return e=ct(12,n,t,l|2),e.elementType=le,e.lanes=i,e;case He:return e=ct(13,n,t,l),e.elementType=He,e.lanes=i,e;case Ne:return e=ct(19,n,t,l),e.elementType=Ne,e.lanes=i,e;case x:return El(n,l,i,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case fe:o=10;break e;case Ke:o=9;break e;case ze:o=11;break e;case Xe:o=14;break e;case Fe:o=16,r=null;break e}throw Error(d(130,e==null?e:typeof e,""))}return t=ct(o,n,t,l),t.elementType=e,t.type=r,t.lanes=i,t}function cn(e,t,n,r){return e=ct(7,e,r,t),e.lanes=n,e}function El(e,t,n,r){return e=ct(22,e,r,t),e.elementType=x,e.lanes=n,e.stateNode={isHidden:!1},e}function So(e,t,n){return e=ct(6,e,null,t),e.lanes=n,e}function No(e,t,n){return t=ct(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Rd(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Yl(0),this.expirationTimes=Yl(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Yl(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function _o(e,t,n,r,l,i,o,u,a){return e=new Rd(e,t,n,u,a),t===1?(t=1,i===!0&&(t|=8)):t=0,i=ct(3,null,null,t),e.current=i,i.stateNode=e,i.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Fi(i),e}function Md(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(m)}catch(S){console.error(S)}}return m(),Lo.exports=Kd(),Lo.exports}var Ia;function Gd(){if(Ia)return Ml;Ia=1;var m=Xd();return Ml.createRoot=m.createRoot,Ml.hydrateRoot=m.hydrateRoot,Ml}var Yd=Gd();async function je(m,S){const d=await fetch(m,S);if(!d.ok)throw new Error(`${d.status} ${await d.text()}`);return(d.headers.get("content-type")||"").includes("application/json")?d.json():d}const yt=(m,S)=>({method:m,headers:{"Content-Type":"application/json"},body:S?JSON.stringify(S):void 0}),ue={listBooks:()=>je("/api/books"),uploadBook:m=>{const S=new FormData;return S.append("file",m),je("/api/books",{method:"POST",body:S})},getBook:m=>je(`/api/books/${m}`),getChapter:(m,S)=>je(`/api/books/${m}/chapters/${S}`),putAnalysis:(m,S,d)=>je(`/api/books/${m}/chapters/${S}/analysis`,yt("PUT",d)),analyze:(m,S)=>je(`/api/books/${m}/analyze`,yt("POST",{chapters:S})),pronounce:m=>je(`/api/books/${m}/pronounce`,yt("POST")),castAuto:m=>je(`/api/books/${m}/cast/auto`,yt("POST")),castAnalyze:(m,S)=>je(`/api/books/${m}/cast/analyze`,yt("POST",{chapters:S})),castDedup:m=>je(`/api/books/${m}/cast/dedup`,yt("POST")),render:(m,S,d,L)=>je(`/api/books/${m}/render`,yt("POST",{chapters:S,backend:d,mono:L})),getCast:m=>je(`/api/books/${m}/cast`),putCast:(m,S)=>je(`/api/books/${m}/cast`,yt("PUT",S)),getUnresolvedSpeakers:m=>je(`/api/books/${m}/cast/unresolved`),getPron:m=>je(`/api/books/${m}/pronunciation`),putPron:(m,S)=>je(`/api/books/${m}/pronunciation`,yt("PUT",S)),getSettings:()=>je("/api/settings"),putSettings:m=>je("/api/settings",yt("PUT",m)),listLmStudioModels:()=>je("/api/lmstudio/models"),audioUrl:(m,S)=>`/api/books/${m}/audio/${S}`,coverUrl:m=>`/api/books/${m}/cover`,previewVoice:async(m,S)=>{const d=await fetch("/api/voicebank/preview",yt("POST",{voice_id:m,text:S}));if(!d.ok)throw new Error("preview");return URL.createObjectURL(await d.blob())}};function qd(m,S){let d,L=!1;const E=()=>{const B=location.protocol==="https:"?"wss":"ws";d=new WebSocket(`${B}://${location.host}/ws/${m}`),d.onmessage=R=>{const G=JSON.parse(R.data);G.type==="state"&&S(G.state)},d.onclose=()=>{L||setTimeout(E,1500)}};return E(),()=>{L=!0,d&&d.close()}}const Ua={done:"bg-emerald-900/50 text-emerald-300",running:"bg-ink-accent/20 text-ink-accent",error:"bg-red-900/50 text-red-300",pending:"bg-ink-edge text-ink-muted"},Jd={done:"terminé",running:"en cours",error:"erreur",pending:"en attente"};function $a({status:m}){return s.jsx("span",{className:`chip ${Ua[m]||Ua.pending}`,children:Jd[m]||m})}function Va({value:m}){return s.jsx("div",{className:"h-1.5 w-full overflow-hidden rounded-full bg-ink-edge",children:s.jsx("div",{className:"h-full bg-ink-accent transition-all duration-300",style:{width:`${Math.round((m||0)*100)}%`}})})}function dn(){return s.jsx("span",{className:"inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-ink-accent border-t-transparent"})}function Zd({onOpen:m}){const[S,d]=W.useState(null),[L,E]=W.useState(!1),[B,R]=W.useState(null),G=W.useRef(),Q=()=>ue.listBooks().then(d).catch(j=>R(String(j)));W.useEffect(()=>{Q()},[]);const M=async j=>{if(j){E(!0),R(null);try{const{slug:I}=await ue.uploadBook(j);await Q(),m(I)}catch(I){R("Échec de l'import : "+I)}finally{E(!1)}}};return s.jsxs("div",{className:"space-y-8",children:[s.jsxs("section",{onDragOver:j=>j.preventDefault(),onDrop:j=>{j.preventDefault(),M(j.dataTransfer.files[0])},className:"card flex flex-col items-center justify-center gap-3 border-dashed py-12 text-center",children:[s.jsx("div",{className:"text-4xl",children:"📖"}),s.jsx("p",{className:"font-serif text-lg",children:"Déposez un fichier EPUB"}),s.jsx("p",{className:"text-sm text-ink-muted",children:"ou"}),s.jsxs("button",{className:"btn-primary",disabled:L,onClick:()=>{var j;return(j=G.current)==null?void 0:j.click()},children:[L?s.jsx(dn,{}):null,L?"Import en cours…":"Choisir un fichier"]}),s.jsx("input",{ref:G,type:"file",accept:".epub",className:"hidden",onChange:j=>M(j.target.files[0])})]}),B&&s.jsx("p",{className:"text-sm text-red-400",children:B}),s.jsxs("section",{children:[s.jsx("h2",{className:"mb-3 font-serif text-lg text-ink-muted",children:"Bibliothèque"}),S===null?s.jsxs("p",{className:"text-ink-muted",children:[s.jsx(dn,{})," chargement…"]}):S.length===0?s.jsx("p",{className:"text-ink-muted",children:"Aucun livre pour l'instant."}):s.jsx("div",{className:"grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4",children:S.map(j=>s.jsxs("button",{onClick:()=>m(j.slug),className:"card group overflow-hidden text-left transition-transform hover:-translate-y-1",children:[s.jsx("div",{className:"aspect-[2/3] w-full bg-ink-edge",children:j.cover&&s.jsx("img",{src:j.cover,alt:"",className:"h-full w-full object-cover"})}),s.jsxs("div",{className:"p-3",children:[s.jsx("p",{className:"line-clamp-2 font-serif text-sm",children:j.title}),s.jsx("p",{className:"mt-1 text-xs text-ink-muted",children:j.author}),s.jsxs("p",{className:"mt-2 text-xs text-ink-accent",children:[j.rendered,"/",j.chapters," chapitres rendus"]})]})]},j.slug))})]})]})}function bd({slug:m,book:S,state:d,busy:L}){const E=S.chapters.filter(D=>D.render),[B,R]=W.useState("kokoro"),[G,Q]=W.useState(!1),[M,j]=W.useState(()=>new Set);W.useEffect(()=>{ue.getSettings().then(D=>(D==null?void 0:D.default_backend)&&R(D.default_backend)).catch(()=>{})},[]);const I=D=>{const Z=new Set(M);Z.has(D)?Z.delete(D):Z.add(D),j(Z)},$=D=>{D.length&&ue.render(m,D,B,G)};return s.jsxs("div",{className:"space-y-4",children:[s.jsxs("div",{className:"card flex flex-wrap items-center gap-3 p-3",children:[s.jsx("label",{className:"text-sm text-ink-muted",children:"Moteur"}),s.jsxs("select",{className:"input",value:B,onChange:D=>R(D.target.value),children:[s.jsx("option",{value:"kokoro",children:"Kokoro (rapide)"}),s.jsx("option",{value:"qwen3",children:"Qwen3 (qualité + clonage)"})]}),s.jsxs("label",{className:"flex items-center gap-2 text-sm text-ink-muted",children:[s.jsx("input",{type:"checkbox",checked:G,onChange:D=>Q(D.target.checked)}),"mono-narrateur"]}),s.jsxs("div",{className:"ml-auto flex gap-2",children:[s.jsxs("button",{className:"btn-ghost",disabled:L||!M.size,onClick:()=>$([...M]),children:["Rendre la sélection (",M.size,")"]}),s.jsx("button",{className:"btn-primary",disabled:L,onClick:()=>$(E.map(D=>D.index)),children:"Rendre tout"})]})]}),s.jsx("div",{className:"card divide-y divide-ink-edge",children:E.map(D=>{var Y,De;const Z=((Y=d.render)==null?void 0:Y[D.index])||((De=d.render)==null?void 0:De[String(D.index)])||{},b=(d.analyzed_chapters||[]).includes(D.index);return s.jsxs("div",{className:"flex items-center gap-3 px-4 py-2.5",children:[s.jsx("input",{type:"checkbox",checked:M.has(D.index),onChange:()=>I(D.index)}),s.jsx("div",{className:"w-9 text-center text-xs text-ink-muted",children:D.index}),s.jsxs("div",{className:"flex-1 min-w-0",children:[s.jsx("p",{className:"truncate font-serif text-sm",children:D.title}),s.jsxs("div",{className:"mt-0.5 flex items-center gap-2 text-xs text-ink-muted",children:[s.jsxs("span",{children:[D.word_count," mots"]}),D.pov&&s.jsx("span",{className:"chip bg-ink-edge text-ink-muted",children:D.pov}),b&&s.jsx("span",{className:"text-emerald-400",children:"analysé"})]}),Z.status==="running"&&s.jsx("div",{className:"mt-1.5 max-w-xs",children:s.jsx(Va,{value:Z.progress})})]}),Z.status&&s.jsx($a,{status:Z.status}),Z.mp3&&s.jsxs(s.Fragment,{children:[s.jsx("audio",{controls:!0,src:ue.audioUrl(m,D.index),className:"h-8"}),s.jsx("a",{className:"btn-ghost",href:ue.audioUrl(m,D.index),download:!0,children:"↓"})]}),!L&&s.jsxs(s.Fragment,{children:[s.jsx("button",{className:"btn-ghost",title:b?"Ré-analyser ce chapitre":"Analyser ce chapitre",onClick:()=>ue.analyze(m,[D.index]),children:b?"Ré-analyser":"Analyser"}),s.jsx("button",{className:"btn-ghost",title:"Ré-analyser le casting de ce chapitre (sans re-segmenter)",onClick:()=>ue.castAnalyze(m,[D.index]),children:"Casting"}),s.jsx("button",{className:"btn-ghost",title:"Rendre ce chapitre",onClick:()=>$([D.index]),children:"▶"})]})]},D.index)})})]})}const Mo="narrateur";let ef=0;const Aa=()=>++ef;function tf({slug:m,book:S,state:d}){const L=W.useMemo(()=>{const x=new Set(d.analyzed_chapters||[]);return S.chapters.filter(y=>x.has(y.index))},[S,d.analyzed_chapters]),[E,B]=W.useState(()=>{var x;return((x=L[0])==null?void 0:x.index)??null}),[R,G]=W.useState(null),[Q,M]=W.useState([]),[j,I]=W.useState(!1),[$,D]=W.useState(!1),[Z,b]=W.useState({id:null,start:0,end:0}),[Y,De]=W.useState(""),[Pe,$e]=W.useState("all"),[ge,Ve]=W.useState("all");W.useEffect(()=>{var x;(E==null||!L.some(y=>y.index===E))&&B(((x=L[0])==null?void 0:x.index)??null)},[L]),W.useEffect(()=>{ue.getCast(m).then(x=>{var y;return M((((y=x.cast)==null?void 0:y.characters)||[]).map(P=>P.name))}).catch(()=>M([]))},[m]),W.useEffect(()=>{if(E==null){G(null);return}I(!0),D(!1),ue.getChapter(m,E).then(x=>{var y;x.analysis?G({index:x.analysis.index,title:x.analysis.title,segments:(x.analysis.segments||[]).map(P=>({...P,_id:Aa()}))}):G({index:E,title:((y=x.chapter)==null?void 0:y.title)||"",segments:null})}).finally(()=>I(!1))},[m,E]);const ke=W.useMemo(()=>{const x=new Set([Mo,...Q]);return((R==null?void 0:R.segments)||[]).forEach(y=>y.speaker&&x.add(y.speaker)),[...x]},[Q,R]);if(!L.length)return s.jsxs("p",{className:"text-ink-muted",children:["Lancez d'abord l'",s.jsx("b",{children:"Analyse"})," sur un chapitre."]});const ye=x=>{G(y=>({...y,segments:x})),D(!1)},A=(x,y)=>ye(R.segments.map(P=>{if(P._id!==x)return P;const _={...P,...y};if(_.type==="narration"&&(_.speaker=Mo,_.incises=[]),y.text!==void 0){const f=_.text.length;_.incises=(_.incises||[]).filter(g=>g.startye(R.segments.map(_=>{if(_._id!==x)return _;const f=[..._.incises||[],{start:y,end:P}].sort((g,X)=>g.start-X.start).filter((g,X,q)=>X===0||g.start>=q[X-1].end);return{..._,incises:f}})),fe=(x,y)=>ye(R.segments.map(P=>P._id!==x?P:{...P,incises:(P.incises||[]).filter((_,f)=>f!==y)})),Ke=x=>ye(R.segments.filter(y=>y._id!==x)),ze=x=>{const y=R.segments,P=x==null?y.length:y.findIndex(f=>f._id===x)+1,_=[...y];_.splice(P,0,{_id:Aa(),type:"narration",text:"",speaker:Mo}),ye(_)},He=async()=>{const x={index:R.index,title:R.title,segments:R.segments.map(({_id:y,...P})=>P)};await ue.putAnalysis(m,R.index,x),D(!0)},Ne=R==null?void 0:R.segments,Xe=(Ne||[]).filter(x=>!(Pe!=="all"&&x.type!==Pe||ge!=="all"&&x.speaker!==ge||Y&&!x.text.toLowerCase().includes(Y.toLowerCase()))),Fe=(Ne||[]).filter(x=>x.type==="dialogue").length;return s.jsxs("div",{className:"space-y-4",children:[s.jsx("datalist",{id:"speaker-list",children:ke.map(x=>s.jsx("option",{value:x},x))}),s.jsxs("div",{className:"card flex flex-wrap items-center gap-3 p-3",children:[s.jsx("label",{className:"text-sm text-ink-muted",children:"Chapitre"}),s.jsx("select",{className:"input",value:E??"",onChange:x=>B(Number(x.target.value)),children:L.map(x=>s.jsxs("option",{value:x.index,children:[x.index," — ",x.title]},x.index))}),Ne&&s.jsxs("span",{className:"text-xs text-ink-muted",children:[Ne.length," segments · ",Fe," dialogues"]}),s.jsx("button",{className:"btn-primary ml-auto",disabled:!Ne,onClick:He,children:$?"✓ enregistré":"Enregistrer"})]}),j&&s.jsxs("p",{className:"text-ink-muted",children:[s.jsx(dn,{})," chargement de l'analyse…"]}),!j&&Ne===null&&s.jsxs("p",{className:"text-ink-muted",children:["Ce chapitre n'a pas encore d'analyse. Lancez l'",s.jsx("b",{children:"Analyse"}),"."]}),!j&&Ne&&s.jsxs(s.Fragment,{children:[s.jsxs("div",{className:"card flex flex-wrap items-center gap-3 p-3",children:[s.jsx("input",{className:"input flex-1 min-w-[12rem]",placeholder:"Rechercher dans le texte…",value:Y,onChange:x=>De(x.target.value)}),s.jsxs("select",{className:"input",value:Pe,onChange:x=>$e(x.target.value),children:[s.jsx("option",{value:"all",children:"tous types"}),s.jsx("option",{value:"narration",children:"narration"}),s.jsx("option",{value:"dialogue",children:"dialogue"})]}),s.jsxs("select",{className:"input",value:ge,onChange:x=>Ve(x.target.value),children:[s.jsx("option",{value:"all",children:"tous locuteurs"}),ke.map(x=>s.jsx("option",{value:x,children:x},x))]}),Xe.length!==Ne.length&&s.jsxs("span",{className:"text-xs text-ink-muted",children:[Xe.length," affichés"]})]}),s.jsxs("div",{className:"card divide-y divide-ink-edge",children:[Xe.map(x=>{const y=x.type==="dialogue"&&Z.id===x._id&&Z.end>Z.start,P=x.incises||[];return s.jsxs("div",{className:"px-4 py-2.5",children:[s.jsxs("div",{className:"flex items-start gap-3",children:[s.jsxs("select",{className:"input w-28 shrink-0",value:x.type,onChange:_=>A(x._id,{type:_.target.value}),children:[s.jsx("option",{value:"narration",children:"narration"}),s.jsx("option",{value:"dialogue",children:"dialogue"})]}),s.jsx("textarea",{className:"input flex-1 min-h-[2.5rem] resize-y font-serif text-sm",rows:Math.min(6,Math.ceil((x.text.length||1)/80)),value:x.text,onSelect:_=>x.type==="dialogue"&&b({id:x._id,start:_.target.selectionStart,end:_.target.selectionEnd}),onChange:_=>A(x._id,{text:_.target.value})}),s.jsx("input",{className:"input w-40 shrink-0",list:"speaker-list",placeholder:"locuteur",value:x.speaker,disabled:x.type==="narration",onChange:_=>A(x._id,{speaker:_.target.value})}),s.jsxs("div",{className:"flex shrink-0 gap-1",children:[s.jsx("button",{className:"btn-ghost",title:"Insérer après",onClick:()=>ze(x._id),children:"+"}),s.jsx("button",{className:"btn-ghost",title:"Supprimer",onClick:()=>Ke(x._id),children:"✕"})]})]}),x.type==="dialogue"&&(P.length>0||y)&&s.jsxs("div",{className:"mt-1.5 ml-[7.75rem] flex flex-wrap items-center gap-1.5",children:[s.jsx("span",{className:"text-[11px] uppercase tracking-wide text-ink-muted",children:"incises"}),P.map((_,f)=>s.jsxs("span",{className:"inline-flex items-center gap-1 rounded bg-ink-edge/40 px-1.5 py-0.5 text-xs",title:"Lu par la voix du narrateur",children:[s.jsx("span",{className:"text-ink-muted",children:"🎙"}),s.jsx("span",{className:"font-serif",children:x.text.slice(_.start,_.end)}),s.jsx("button",{className:"text-ink-muted hover:text-ink",title:"Retirer l'incise",onClick:()=>fe(x._id,f),children:"✕"})]},f)),y&&s.jsx("button",{className:"btn-ghost text-xs",onClick:()=>{le(x._id,Z.start,Z.end),b({id:null,start:0,end:0})},children:"+ marquer la sélection"})]})]},x._id)}),s.jsx("div",{className:"px-4 py-2.5",children:s.jsx("button",{className:"btn-ghost",onClick:()=>ze(null),children:"+ ajouter un segment"})})]})]})]})}function Ba({voices:m,value:S,onChange:d}){return s.jsxs("select",{className:"input",value:S||"",onChange:L=>d(L.target.value),children:[s.jsx("option",{value:"",children:"— aucune —"}),m.map(L=>s.jsxs("option",{value:L.id,children:[L.label||L.id," (",L.gender==="male"?"H":L.gender==="female"?"F":"?",")"]},L.id))]})}function nf({slug:m,busy:S}){const[d,L]=W.useState(null),[E,B]=W.useState([]),[R,G]=W.useState([]),[Q,M]=W.useState(!1),[j,I]=W.useState(null),[$,D]=W.useState(null),Z=Hd.useRef(!1),b=()=>ue.getCast(m).then(A=>{L(A.cast),B(A.voicebank.entries)}),Y=()=>ue.getUnresolvedSpeakers(m).then(A=>G(A.unresolved||[])).catch(()=>{});W.useEffect(()=>{b(),Y()},[m]),W.useEffect(()=>{S||b().then(()=>{Y(),Z.current&&(Z.current=!1,ue.getCast(m).then(A=>D(`✓ déduplication terminée — ${A.cast.characters.length} personnages`)))})},[S]);const De=async()=>{D(null);try{Z.current=!0,await ue.castDedup(m),D("Déduplication lancée…")}catch(A){Z.current=!1,D("Échec : "+A+" (le serveur backend est-il à jour ? redémarre-le)")}};if(!d)return s.jsxs("p",{className:"text-ink-muted",children:[s.jsx(dn,{})," chargement du casting…"]});if(!d.characters.length)return s.jsxs("p",{className:"text-ink-muted",children:["Lancez d'abord l'",s.jsx("b",{children:"Analyse"})," puis le ",s.jsx("b",{children:"Casting"}),"."]});const Pe=A=>{L({...d,...A}),M(!1)},$e=(A,le)=>Pe({characters:d.characters.map(fe=>fe.name===A?{...fe,voice_id:le}:fe)}),ge=(A,le)=>Pe({characters:d.characters.map(fe=>fe.name===A?{...fe,aliases:le.split(",").map(Ke=>Ke.trim()).filter(Boolean)}:fe)}),Ve=async(A,le)=>{if(!le)return;const fe=d.characters.map(ze=>ze.name===le?{...ze,aliases:[...ze.aliases||[],A]}:ze),Ke={...d,characters:fe};L(Ke),M(!0),await ue.putCast(m,Ke),Y()},ke=async A=>{if(A){I(A);try{const le=await ue.previewVoice(A,"Bonjour, voici un aperçu de cette voix."),fe=new Audio(le);fe.onended=()=>I(null),fe.play()}catch{I(null)}}},ye=async()=>{await ue.putCast(m,d),M(!0),Y()};return s.jsxs("div",{className:"space-y-4",children:[s.jsxs("div",{className:"card flex items-center gap-3 p-3",children:[s.jsx("span",{className:"text-sm text-ink-muted",children:"Narrateur"}),s.jsx(Ba,{voices:E,value:d.narrator_voice_id,onChange:A=>Pe({narrator_voice_id:A})}),s.jsxs("button",{className:"btn-ghost",onClick:()=>ke(d.narrator_voice_id),children:[j===d.narrator_voice_id?"♪":"▶"," écouter"]}),s.jsx("button",{className:"btn-ghost ml-auto",disabled:S,title:"Fusionne les variantes d'un même personnage (Holden / James Holden / James)",onClick:De,children:S?"…":"Dédupliquer"}),s.jsx("button",{className:"btn-primary",onClick:ye,children:Q?"✓ enregistré":"Enregistrer"})]}),$&&s.jsx("p",{className:"px-1 text-sm text-ink-muted",children:$}),s.jsx("div",{className:"card divide-y divide-ink-edge",children:d.characters.map(A=>s.jsxs("div",{className:"flex items-center gap-3 px-4 py-2.5",children:[s.jsxs("div",{className:"flex-1 min-w-0",children:[s.jsx("p",{className:"truncate font-serif text-sm",children:A.name}),s.jsx("input",{className:"input mt-1 w-full text-xs",placeholder:"alias (séparés par des virgules)",value:(A.aliases||[]).join(", "),onChange:le=>ge(A.name,le.target.value)}),A.description&&s.jsx("p",{className:"truncate text-xs text-ink-muted",children:A.description})]}),s.jsx("span",{className:"chip bg-ink-edge text-ink-muted",children:A.gender==="male"?"homme":A.gender==="female"?"femme":"?"}),s.jsx(Ba,{voices:E,value:A.voice_id,onChange:le=>$e(A.name,le)}),s.jsx("button",{className:"btn-ghost",onClick:()=>ke(A.voice_id),children:j===A.voice_id?"♪":"▶"})]},A.name))}),R.length>0&&s.jsxs("div",{className:"card p-4 space-y-2",children:[s.jsxs("p",{className:"font-serif text-sm",children:["Locuteurs non rattachés ",s.jsxs("span",{className:"text-ink-muted",children:["(",R.length,")"]})]}),s.jsx("p",{className:"text-xs text-ink-muted",children:"Ces noms apparaissent dans l'analyse mais ne correspondent à aucun personnage (ils seraient lus par la voix du narrateur). Rattachez-les comme alias."}),R.map(A=>s.jsxs("div",{className:"flex items-center gap-3 text-sm",children:[s.jsxs("span",{className:"flex-1 min-w-0 truncate",children:[A.speaker," ",s.jsxs("span",{className:"text-ink-muted",children:["×",A.count]})]}),s.jsxs("select",{className:"input",defaultValue:"",onChange:le=>Ve(A.speaker,le.target.value),children:[s.jsx("option",{value:"",children:"— rattacher à… —"}),d.characters.map(le=>s.jsx("option",{value:le.name,children:le.name},le.name))]})]},A.speaker))]})]})}function rf({slug:m}){const[S,d]=W.useState(null),[L,E]=W.useState(!1);if(W.useEffect(()=>{ue.getPron(m).then(j=>d(j.entries||[]))},[m]),S===null)return s.jsxs("p",{className:"text-ink-muted",children:[s.jsx(dn,{})," chargement…"]});const B=()=>E(!1),R=(j,I)=>{d(S.map(($,D)=>D===j?{...$,...I}:$)),B()},G=()=>{d([...S,{term:"",replacement:"",enabled:!0}]),B()},Q=j=>{d(S.filter((I,$)=>$!==j)),B()},M=async()=>{await ue.putPron(m,{entries:S.filter(j=>j.term)}),E(!0)};return s.jsxs("div",{className:"space-y-4",children:[s.jsxs("div",{className:"flex items-center gap-3",children:[s.jsx("p",{className:"text-sm text-ink-muted",children:"Corrigez la graphie des mots mal prononcés. La colonne « prononciation » remplace le terme avant la synthèse."}),s.jsx("button",{className:"btn-ghost ml-auto",onClick:G,children:"+ ajouter"}),s.jsx("button",{className:"btn-primary",onClick:M,children:L?"✓ enregistré":"Enregistrer"})]}),S.length===0?s.jsxs("p",{className:"text-ink-muted",children:["Aucune entrée. Lancez l'étape ",s.jsx("b",{children:"Prononciations"})," ou ajoutez-en."]}):s.jsxs("div",{className:"card divide-y divide-ink-edge",children:[s.jsxs("div",{className:"grid grid-cols-[1fr_1fr_auto_auto] gap-3 px-4 py-2 text-xs uppercase text-ink-muted",children:[s.jsx("span",{children:"Terme"}),s.jsx("span",{children:"Prononciation"}),s.jsx("span",{children:"Actif"}),s.jsx("span",{})]}),S.map((j,I)=>s.jsxs("div",{className:"grid grid-cols-[1fr_1fr_auto_auto] items-center gap-3 px-4 py-2",children:[s.jsx("input",{className:"input",value:j.term,onChange:$=>R(I,{term:$.target.value})}),s.jsx("input",{className:"input",value:j.replacement,onChange:$=>R(I,{replacement:$.target.value})}),s.jsx("input",{type:"checkbox",checked:j.enabled!==!1,onChange:$=>R(I,{enabled:$.target.checked})}),s.jsx("button",{className:"text-ink-muted hover:text-red-400",onClick:()=>Q(I),children:"✕"})]},I))]})]})}const lf=[{key:"analyze",label:"Analyse",action:m=>ue.analyze(m),hint:"Découpe le texte, détecte les locuteurs et le casting."},{key:"cast",label:"Casting",action:m=>ue.castAuto(m),hint:"Attribue une voix à chaque personnage."},{key:"pronounce",label:"Prononciations",action:m=>ue.pronounce(m),hint:"Repère les mots à risque de mauvaise prononciation."}];function of({slug:m,onBack:S}){const[d,L]=W.useState(null),[E,B]=W.useState(null),[R,G]=W.useState("chapters");if(W.useEffect(()=>(ue.getBook(m).then($=>{L($),B($.state)}),qd(m,B)),[m]),!d)return s.jsxs("p",{className:"text-ink-muted",children:[s.jsx(dn,{})," chargement…"]});const{book:Q}=d,M=E||d.state,j=!!M.active_stage;return s.jsxs("div",{className:"space-y-6",children:[s.jsx("button",{onClick:S,className:"text-sm text-ink-muted hover:text-ink-text",children:"← Bibliothèque"}),s.jsxs("div",{className:"flex gap-5",children:[Q.cover_file&&s.jsx("img",{src:ue.coverUrl(m),alt:"",className:"h-44 rounded-md border border-ink-edge object-cover"}),s.jsxs("div",{className:"flex-1",children:[s.jsx("h1",{className:"font-serif text-2xl",children:Q.title}),s.jsx("p",{className:"text-ink-muted",children:Q.author}),s.jsxs("p",{className:"mt-1 text-sm text-ink-muted",children:[Q.chapters.filter(I=>I.render).length," chapitres à narrer"]}),j&&s.jsxs("div",{className:"mt-4 max-w-md space-y-1",children:[s.jsxs("div",{className:"flex justify-between text-xs text-ink-accent",children:[s.jsx("span",{children:M.active_detail||M.active_stage}),s.jsxs("span",{children:[Math.round((M.active_progress||0)*100),"%"]})]}),s.jsx(Va,{value:M.active_progress})]})]})]}),s.jsx("div",{className:"grid grid-cols-1 gap-3 sm:grid-cols-3",children:lf.map(I=>{var D;const $=((D=M.stages)==null?void 0:D[I.key])||"pending";return s.jsxs("div",{className:"card p-4",children:[s.jsxs("div",{className:"flex items-center justify-between",children:[s.jsx("span",{className:"font-medium",children:I.label}),s.jsx($a,{status:$})]}),s.jsx("p",{className:"mt-1 text-xs text-ink-muted",children:I.hint}),s.jsx("button",{className:"btn-ghost mt-3",disabled:j,onClick:()=>I.action(m),children:$==="done"?"Relancer":"Lancer"})]},I.key)})}),s.jsx("div",{className:"flex gap-1 border-b border-ink-edge",children:[["chapters","Chapitres"],["analysis","Analyse"],["cast","Casting"],["pron","Prononciation"]].map(([I,$])=>s.jsx("button",{onClick:()=>G(I),className:`px-4 py-2 text-sm ${R===I?"border-b-2 border-ink-accent text-ink-text":"text-ink-muted hover:text-ink-text"}`,children:$},I))}),R==="chapters"&&s.jsx(bd,{slug:m,book:Q,state:M,busy:j}),R==="analysis"&&s.jsx(tf,{slug:m,book:Q,state:M}),R==="cast"&&s.jsx(nf,{slug:m,busy:j}),R==="pron"&&s.jsx(rf,{slug:m})]})}const uf=[{title:"Moteur LLM (analyse)",hint:"Choisit le moteur d'analyse de texte. MLX charge un modèle mlx-community en local ; LM Studio délègue à son serveur OpenAI local (onglet Developer > Start Server), qui sert des modèles GGUF et MLX chargés dans son interface.",fields:[{key:"gemma_backend",label:"Backend",type:"select",options:[["mlx","MLX (mlx-lm, Apple Silicon)"],["lmstudio","LM Studio (API locale — GGUF + MLX)"]]},{key:"lmstudio_base_url",label:"LM Studio — URL du serveur",type:"text"},{key:"lmstudio_model",label:"LM Studio — modèle à charger",type:"lmstudio_model"},{key:"lmstudio_defer_config",label:"Déléguer la config de génération à LM Studio (température, tokens, contexte gérés côté LM Studio)",type:"checkbox"}]},{title:"Modèles (identifiants MLX / HuggingFace)",hint:"Changer un identifiant recharge un autre modèle (peut déclencher un téléchargement au prochain usage).",fields:[{key:"gemma_model",label:"Gemma (analyse)",type:"text"},{key:"qwen3_model",label:"Qwen3-TTS (rendu)",type:"text"},{key:"kokoro_model",label:"Kokoro (preview)",type:"text"}]},{title:"Génération Gemma",hint:"Paramètres d'échantillonnage de l'analyse (locuteurs, personnages, prononciations). S'appliquent au backend MLX ; pour LM Studio, la config du modèle dans LM Studio prime (sauf si la délégation est décochée ci-dessus).",fields:[{key:"gemma_temperature",label:"Température",type:"number",step:.05,min:0,max:2},{key:"gemma_max_tokens",label:"Max tokens",type:"number",step:1,min:64,max:8192}]},{title:"Prompts système (analyse)",hint:"Instructions envoyées à Gemma avant chaque tâche. Le modèle doit répondre en JSON.",fields:[{key:"prompt_speakers",label:"Attribution des locuteurs",type:"textarea"},{key:"prompt_characters",label:"Extraction des personnages",type:"textarea"},{key:"prompt_pronunciation",label:"Mots à risque (prononciation)",type:"textarea"}]},{title:"Casting (déduplication)",hint:"Le rapprochement des variantes de noms (Holden / James Holden / James) est heuristique et sûr. La passe Gemma ajoute les variantes non évidentes (diminutifs, titres) mais, avec un petit modèle local, produit des fusions erronées.",fields:[{key:"dedup_use_gemma",label:"Affiner la déduplication avec Gemma (moins sûr)",type:"checkbox"}]},{title:"TTS (voix par défaut)",hint:"Backend et voix utilisés par défaut pour le rendu et les replis.",fields:[{key:"default_backend",label:"Backend par défaut",type:"select",options:[["kokoro","Kokoro (rapide)"],["qwen3","Qwen3 (qualité + clonage)"]]},{key:"language",label:"Langue (Qwen3)",type:"text"},{key:"kokoro_lang_code",label:"Code langue Kokoro",type:"text"},{key:"kokoro_default_voice",label:"Voix Kokoro par défaut",type:"text"},{key:"qwen3_default_voice",label:"Voix Qwen3 par défaut",type:"text"}]},{title:"Audio (encodage final)",hint:"Appliqué à la concaténation et à l'export MP3.",fields:[{key:"target_sample_rate",label:"Sample rate (Hz)",type:"number",step:1e3,min:8e3,max:48e3},{key:"mp3_bitrate",label:"Bitrate MP3",type:"text"},{key:"target_dbfs",label:"Normalisation (dBFS)",type:"number",step:.5,min:-40,max:0}]}];function sf({value:m,onChange:S}){const[d,L]=W.useState(null),[E,B]=W.useState(null),[R,G]=W.useState(!1),Q=async()=>{G(!0),B(null);try{const M=await ue.listLmStudioModels();L(M.models||[])}catch(M){B(String(M)),L(null)}finally{G(!1)}};return s.jsxs("div",{className:"space-y-1",children:[s.jsxs("div",{className:"flex gap-2",children:[s.jsx("input",{className:"input w-full",type:"text",list:"lmstudio-models",placeholder:"(vide = modèle actuellement chargé)",value:m??"",onChange:M=>S(M.target.value)}),s.jsx("datalist",{id:"lmstudio-models",children:(d||[]).map(M=>s.jsx("option",{value:M.id,children:M.state},M.id))}),s.jsx("button",{type:"button",className:"btn-ghost whitespace-nowrap",onClick:Q,disabled:R,children:R?"…":"Lister"})]}),E&&s.jsx("p",{className:"text-xs text-red-400",children:"LM Studio injoignable — lance l'app et active le serveur local."}),d&&s.jsxs("p",{className:"text-xs text-ink-muted",children:[d.length," modèle(s) téléchargé(s). Un modèle non chargé sera chargé automatiquement (JIT) à la première analyse."]})]})}function af({field:m,value:S,onChange:d}){const L="input w-full";return m.type==="lmstudio_model"?s.jsx(sf,{value:S,onChange:d}):m.type==="checkbox"?s.jsx("input",{type:"checkbox",className:"h-4 w-4",checked:!!S,onChange:E=>d(E.target.checked)}):m.type==="textarea"?s.jsx("textarea",{className:`${L} min-h-[5rem] resize-y text-sm`,rows:4,value:S??"",onChange:E=>d(E.target.value)}):m.type==="select"?s.jsx("select",{className:L,value:S??"",onChange:E=>d(E.target.value),children:m.options.map(([E,B])=>s.jsx("option",{value:E,children:B},E))}):m.type==="number"?s.jsx("input",{className:L,type:"number",step:m.step,min:m.min,max:m.max,value:S??"",onChange:E=>d(E.target.value===""?"":Number(E.target.value))}):s.jsx("input",{className:L,type:"text",value:S??"",onChange:E=>d(E.target.value)})}function cf({onBack:m}){const[S,d]=W.useState(null),[L,E]=W.useState(!1),[B,R]=W.useState(null);if(W.useEffect(()=>{ue.getSettings().then(d).catch(M=>R(String(M)))},[]),B)return s.jsx("p",{className:"text-sm text-red-400",children:B});if(!S)return s.jsxs("p",{className:"text-ink-muted",children:[s.jsx(dn,{})," chargement des réglages…"]});const G=(M,j)=>{d({...S,[M]:j}),E(!1)},Q=async()=>{R(null);try{await ue.putSettings(S),E(!0)}catch(M){R("Échec de l'enregistrement : "+M)}};return s.jsxs("div",{className:"space-y-6",children:[s.jsxs("div",{className:"flex items-center gap-3",children:[s.jsx("button",{onClick:m,className:"text-sm text-ink-muted hover:text-ink-text",children:"← Bibliothèque"}),s.jsx("h1",{className:"font-serif text-2xl",children:"Réglages techniques"}),s.jsx("button",{className:"btn-primary ml-auto",onClick:Q,children:L?"✓ enregistré":"Enregistrer"})]}),s.jsx("p",{className:"text-sm text-ink-muted",children:"Réglages globaux appliqués à toute l'app. Les changements de modèle prennent effet au prochain lancement d'analyse ou de rendu."}),uf.map(M=>s.jsxs("section",{className:"card p-4 space-y-3",children:[s.jsxs("div",{children:[s.jsx("h2",{className:"font-medium",children:M.title}),M.hint&&s.jsx("p",{className:"text-xs text-ink-muted",children:M.hint})]}),s.jsx("div",{className:"grid gap-3",children:M.fields.map(j=>s.jsxs("label",{className:"grid gap-1",children:[s.jsx("span",{className:"text-sm text-ink-muted",children:j.label}),s.jsx(af,{field:j,value:S[j.key],onChange:I=>G(j.key,I)})]},j.key))})]},M.title)),s.jsx("div",{className:"flex justify-end",children:s.jsx("button",{className:"btn-primary",onClick:Q,children:L?"✓ enregistré":"Enregistrer"})})]})}function df(){const[m,S]=W.useState(()=>location.hash?decodeURIComponent(location.hash.slice(1)):null),[d,L]=W.useState(!1),E=()=>{L(!1),S(null)};return s.jsxs("div",{className:"min-h-screen bg-ink-bg text-ink-text",children:[s.jsx("header",{className:"border-b border-ink-edge",children:s.jsxs("div",{className:"mx-auto flex max-w-6xl items-center gap-3 px-6 py-4",children:[s.jsxs("button",{onClick:E,className:"flex items-center gap-2",children:[s.jsx("span",{className:"text-2xl",children:"🖋️"}),s.jsxs("span",{className:"font-serif text-xl tracking-wide",children:["Ink",s.jsx("span",{className:"text-ink-accent",children:"Flow"})]})]}),s.jsx("span",{className:"ml-2 hidden text-sm text-ink-muted sm:inline",children:"EPUB → livre audio · local · MLX"}),s.jsx("button",{onClick:()=>L(!0),title:"Réglages techniques",className:"ml-auto text-xl text-ink-muted hover:text-ink-text",children:"⚙"})]})}),s.jsx("main",{className:"mx-auto max-w-6xl px-6 py-8",children:d?s.jsx(cf,{onBack:E}):m?s.jsx(of,{slug:m,onBack:()=>S(null)}):s.jsx(Zd,{onOpen:S})})]})}Yd.createRoot(document.getElementById("root")).render(s.jsx(df,{})); diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 0c488c1..94ee572 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -4,8 +4,8 @@ InkFlow — EPUB → Livre audio - - + +

diff --git a/frontend/src/CastEditor.jsx b/frontend/src/CastEditor.jsx index 8a17eec..bd68b7b 100644 --- a/frontend/src/CastEditor.jsx +++ b/frontend/src/CastEditor.jsx @@ -18,6 +18,7 @@ function VoiceSelect({ voices, value, onChange }) { export default function CastEditor({ slug, busy }) { const [cast, setCast] = useState(null); const [voices, setVoices] = useState([]); + const [unresolved, setUnresolved] = useState([]); const [saved, setSaved] = useState(false); const [playing, setPlaying] = useState(null); const [msg, setMsg] = useState(null); @@ -25,12 +26,17 @@ export default function CastEditor({ slug, busy }) { const reload = () => api.getCast(slug).then((d) => { setCast(d.cast); setVoices(d.voicebank.entries); }); + // Locuteurs apparus dans l'analyse mais rattachés à aucun personnage. + const reloadUnresolved = () => + api.getUnresolvedSpeakers(slug) + .then((d) => setUnresolved(d.unresolved || [])).catch(() => {}); - useEffect(() => { reload(); }, [slug]); + useEffect(() => { reload(); reloadUnresolved(); }, [slug]); // Recharge le casting quand un job de fond (dédup / casting chapitre) se termine. useEffect(() => { if (busy) return; reload().then(() => { + reloadUnresolved(); if (dedupPending.current) { dedupPending.current = false; api.getCast(slug).then((d) => @@ -58,6 +64,20 @@ export default function CastEditor({ slug, busy }) { const update = (patch) => { setCast({ ...cast, ...patch }); setSaved(false); }; const setChar = (name, voiceId) => update({ characters: cast.characters.map((c) => c.name === name ? { ...c, voice_id: voiceId } : c) }); + const setAlias = (name, aliasesStr) => + update({ characters: cast.characters.map((c) => c.name === name + ? { ...c, aliases: aliasesStr.split(",").map((s) => s.trim()).filter(Boolean) } : c) }); + + // Rattache une surface non résolue à un personnage (alias) et enregistre. + const attachToChar = async (surface, name) => { + if (!name) return; + const characters = cast.characters.map((c) => c.name === name + ? { ...c, aliases: [...(c.aliases || []), surface] } : c); + const next = { ...cast, characters }; + setCast(next); setSaved(true); + await api.putCast(slug, next); + reloadUnresolved(); + }; const preview = async (voiceId) => { if (!voiceId) return; @@ -70,7 +90,7 @@ export default function CastEditor({ slug, busy }) { } catch { setPlaying(null); } }; - const save = async () => { await api.putCast(slug, cast); setSaved(true); }; + const save = async () => { await api.putCast(slug, cast); setSaved(true); reloadUnresolved(); }; return (
@@ -98,9 +118,9 @@ export default function CastEditor({ slug, busy }) {

{c.name}

- {c.aliases?.length > 0 && ( -

alias : {c.aliases.join(", ")}

- )} + setAlias(c.name, e.target.value)} /> {c.description &&

{c.description}

}
@@ -114,6 +134,32 @@ export default function CastEditor({ slug, busy }) {
))}
+ + {unresolved.length > 0 && ( +
+

+ Locuteurs non rattachés ({unresolved.length}) +

+

+ Ces noms apparaissent dans l'analyse mais ne correspondent à aucun personnage + (ils seraient lus par la voix du narrateur). Rattachez-les comme alias. +

+ {unresolved.map((u) => ( +
+ + {u.speaker} ×{u.count} + + +
+ ))} +
+ )} ); } diff --git a/frontend/src/Settings.jsx b/frontend/src/Settings.jsx index 49e8962..f90b3a1 100644 --- a/frontend/src/Settings.jsx +++ b/frontend/src/Settings.jsx @@ -4,6 +4,17 @@ import { Spinner } from "./ui.jsx"; // Description declarative des champs, groupes par section. const SECTIONS = [ + { + title: "Moteur LLM (analyse)", + hint: "Choisit le moteur d'analyse de texte. MLX charge un modèle mlx-community en local ; LM Studio délègue à son serveur OpenAI local (onglet Developer > Start Server), qui sert des modèles GGUF et MLX chargés dans son interface.", + fields: [ + { key: "gemma_backend", label: "Backend", type: "select", + options: [["mlx", "MLX (mlx-lm, Apple Silicon)"], ["lmstudio", "LM Studio (API locale — GGUF + MLX)"]] }, + { key: "lmstudio_base_url", label: "LM Studio — URL du serveur", type: "text" }, + { key: "lmstudio_model", label: "LM Studio — modèle à charger", type: "lmstudio_model" }, + { key: "lmstudio_defer_config", label: "Déléguer la config de génération à LM Studio (température, tokens, contexte gérés côté LM Studio)", type: "checkbox" }, + ], + }, { title: "Modèles (identifiants MLX / HuggingFace)", hint: "Changer un identifiant recharge un autre modèle (peut déclencher un téléchargement au prochain usage).", @@ -15,7 +26,7 @@ const SECTIONS = [ }, { title: "Génération Gemma", - hint: "Paramètres d'échantillonnage de l'analyse (locuteurs, personnages, prononciations).", + hint: "Paramètres d'échantillonnage de l'analyse (locuteurs, personnages, prononciations). S'appliquent au backend MLX ; pour LM Studio, la config du modèle dans LM Studio prime (sauf si la délégation est décochée ci-dessus).", fields: [ { key: "gemma_temperature", label: "Température", type: "number", step: 0.05, min: 0, max: 2 }, { key: "gemma_max_tokens", label: "Max tokens", type: "number", step: 1, min: 64, max: 8192 }, @@ -60,8 +71,50 @@ const SECTIONS = [ }, ]; +// Selecteur de modele LM Studio : liste les modeles TELECHARGES (via l'API REST +// native), avec saisie libre (datalist). On peut donc choisir un modele non +// encore charge : LM Studio le charge a la volee (JIT) a la 1re requete. +function LmStudioModelField({ value, onChange }) { + const [models, setModels] = useState(null); + const [err, setErr] = useState(null); + const [loading, setLoading] = useState(false); + + const load = async () => { + setLoading(true); setErr(null); + try { const r = await api.listLmStudioModels(); setModels(r.models || []); } + catch (e) { setErr(String(e)); setModels(null); } + finally { setLoading(false); } + }; + + return ( +
+
+ onChange(e.target.value)} /> + + {(models || []).map((m) => ( + + ))} + + +
+ {err &&

+ LM Studio injoignable — lance l'app et active le serveur local.

} + {models &&

+ {models.length} modèle(s) téléchargé(s). Un modèle non chargé sera chargé + automatiquement (JIT) à la première analyse.

} +
+ ); +} + function Field({ field, value, onChange }) { const common = "input w-full"; + if (field.type === "lmstudio_model") + return ; if (field.type === "checkbox") return onChange(e.target.checked)} />; diff --git a/frontend/src/api.js b/frontend/src/api.js index 2497f78..c10cd1b 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -34,10 +34,12 @@ export const api = { j(`/api/books/${slug}/render`, json("POST", { chapters, backend, mono })), getCast: (slug) => j(`/api/books/${slug}/cast`), putCast: (slug, cast) => j(`/api/books/${slug}/cast`, json("PUT", cast)), + getUnresolvedSpeakers: (slug) => j(`/api/books/${slug}/cast/unresolved`), getPron: (slug) => j(`/api/books/${slug}/pronunciation`), putPron: (slug, pron) => j(`/api/books/${slug}/pronunciation`, json("PUT", pron)), getSettings: () => j("/api/settings"), putSettings: (settings) => j("/api/settings", json("PUT", settings)), + listLmStudioModels: () => j("/api/lmstudio/models"), audioUrl: (slug, idx) => `/api/books/${slug}/audio/${idx}`, coverUrl: (slug) => `/api/books/${slug}/cover`, previewVoice: async (voiceId, text) => { diff --git a/voicebank/clips/f_bella.wav b/voicebank/clips/f_bella.wav deleted file mode 100644 index 2d5bccb..0000000 Binary files a/voicebank/clips/f_bella.wav and /dev/null differ diff --git a/voicebank/clips/f_emma.wav b/voicebank/clips/f_emma.wav deleted file mode 100644 index bdceeed..0000000 Binary files a/voicebank/clips/f_emma.wav and /dev/null differ diff --git a/voicebank/clips/f_heart.wav b/voicebank/clips/f_heart.wav deleted file mode 100644 index 53d30ee..0000000 Binary files a/voicebank/clips/f_heart.wav and /dev/null differ diff --git a/voicebank/clips/f_nicole.wav b/voicebank/clips/f_nicole.wav deleted file mode 100644 index 7bd9596..0000000 Binary files a/voicebank/clips/f_nicole.wav and /dev/null differ diff --git a/voicebank/clips/fr_anon_f_1.wav b/voicebank/clips/fr_anon_f_1.wav new file mode 100644 index 0000000..f2c658f Binary files /dev/null and b/voicebank/clips/fr_anon_f_1.wav differ diff --git a/voicebank/clips/fr_anon_f_2.wav b/voicebank/clips/fr_anon_f_2.wav new file mode 100644 index 0000000..2aae152 Binary files /dev/null and b/voicebank/clips/fr_anon_f_2.wav differ diff --git a/voicebank/clips/fr_anon_f_3.wav b/voicebank/clips/fr_anon_f_3.wav new file mode 100644 index 0000000..3305552 Binary files /dev/null and b/voicebank/clips/fr_anon_f_3.wav differ diff --git a/voicebank/clips/fr_anon_f_4.wav b/voicebank/clips/fr_anon_f_4.wav new file mode 100644 index 0000000..ca0c5bf Binary files /dev/null and b/voicebank/clips/fr_anon_f_4.wav differ diff --git a/voicebank/clips/fr_anon_m_1.wav b/voicebank/clips/fr_anon_m_1.wav new file mode 100644 index 0000000..046ccd0 Binary files /dev/null and b/voicebank/clips/fr_anon_m_1.wav differ diff --git a/voicebank/clips/fr_anon_m_2.wav b/voicebank/clips/fr_anon_m_2.wav new file mode 100644 index 0000000..a2c4c9b Binary files /dev/null and b/voicebank/clips/fr_anon_m_2.wav differ diff --git a/voicebank/clips/fr_anon_m_3.wav b/voicebank/clips/fr_anon_m_3.wav new file mode 100644 index 0000000..beb8724 Binary files /dev/null and b/voicebank/clips/fr_anon_m_3.wav differ diff --git a/voicebank/clips/fr_anon_m_4.wav b/voicebank/clips/fr_anon_m_4.wav new file mode 100644 index 0000000..2e66ac1 Binary files /dev/null and b/voicebank/clips/fr_anon_m_4.wav differ diff --git a/voicebank/clips/fr_f_1.wav b/voicebank/clips/fr_f_1.wav new file mode 100644 index 0000000..1214135 Binary files /dev/null and b/voicebank/clips/fr_f_1.wav differ diff --git a/voicebank/clips/fr_f_10.wav b/voicebank/clips/fr_f_10.wav new file mode 100644 index 0000000..5e7281d Binary files /dev/null and b/voicebank/clips/fr_f_10.wav differ diff --git a/voicebank/clips/fr_f_11.wav b/voicebank/clips/fr_f_11.wav new file mode 100644 index 0000000..2b591aa Binary files /dev/null and b/voicebank/clips/fr_f_11.wav differ diff --git a/voicebank/clips/fr_f_12.wav b/voicebank/clips/fr_f_12.wav new file mode 100644 index 0000000..41c0279 Binary files /dev/null and b/voicebank/clips/fr_f_12.wav differ diff --git a/voicebank/clips/fr_f_13.wav b/voicebank/clips/fr_f_13.wav new file mode 100644 index 0000000..b682a13 Binary files /dev/null and b/voicebank/clips/fr_f_13.wav differ diff --git a/voicebank/clips/fr_f_14.wav b/voicebank/clips/fr_f_14.wav new file mode 100644 index 0000000..bf1b119 Binary files /dev/null and b/voicebank/clips/fr_f_14.wav differ diff --git a/voicebank/clips/fr_f_15.wav b/voicebank/clips/fr_f_15.wav new file mode 100644 index 0000000..8fb6081 Binary files /dev/null and b/voicebank/clips/fr_f_15.wav differ diff --git a/voicebank/clips/fr_f_16.wav b/voicebank/clips/fr_f_16.wav new file mode 100644 index 0000000..514111a Binary files /dev/null and b/voicebank/clips/fr_f_16.wav differ diff --git a/voicebank/clips/fr_f_17.wav b/voicebank/clips/fr_f_17.wav new file mode 100644 index 0000000..62cc10d Binary files /dev/null and b/voicebank/clips/fr_f_17.wav differ diff --git a/voicebank/clips/fr_f_18.wav b/voicebank/clips/fr_f_18.wav new file mode 100644 index 0000000..c3e6d17 Binary files /dev/null and b/voicebank/clips/fr_f_18.wav differ diff --git a/voicebank/clips/fr_f_2.wav b/voicebank/clips/fr_f_2.wav new file mode 100644 index 0000000..d370b09 Binary files /dev/null and b/voicebank/clips/fr_f_2.wav differ diff --git a/voicebank/clips/fr_f_3.wav b/voicebank/clips/fr_f_3.wav new file mode 100644 index 0000000..80864ed Binary files /dev/null and b/voicebank/clips/fr_f_3.wav differ diff --git a/voicebank/clips/fr_f_4.wav b/voicebank/clips/fr_f_4.wav new file mode 100644 index 0000000..09e9117 Binary files /dev/null and b/voicebank/clips/fr_f_4.wav differ diff --git a/voicebank/clips/fr_f_5.wav b/voicebank/clips/fr_f_5.wav new file mode 100644 index 0000000..e9a2a3e Binary files /dev/null and b/voicebank/clips/fr_f_5.wav differ diff --git a/voicebank/clips/fr_f_6.wav b/voicebank/clips/fr_f_6.wav new file mode 100644 index 0000000..cf74ee1 Binary files /dev/null and b/voicebank/clips/fr_f_6.wav differ diff --git a/voicebank/clips/fr_f_7.wav b/voicebank/clips/fr_f_7.wav new file mode 100644 index 0000000..40635a6 Binary files /dev/null and b/voicebank/clips/fr_f_7.wav differ diff --git a/voicebank/clips/fr_f_8.wav b/voicebank/clips/fr_f_8.wav new file mode 100644 index 0000000..713e482 Binary files /dev/null and b/voicebank/clips/fr_f_8.wav differ diff --git a/voicebank/clips/fr_f_9.wav b/voicebank/clips/fr_f_9.wav new file mode 100644 index 0000000..8ac0862 Binary files /dev/null and b/voicebank/clips/fr_f_9.wav differ diff --git a/voicebank/clips/fr_f_siwis.wav b/voicebank/clips/fr_f_siwis.wav deleted file mode 100644 index 100607a..0000000 Binary files a/voicebank/clips/fr_f_siwis.wav and /dev/null differ diff --git a/voicebank/clips/fr_m_1.wav b/voicebank/clips/fr_m_1.wav new file mode 100644 index 0000000..b2ac5e5 Binary files /dev/null and b/voicebank/clips/fr_m_1.wav differ diff --git a/voicebank/clips/fr_m_10.wav b/voicebank/clips/fr_m_10.wav new file mode 100644 index 0000000..fcfce93 Binary files /dev/null and b/voicebank/clips/fr_m_10.wav differ diff --git a/voicebank/clips/fr_m_11.wav b/voicebank/clips/fr_m_11.wav new file mode 100644 index 0000000..5f00864 Binary files /dev/null and b/voicebank/clips/fr_m_11.wav differ diff --git a/voicebank/clips/fr_m_12.wav b/voicebank/clips/fr_m_12.wav new file mode 100644 index 0000000..33d5781 Binary files /dev/null and b/voicebank/clips/fr_m_12.wav differ diff --git a/voicebank/clips/fr_m_13.wav b/voicebank/clips/fr_m_13.wav new file mode 100644 index 0000000..693e846 Binary files /dev/null and b/voicebank/clips/fr_m_13.wav differ diff --git a/voicebank/clips/fr_m_14.wav b/voicebank/clips/fr_m_14.wav new file mode 100644 index 0000000..1969b9f Binary files /dev/null and b/voicebank/clips/fr_m_14.wav differ diff --git a/voicebank/clips/fr_m_2.wav b/voicebank/clips/fr_m_2.wav new file mode 100644 index 0000000..2cd2d43 Binary files /dev/null and b/voicebank/clips/fr_m_2.wav differ diff --git a/voicebank/clips/fr_m_3.wav b/voicebank/clips/fr_m_3.wav new file mode 100644 index 0000000..e597727 Binary files /dev/null and b/voicebank/clips/fr_m_3.wav differ diff --git a/voicebank/clips/fr_m_4.wav b/voicebank/clips/fr_m_4.wav new file mode 100644 index 0000000..d4fc96b Binary files /dev/null and b/voicebank/clips/fr_m_4.wav differ diff --git a/voicebank/clips/fr_m_5.wav b/voicebank/clips/fr_m_5.wav new file mode 100644 index 0000000..5f99a1b Binary files /dev/null and b/voicebank/clips/fr_m_5.wav differ diff --git a/voicebank/clips/fr_m_6.wav b/voicebank/clips/fr_m_6.wav new file mode 100644 index 0000000..f955ec9 Binary files /dev/null and b/voicebank/clips/fr_m_6.wav differ diff --git a/voicebank/clips/fr_m_7.wav b/voicebank/clips/fr_m_7.wav new file mode 100644 index 0000000..64434eb Binary files /dev/null and b/voicebank/clips/fr_m_7.wav differ diff --git a/voicebank/clips/fr_m_8.wav b/voicebank/clips/fr_m_8.wav new file mode 100644 index 0000000..db9fdaa Binary files /dev/null and b/voicebank/clips/fr_m_8.wav differ diff --git a/voicebank/clips/fr_m_9.wav b/voicebank/clips/fr_m_9.wav new file mode 100644 index 0000000..b6bd6c1 Binary files /dev/null and b/voicebank/clips/fr_m_9.wav differ diff --git a/voicebank/clips/fr_narrator.wav b/voicebank/clips/fr_narrator.wav new file mode 100644 index 0000000..8d2d3b6 Binary files /dev/null and b/voicebank/clips/fr_narrator.wav differ diff --git a/voicebank/clips/m_eric.wav b/voicebank/clips/m_eric.wav deleted file mode 100644 index 83957bb..0000000 Binary files a/voicebank/clips/m_eric.wav and /dev/null differ diff --git a/voicebank/clips/m_fenrir.wav b/voicebank/clips/m_fenrir.wav deleted file mode 100644 index dff4ee4..0000000 Binary files a/voicebank/clips/m_fenrir.wav and /dev/null differ diff --git a/voicebank/clips/m_george.wav b/voicebank/clips/m_george.wav deleted file mode 100644 index 4feb265..0000000 Binary files a/voicebank/clips/m_george.wav and /dev/null differ diff --git a/voicebank/clips/m_lewis.wav b/voicebank/clips/m_lewis.wav deleted file mode 100644 index 1d9d736..0000000 Binary files a/voicebank/clips/m_lewis.wav and /dev/null differ diff --git a/voicebank/clips/m_michael.wav b/voicebank/clips/m_michael.wav deleted file mode 100644 index 8f14985..0000000 Binary files a/voicebank/clips/m_michael.wav and /dev/null differ diff --git a/voicebank/clips/m_santa.wav b/voicebank/clips/m_santa.wav deleted file mode 100644 index 98ef963..0000000 Binary files a/voicebank/clips/m_santa.wav and /dev/null differ diff --git a/voicebank/metadata.json b/voicebank/metadata.json index 677bceb..5e44ba0 100644 --- a/voicebank/metadata.json +++ b/voicebank/metadata.json @@ -1,114 +1,455 @@ { "entries": [ { - "id": "fr_f_siwis", - "kokoro_voice": "ff_siwis", - "gender": "female", - "age": "adult", - "lang": "fr", - "label": "Siwis (FR)", - "ref_audio": "clips/fr_f_siwis.wav", - "ref_text": "L'univers est toujours plus étrange qu'on ne le croit. Chaque nouvelle merveille pose les bases d'une découverte plus éblouissante encore." - }, - { - "id": "f_bella", + "id": "fr_narrator", "kokoro_voice": "af_bella", "gender": "female", "age": "adult", "lang": "fr", - "label": "Bella", - "ref_audio": "clips/f_bella.wav", - "ref_text": "L'univers est toujours plus étrange qu'on ne le croit. Chaque nouvelle merveille pose les bases d'une découverte plus éblouissante encore." + "label": "Narrateur (FR)", + "ref_audio": "clips/fr_narrator.wav", + "ref_text": "Il n'était pas jusqu'à l'ombre des grands oiseaux qui passaient sur nos têtes, dont je ne surprisse le rapide effleurement à la surface de la mer. En cette occasion, je fus témoin de l'un des plus beaux coups de fusil qui ait jamais fait tressaillir les fibres d'un chasseur", + "anonymous": false }, { - "id": "f_heart", - "kokoro_voice": "af_heart", - "gender": "female", - "age": "young", - "lang": "fr", - "label": "Heart", - "ref_audio": "clips/f_heart.wav", - "ref_text": "L'univers est toujours plus étrange qu'on ne le croit. Chaque nouvelle merveille pose les bases d'une découverte plus éblouissante encore." - }, - { - "id": "f_emma", - "kokoro_voice": "bf_emma", + "id": "fr_f_1", + "kokoro_voice": "af_bella", "gender": "female", "age": "adult", "lang": "fr", - "label": "Emma", - "ref_audio": "clips/f_emma.wav", - "ref_text": "L'univers est toujours plus étrange qu'on ne le croit. Chaque nouvelle merveille pose les bases d'une découverte plus éblouissante encore." + "label": "Voix F 1 (FR)", + "ref_audio": "clips/fr_f_1.wav", + "ref_text": "M. John Knightley sourit et reprit: — Vous voulez dire que voue étiez résolue à prendre l'air, car vous ne vous trouviez pas à six mètres de votre porte et les garçons avaient renoncé depuis longtemps à compter les gouttes de pluie", + "anonymous": false }, { - "id": "f_nicole", + "id": "fr_f_2", + "kokoro_voice": "af_heart", + "gender": "female", + "age": "adult", + "lang": "fr", + "label": "Voix F 2 (FR)", + "ref_audio": "clips/fr_f_2.wav", + "ref_text": "et il gâterait tout à fait les façons que l'on a la bonté de tolérer chez l'homme en habit bleu. Il salua avec beaucoup de respect, et sortit sans regarder. Ce trait amusa le marquis. Il le conta le soir à l'abbé Pirard", + "anonymous": false + }, + { + "id": "fr_f_3", "kokoro_voice": "af_nicole", "gender": "female", "age": "adult", "lang": "fr", - "label": "Nicole", - "ref_audio": "clips/f_nicole.wav", - "ref_text": "L'univers est toujours plus étrange qu'on ne le croit. Chaque nouvelle merveille pose les bases d'une découverte plus éblouissante encore." + "label": "Voix F 3 (FR)", + "ref_audio": "clips/fr_f_3.wav", + "ref_text": "Loin de parler quand on me paie, et de me taire quand on ne me donne rien, je laisse également le riche et le pauvre m'interroger; ou, si on l'aime mieux, on répond à mes questions, et l'on entend ce que j'ai à dire", + "anonymous": false }, { - "id": "m_fenrir", + "id": "fr_f_4", + "kokoro_voice": "bf_emma", + "gender": "female", + "age": "adult", + "lang": "fr", + "label": "Voix F 4 (FR)", + "ref_audio": "clips/fr_f_4.wav", + "ref_text": "fit l'éloge de Mme Weston avec une persistance si outrée et finalement se mit à admirer les dessins d'Emma avec tant de zèle et si peu de compétence que celle-ci dut reconnaître qu'il avait tout à fait l'allure d'un amoureux", + "anonymous": false + }, + { + "id": "fr_f_5", + "kokoro_voice": "af_bella", + "gender": "female", + "age": "adult", + "lang": "fr", + "label": "Voix F 5 (FR)", + "ref_audio": "clips/fr_f_5.wav", + "ref_text": "Là, s'étant enfermé avec son grand vizir, ce ministre les habilla, les mit ensuite sur le feu dans une casserole, et quand ils furent cuits d'un côté, il les retourna de l'autre.", + "anonymous": false + }, + { + "id": "fr_f_6", + "kokoro_voice": "af_heart", + "gender": "female", + "age": "adult", + "lang": "fr", + "label": "Voix F 6 (FR)", + "ref_audio": "clips/fr_f_6.wav", + "ref_text": "Sa main eut un tremblement et la bougie tomba du chandelier sur le tapis, où elle s'écrasa. Il posa le pied dessus, la repoussant. Puis il se laissa tomber dans le fauteuil près de la table et ensevelit sa face dans ses mains", + "anonymous": false + }, + { + "id": "fr_f_7", + "kokoro_voice": "af_nicole", + "gender": "female", + "age": "adult", + "lang": "fr", + "label": "Voix F 7 (FR)", + "ref_audio": "clips/fr_f_7.wav", + "ref_text": "Je fais atteler en dix minutes, nous allons à deux lieues de Paris; je corrige mon Turc en un tour de main et je rentre à l'étude, avant que les petits journaux de seandale aient eu vent de notre histoire", + "anonymous": false + }, + { + "id": "fr_f_8", + "kokoro_voice": "bf_emma", + "gender": "female", + "age": "adult", + "lang": "fr", + "label": "Voix F 8 (FR)", + "ref_audio": "clips/fr_f_8.wav", + "ref_text": "ces calenders borgnes sont la cause de ce malheur; il n'y a pas de ville qui ne tombe en ruine devant des gens de si mauvais augure. Madame, je vous supplie de ne pas confondre le premier avec le dernier", + "anonymous": false + }, + { + "id": "fr_f_9", + "kokoro_voice": "af_bella", + "gender": "female", + "age": "adult", + "lang": "fr", + "label": "Voix F 9 (FR)", + "ref_audio": "clips/fr_f_9.wav", + "ref_text": "Les oiseaux la prirent pour un épi et se mirent à en picorer les grains. Alors vite, avec l'autre patte, le chat les attrapa.", + "anonymous": false + }, + { + "id": "fr_f_10", + "kokoro_voice": "af_heart", + "gender": "female", + "age": "adult", + "lang": "fr", + "label": "Voix F 10 (FR)", + "ref_audio": "clips/fr_f_10.wav", + "ref_text": "et qu'il n'y ait pas de danger qu'elles le fassent rôtir, car aucun estomac ne peut supporter le porc rôti, je crois que vous feriez mieux d'envoyer le jambon. N'est-ce pas votre avis, ma chère", + "anonymous": false + }, + { + "id": "fr_f_11", + "kokoro_voice": "af_nicole", + "gender": "female", + "age": "adult", + "lang": "fr", + "label": "Voix F 11 (FR)", + "ref_audio": "clips/fr_f_11.wav", + "ref_text": "Ce qui nous sépare, en effet, ce n'est pas une différence: c'est un infini. Lucienne se leva et prit le bras de M. de W. — Je remporte de notre entretien cet axiome", + "anonymous": false + }, + { + "id": "fr_f_12", + "kokoro_voice": "bf_emma", + "gender": "female", + "age": "adult", + "lang": "fr", + "label": "Voix F 12 (FR)", + "ref_audio": "clips/fr_f_12.wav", + "ref_text": "Au son des flûtes seulement, elle le déchire un peu, puis tout à fait, et, avec les gestes de la danse, elle cueille les fleurs de son corps, En chantant: Où sont mes roses?", + "anonymous": false + }, + { + "id": "fr_f_13", + "kokoro_voice": "af_bella", + "gender": "female", + "age": "adult", + "lang": "fr", + "label": "Voix F 13 (FR)", + "ref_audio": "clips/fr_f_13.wav", + "ref_text": "Ne voulant point avoir des mots avec un homme si bon et si agréable en toutes autres choses, je m'employai avec les scieurs de long, et je m'en acquittai à leur contentement;", + "anonymous": false + }, + { + "id": "fr_f_14", + "kokoro_voice": "af_heart", + "gender": "female", + "age": "adult", + "lang": "fr", + "label": "Voix F 14 (FR)", + "ref_audio": "clips/fr_f_14.wav", + "ref_text": "Tu viens de lui donner un coup de griffe sec: ce n'est pas trop mal. Je t'assure que l'aigle l'aura senti; le vent emporte la beauté de ses plumes, tachées de sang", + "anonymous": false + }, + { + "id": "fr_f_15", + "kokoro_voice": "af_nicole", + "gender": "female", + "age": "adult", + "lang": "fr", + "label": "Voix F 15 (FR)", + "ref_audio": "clips/fr_f_15.wav", + "ref_text": "que si la bonté divine est infinie, l'usage en est pourtant réglé par la justice, et qu'il peut venir un moment où le Dieu de miséricorde se change en un Dieu de vengeance.", + "anonymous": false + }, + { + "id": "fr_f_16", + "kokoro_voice": "bf_emma", + "gender": "female", + "age": "adult", + "lang": "fr", + "label": "Voix F 16 (FR)", + "ref_audio": "clips/fr_f_16.wav", + "ref_text": "C'est la fin de la France! dit Trochu profondément convaincu. Il comprenait enfin ce que depuis plusieurs heures on ne cessait de lui répéter, la déchéance du gouvernement de la défense nationale", + "anonymous": false + }, + { + "id": "fr_f_17", + "kokoro_voice": "af_bella", + "gender": "female", + "age": "adult", + "lang": "fr", + "label": "Voix F 17 (FR)", + "ref_audio": "clips/fr_f_17.wav", + "ref_text": "Ce petit mémoire justificatif arrangé en forme de conte, que Fouqué ne devait ouvrir qu'en cas d'accident, Julien le fit aussi peu compromettant que possible pour Mlle de La Mole;", + "anonymous": false + }, + { + "id": "fr_f_18", + "kokoro_voice": "af_heart", + "gender": "female", + "age": "adult", + "lang": "fr", + "label": "Voix F 18 (FR)", + "ref_audio": "clips/fr_f_18.wav", + "ref_text": "S'endormir en transcrivant une sorte de commentaire de l'Apocalypse, le lendemain aller porter une lettre d'un air mélancolique, remettre le cheval à l'écurie avec l'espérance d'apercevoir la robe de Mathilde", + "anonymous": false + }, + { + "id": "fr_m_1", "kokoro_voice": "am_fenrir", "gender": "male", "age": "adult", "lang": "fr", - "label": "Fenrir", - "ref_audio": "clips/m_fenrir.wav", - "ref_text": "L'univers est toujours plus étrange qu'on ne le croit. Chaque nouvelle merveille pose les bases d'une découverte plus éblouissante encore." + "label": "Voix H 1 (FR)", + "ref_audio": "clips/fr_m_1.wav", + "ref_text": "Mon vieux serin considérait Darwin comme un grand coupable et ne parlait rien moins que de le pendre. Darwin n'était pas encore mort, à ce moment-là. Moi, je lui répondais que Bossuet était un drôle et que, si je savais où se trouvait sa tombe, j'irais la souiller d'excréments", + "anonymous": false }, { - "id": "m_michael", + "id": "fr_m_2", "kokoro_voice": "am_michael", "gender": "male", "age": "adult", "lang": "fr", - "label": "Michael", - "ref_audio": "clips/m_michael.wav", - "ref_text": "L'univers est toujours plus étrange qu'on ne le croit. Chaque nouvelle merveille pose les bases d'une découverte plus éblouissante encore." + "label": "Voix H 2 (FR)", + "ref_audio": "clips/fr_m_2.wav", + "ref_text": "Elle accepta avec un sourire qui me fit oublier que j'avais risqué le salut de mon âme pour avoir le plaisir de me trémousser et de battre des ailes de pigeon en sa compagnie. Pendant deux heures de temps, une danse n'attendait pas l'autre", + "anonymous": false }, { - "id": "m_george", + "id": "fr_m_3", "kokoro_voice": "bm_george", "gender": "male", "age": "adult", "lang": "fr", - "label": "George", - "ref_audio": "clips/m_george.wav", - "ref_text": "L'univers est toujours plus étrange qu'on ne le croit. Chaque nouvelle merveille pose les bases d'une découverte plus éblouissante encore." + "label": "Voix H 3 (FR)", + "ref_audio": "clips/fr_m_3.wav", + "ref_text": "que le voiant le lendemain comme il se presentoit pour entrer au Bal chez la Reine, paré d'un nombre infini de pierreries, mais plus paré encore de sa bonne mine, il se mît à l'entrée de la porte, &", + "anonymous": false }, { - "id": "m_lewis", - "kokoro_voice": "bm_lewis", + "id": "fr_m_4", + "kokoro_voice": "am_eric", "gender": "male", "age": "adult", "lang": "fr", - "label": "Lewis", - "ref_audio": "clips/m_lewis.wav", - "ref_text": "L'univers est toujours plus étrange qu'on ne le croit. Chaque nouvelle merveille pose les bases d'une découverte plus éblouissante encore." + "label": "Voix H 4 (FR)", + "ref_audio": "clips/fr_m_4.wav", + "ref_text": "C'est dans sa propre voiture que j'ai été ramenée à l'hôtel; la vôtre vous sera renvoyée demain. Vous trouverez vos chevaux bien affaiblis depuis cet accident; ils sont comme hébétés; on dirait qu'ils ne peuvent se pardonner à eux-mêmes de s'être laissé dompter par un homme", + "anonymous": false }, { - "id": "m_eric", + "id": "fr_m_5", + "kokoro_voice": "am_fenrir", + "gender": "male", + "age": "adult", + "lang": "fr", + "label": "Voix H 5 (FR)", + "ref_audio": "clips/fr_m_5.wav", + "ref_text": "Le fait raconté, fut vérifié par les autorités qui vinrent avec les archers visiter les lieux, le récit de Fortin fut reconnu vrai: on vit toutes les traces d'un repas horrible, des danses et des jeux de la troupe diabolique", + "anonymous": false + }, + { + "id": "fr_m_6", + "kokoro_voice": "am_michael", + "gender": "male", + "age": "adult", + "lang": "fr", + "label": "Voix H 6 (FR)", + "ref_audio": "clips/fr_m_6.wav", + "ref_text": "Chose étonnante! se disait-il, je croyais que par sa lettre à M. de La Mole elle avait détruit à jamais mon bonheur à venir et moins de quinze jours après la date de cette lettre, je ne songe plus à tout ce qui m'occupait alors", + "anonymous": false + }, + { + "id": "fr_m_7", + "kokoro_voice": "bm_george", + "gender": "male", + "age": "adult", + "lang": "fr", + "label": "Voix H 7 (FR)", + "ref_audio": "clips/fr_m_7.wav", + "ref_text": "Bakbarah, qui de temps en temps levait la tête pour la regarder et qui la voyait rire, s'imagina que c'était de la joie qu'elle avait de sa venue, et se flatta que bientôt elle écarterait ses esclaves pour rester avec lui sans témoins", + "anonymous": false + }, + { + "id": "fr_m_8", "kokoro_voice": "am_eric", "gender": "male", - "age": "young", + "age": "adult", "lang": "fr", - "label": "Eric", - "ref_audio": "clips/m_eric.wav", - "ref_text": "L'univers est toujours plus étrange qu'on ne le croit. Chaque nouvelle merveille pose les bases d'une découverte plus éblouissante encore." + "label": "Voix H 8 (FR)", + "ref_audio": "clips/fr_m_8.wav", + "ref_text": "je dis dans les meilleurs: une daube de baeuf où la gelée ne sente pas la colle, et où le baeuf ait pris le parfum des carottes, c'est admirable! Permettez-moi d'y revenir, ajouta-t-il en faisant signe qu'il voulait encore de la gelée", + "anonymous": false }, { - "id": "m_santa", - "kokoro_voice": "am_santa", + "id": "fr_m_9", + "kokoro_voice": "am_fenrir", "gender": "male", - "age": "old", + "age": "adult", "lang": "fr", - "label": "Santa", - "ref_audio": "clips/m_santa.wav", - "ref_text": "L'univers est toujours plus étrange qu'on ne le croit. Chaque nouvelle merveille pose les bases d'une découverte plus éblouissante encore." + "label": "Voix H 9 (FR)", + "ref_audio": "clips/fr_m_9.wav", + "ref_text": "Lorsque les navires de ce hardi chercheur arrivèrent à la mer de Sargasses, ils naviguèrent non sans peine au milieu de ces herbes qui arrêtaient leur marche au grand effroi des équipages, et ils perdirent trois longues semaines à les traverser", + "anonymous": false + }, + { + "id": "fr_m_10", + "kokoro_voice": "am_michael", + "gender": "male", + "age": "adult", + "lang": "fr", + "label": "Voix H 10 (FR)", + "ref_audio": "clips/fr_m_10.wav", + "ref_text": "Il y avait donc à la Banque de France une fortune de trois milliards trois cent vingt-trois millions, plus de la moitié de la rançon de la guerre. Que serait-il advenu si la Commune eût pu s'emparer de ce trésor", + "anonymous": false + }, + { + "id": "fr_m_11", + "kokoro_voice": "bm_george", + "gender": "male", + "age": "adult", + "lang": "fr", + "label": "Voix H 11 (FR)", + "ref_audio": "clips/fr_m_11.wav", + "ref_text": "La sentinelle ne pouvait l'apercevoir, ce brigand d'enfant préparait à coup sûr une farce, car il ne décolérait pas contre les soldats, il demandait quand on serait débarrassé de ces assassins, qu'on envoyait avec des fusils tuer le monde", + "anonymous": false + }, + { + "id": "fr_m_12", + "kokoro_voice": "am_eric", + "gender": "male", + "age": "adult", + "lang": "fr", + "label": "Voix H 12 (FR)", + "ref_audio": "clips/fr_m_12.wav", + "ref_text": "Mon dîner s'y trouvait préparé. Il se composait d'une soupe à la tortue faite des carets les plus délicats, d'un surmulet à chair blanche, un peu feuilletée, dont le foie préparé à part fit un manger délicieux", + "anonymous": false + }, + { + "id": "fr_m_13", + "kokoro_voice": "am_fenrir", + "gender": "male", + "age": "adult", + "lang": "fr", + "label": "Voix H 13 (FR)", + "ref_audio": "clips/fr_m_13.wav", + "ref_text": "Mais avant qu'elle n'arrive jusqu'à nous le voile de nuage se déchire, la mer entre en ébullition et l'électricité, produite par une vaste action chimique qui s'opère dans les couches supérieures, est mise en jeu", + "anonymous": false + }, + { + "id": "fr_m_14", + "kokoro_voice": "am_michael", + "gender": "male", + "age": "adult", + "lang": "fr", + "label": "Voix H 14 (FR)", + "ref_audio": "clips/fr_m_14.wav", + "ref_text": "fais ce que tu voudras, agis comme il te plaira, enferme-moi toute la vie dans une prison obscure, avec des scorpions pour compagnons de ma captivité, ou arrache-moi un", + "anonymous": false + }, + { + "id": "fr_anon_f_1", + "kokoro_voice": "af_bella", + "gender": "female", + "age": "adult", + "lang": "fr", + "label": "Anonyme F 1 (FR)", + "ref_audio": "clips/fr_anon_f_1.wav", + "ref_text": "Lorsque l'envieux se vit seul avec ce bon homme, il commença de lui raconter ce qui lui plut, en marchant l'un à côté de l'autre dans la cour", + "anonymous": true + }, + { + "id": "fr_anon_f_2", + "kokoro_voice": "af_heart", + "gender": "female", + "age": "adult", + "lang": "fr", + "label": "Anonyme F 2 (FR)", + "ref_audio": "clips/fr_anon_f_2.wav", + "ref_text": "Sorbonne était l'endroit bucolique Où je t'adorais du soir au matin. C'est ainsi qu'une âme amoureuse applique La carte du Tendre au pays latin. Ô place Maubert! Ô place Dauphine", + "anonymous": true + }, + { + "id": "fr_anon_f_3", + "kokoro_voice": "af_nicole", + "gender": "female", + "age": "adult", + "lang": "fr", + "label": "Anonyme F 3 (FR)", + "ref_audio": "clips/fr_anon_f_3.wav", + "ref_text": "sur une chaise, la tête et les deux coudes sur son lit, abîmé dans des pensées qu'il ne pouvait saisir et comme en proie à un vertige.", + "anonymous": true + }, + { + "id": "fr_anon_f_4", + "kokoro_voice": "bf_emma", + "gender": "female", + "age": "adult", + "lang": "fr", + "label": "Anonyme F 4 (FR)", + "ref_audio": "clips/fr_anon_f_4.wav", + "ref_text": "Nous nous serions séparées parfaitement contentes l'une de l'autre, si elle n'avait voulu me charger d'une lettre pour Danceny;", + "anonymous": true + }, + { + "id": "fr_anon_m_1", + "kokoro_voice": "am_fenrir", + "gender": "male", + "age": "adult", + "lang": "fr", + "label": "Anonyme H 1 (FR)", + "ref_audio": "clips/fr_anon_m_1.wav", + "ref_text": "et comme j'ai remarqué que le sable était toujours moins creusé par une patte que par les trois autres, j'ai compris que la chienne de notre auguste reine était un peu boiteuse, si je l'ose dire", + "anonymous": true + }, + { + "id": "fr_anon_m_2", + "kokoro_voice": "am_michael", + "gender": "male", + "age": "adult", + "lang": "fr", + "label": "Anonyme H 2 (FR)", + "ref_audio": "clips/fr_anon_m_2.wav", + "ref_text": "nous savons que Gallifet était à Sedan puisqu'il y ramassa le chapeau à plumes blanches de Margueritte, cela ne fait absolument rien, au sang dont il est couvert, et qui ne s'effacera jamais", + "anonymous": true + }, + { + "id": "fr_anon_m_3", + "kokoro_voice": "bm_george", + "gender": "male", + "age": "adult", + "lang": "fr", + "label": "Anonyme H 3 (FR)", + "ref_audio": "clips/fr_anon_m_3.wav", + "ref_text": "Ma curiosité fut excitée au plus haut point. Mon oncle essaya vainement de me retenir. Quand il vit que mon impatience me ferait plus de mal que la satisfaction de mes désirs, il céda", + "anonymous": true + }, + { + "id": "fr_anon_m_4", + "kokoro_voice": "am_eric", + "gender": "male", + "age": "adult", + "lang": "fr", + "label": "Anonyme H 4 (FR)", + "ref_audio": "clips/fr_anon_m_4.wav", + "ref_text": "Après une vie d'amour, une éternité d'amour, c'est une augmentation en effet; mais accroître en son intensité même la félicité ineffable que l'amour donne à l'âme dès ce monde, c'est impossible, même à Dieu", + "anonymous": true } ] } \ No newline at end of file