From d3bb91394bf9d15d4d5e078a0e1e497006897234 Mon Sep 17 00:00:00 2001 From: colgora Date: Sun, 21 Jun 2026 00:10:11 +0200 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20InkFlow=20=E2=80=94=20EPUB?= =?UTF-8?q?=20vers=20livre=20audio=20local=20(MLX/Kokoro)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 23 + .idea/.gitignore | 10 + README.md | 105 + backend/inkflow/__init__.py | 0 backend/inkflow/analysis/__init__.py | 0 backend/inkflow/analysis/gemma.py | 123 + backend/inkflow/analysis/pronunciation.py | 59 + backend/inkflow/analysis/segmenter.py | 622 +++++ backend/inkflow/api/__init__.py | 0 backend/inkflow/api/app.py | 295 +++ backend/inkflow/api/ws.py | 47 + backend/inkflow/audio/__init__.py | 0 backend/inkflow/audio/postprocess.py | 125 + backend/inkflow/casting/__init__.py | 0 backend/inkflow/casting/assign.py | 86 + backend/inkflow/casting/dedup.py | 345 +++ backend/inkflow/casting/voicebank.py | 91 + backend/inkflow/cli.py | 239 ++ backend/inkflow/config.py | 96 + backend/inkflow/epub/__init__.py | 0 backend/inkflow/epub/parser.py | 267 ++ backend/inkflow/models.py | 176 ++ backend/inkflow/pipeline/__init__.py | 0 backend/inkflow/pipeline/orchestrator.py | 364 +++ backend/inkflow/pipeline/render.py | 158 ++ backend/inkflow/settings.py | 170 ++ backend/inkflow/store/__init__.py | 0 backend/inkflow/store/artifacts.py | 63 + backend/inkflow/tts/__init__.py | 0 backend/inkflow/tts/base.py | 48 + backend/inkflow/tts/chunk.py | 62 + backend/inkflow/tts/factory.py | 20 + backend/inkflow/tts/kokoro.py | 93 + backend/inkflow/tts/qwen3.py | 58 + backend/inkflow/util.py | 22 + backend/pyproject.toml | 40 + backend/scripts/setup_models.py | 87 + backend/tests/test_incises.py | 204 ++ frontend/dist/assets/index-CMUl6Yfl.js | 40 + frontend/dist/assets/index-DlPmWkkU.css | 1 + frontend/dist/index.html | 13 + frontend/index.html | 12 + frontend/package-lock.json | 2767 +++++++++++++++++++++ frontend/package.json | 22 + frontend/postcss.config.js | 6 + frontend/src/AnalysisEditor.jsx | 245 ++ frontend/src/App.jsx | 44 + frontend/src/BookView.jsx | 99 + frontend/src/CastEditor.jsx | 119 + frontend/src/Chapters.jsx | 98 + frontend/src/Library.jsx | 80 + frontend/src/PronunciationEditor.jsx | 59 + frontend/src/Settings.jsx | 142 ++ frontend/src/api.js | 64 + frontend/src/index.css | 37 + frontend/src/main.jsx | 6 + frontend/src/ui.jsx | 35 + frontend/tailwind.config.js | 23 + frontend/vite.config.js | 14 + voicebank/clips/f_bella.wav | Bin 0 -> 418844 bytes voicebank/clips/f_emma.wav | Bin 0 -> 372044 bytes voicebank/clips/f_heart.wav | Bin 0 -> 386444 bytes voicebank/clips/f_nicole.wav | Bin 0 -> 478844 bytes voicebank/clips/fr_f_siwis.wav | Bin 0 -> 382844 bytes voicebank/clips/m_eric.wav | Bin 0 -> 344444 bytes voicebank/clips/m_fenrir.wav | Bin 0 -> 402044 bytes voicebank/clips/m_george.wav | Bin 0 -> 428444 bytes voicebank/clips/m_lewis.wav | Bin 0 -> 422444 bytes voicebank/clips/m_michael.wav | Bin 0 -> 426044 bytes voicebank/clips/m_santa.wav | Bin 0 -> 412844 bytes voicebank/metadata.json | 114 + 71 files changed, 8138 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 README.md create mode 100644 backend/inkflow/__init__.py create mode 100644 backend/inkflow/analysis/__init__.py create mode 100644 backend/inkflow/analysis/gemma.py create mode 100644 backend/inkflow/analysis/pronunciation.py create mode 100644 backend/inkflow/analysis/segmenter.py create mode 100644 backend/inkflow/api/__init__.py create mode 100644 backend/inkflow/api/app.py create mode 100644 backend/inkflow/api/ws.py create mode 100644 backend/inkflow/audio/__init__.py create mode 100644 backend/inkflow/audio/postprocess.py create mode 100644 backend/inkflow/casting/__init__.py create mode 100644 backend/inkflow/casting/assign.py create mode 100644 backend/inkflow/casting/dedup.py create mode 100644 backend/inkflow/casting/voicebank.py create mode 100644 backend/inkflow/cli.py create mode 100644 backend/inkflow/config.py create mode 100644 backend/inkflow/epub/__init__.py create mode 100644 backend/inkflow/epub/parser.py create mode 100644 backend/inkflow/models.py create mode 100644 backend/inkflow/pipeline/__init__.py create mode 100644 backend/inkflow/pipeline/orchestrator.py create mode 100644 backend/inkflow/pipeline/render.py create mode 100644 backend/inkflow/settings.py create mode 100644 backend/inkflow/store/__init__.py create mode 100644 backend/inkflow/store/artifacts.py create mode 100644 backend/inkflow/tts/__init__.py create mode 100644 backend/inkflow/tts/base.py create mode 100644 backend/inkflow/tts/chunk.py create mode 100644 backend/inkflow/tts/factory.py create mode 100644 backend/inkflow/tts/kokoro.py create mode 100644 backend/inkflow/tts/qwen3.py create mode 100644 backend/inkflow/util.py create mode 100644 backend/pyproject.toml create mode 100644 backend/scripts/setup_models.py create mode 100644 backend/tests/test_incises.py create mode 100644 frontend/dist/assets/index-CMUl6Yfl.js create mode 100644 frontend/dist/assets/index-DlPmWkkU.css create mode 100644 frontend/dist/index.html create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/AnalysisEditor.jsx create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/BookView.jsx create mode 100644 frontend/src/CastEditor.jsx create mode 100644 frontend/src/Chapters.jsx create mode 100644 frontend/src/Library.jsx create mode 100644 frontend/src/PronunciationEditor.jsx create mode 100644 frontend/src/Settings.jsx create mode 100644 frontend/src/api.js create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/ui.jsx create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/vite.config.js create mode 100644 voicebank/clips/f_bella.wav create mode 100644 voicebank/clips/f_emma.wav create mode 100644 voicebank/clips/f_heart.wav create mode 100644 voicebank/clips/f_nicole.wav create mode 100644 voicebank/clips/fr_f_siwis.wav create mode 100644 voicebank/clips/m_eric.wav create mode 100644 voicebank/clips/m_fenrir.wav create mode 100644 voicebank/clips/m_george.wav create mode 100644 voicebank/clips/m_lewis.wav create mode 100644 voicebank/clips/m_michael.wav create mode 100644 voicebank/clips/m_santa.wav create mode 100644 voicebank/metadata.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..112d812 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Python +.venv/ +__pycache__/ +*.pyc +*.egg-info/ +.pytest_cache/ + +# InkFlow : artefacts générés et sorties +data/ +output/ + +# Node +node_modules/ + +# Échantillons audio (volumineux, non versionnés) +samples/ + +# Modèles / caches HF (au cas où téléchargés localement) +.cache/ +models/ + +# OS +.DS_Store diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/README.md b/README.md new file mode 100644 index 0000000..9808000 --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# InkFlow + +Transforme un **EPUB** en **livre audio**, 100 % en local sur Mac (Apple Silicon / MLX), +avec des modèles open-source. Sortie : **1 dossier par livre, 1 MP3 par chapitre** +(tags ID3 + cover), au format calqué sur un audiobook classique. + +- **Analyse de texte** : Gemma via `mlx-lm` (segmentation narration/dialogue, + attribution des locuteurs, extraction du casting, prononciations). +- **Synthèse vocale** : backend pluggable — + - **Kokoro** : rapide, voix préréglées → previews / mono-narrateur. + - **Qwen3-TTS** : qualité + clonage par audio de référence → rendu final, casting par personnage. +- **Langue** : optimisé français (puis multilingue). + +## Pré-requis + +- macOS Apple Silicon (arm64), Python ≥ 3.11 +- `ffmpeg` et `espeak-ng` : + ```bash + brew install ffmpeg espeak-ng + ``` + +## Installation + +```bash +python3.13 -m venv .venv +source .venv/bin/activate +pip install -e backend # installe inkflow + dépendances +python backend/scripts/setup_models.py # vérifie l'env + télécharge les modèles MLX +``` + +> Kokoro en français nécessite `espeak-ng` ; InkFlow localise automatiquement +> `libespeak-ng.dylib` (sinon, exporter `PHONEMIZER_ESPEAK_LIBRARY`). + +## Utilisation (CLI) + +```bash +# 1. Parser l'EPUB -> data//book.json + chapters/chNN.json +inkflow parse "samples/Colère de Tiamat, La - James S.A. Corey.epub" + +# 2. Analyser (Gemma) -> analysis/chNN.json + cast.json +inkflow analyze la-colere-de-tiamat --chapter 5 # un chapitre +inkflow analyze la-colere-de-tiamat # tous les chapitres + +# 3. Synthétiser un chapitre -> output//NN-....mp3 +inkflow render la-colere-de-tiamat 5 --backend kokoro # rapide +inkflow render la-colere-de-tiamat 5 --backend qwen3 --no-mono # qualité + multi-voix (M3) + +# Infos +inkflow info la-colere-de-tiamat +``` + +(Sans installation `-e`, lancer depuis `backend/` via `python -m inkflow.cli …`.) + +## Interface web + +```bash +# 1. Build du frontend (une fois) +cd frontend && npm install && npm run build && cd .. + +# 2. Lancer l'app (API + UI servie sur le même port) +inkflow serve # http://127.0.0.1:8000 +``` + +L'UI permet : import EPUB par glisser-déposer, suivi temps réel des étapes +(WebSocket), édition du casting (personnage → voix, avec preview), édition du +dictionnaire de prononciation, choix du moteur (Kokoro/Qwen3) et rendu des +chapitres avec lecteur audio + téléchargement. + +Pour le développement frontend avec rechargement à chaud : +```bash +inkflow serve # backend sur :8000 +cd frontend && npm run dev # UI sur :5173 (proxy API/WS vers :8000) +``` + +## Architecture + +``` +backend/inkflow/ + epub/parser.py EPUB -> book.json + texte par chapitre + analysis/gemma.py wrapper mlx-lm (Gemma) + analysis/segmenter.py narration/dialogue + locuteurs + casting + analysis/pronunciation.py + tts/base.py interface TTSBackend + VoiceSpec + tts/kokoro.py tts/qwen3.py tts/factory.py + audio/postprocess.py concat + normalisation + MP3 (ffmpeg) + cover + pipeline/render.py (segments + voix) -> MP3 + store/artifacts.py persistance JSON (reprenable) +data// artefacts intermédiaires (json, wav, cover) +output// MP3 finaux (1 par chapitre) +voicebank/ clips de référence pour le clonage (M3) +``` + +## État d'avancement + +- [x] **M1** — Parsing EPUB, analyse Gemma (segments + casting), CLI. +- [x] **M2** — TTS bout-en-bout (Kokoro/Qwen3), mono-narrateur → MP3 taggé + cover. +- [x] **M3** — Multi-voix : voice bank + auto-casting personnage → voix (clonage Qwen3). +- [x] **M4** — Interface web (FastAPI + WebSocket + React) : suivi, éditeurs casting/prononciation, previews. +- [x] **M5** — État reprenable (réconciliation avec les artefacts), run par lots via UI/CLI. + +### Note sur les moteurs +- **Kokoro** : ~30 s/chapitre, voix distinctes par timbre (rendu rapide, brouillons). +- **Qwen3-TTS** : clonage des voix de la banque par personnage, qualité supérieure, + nettement plus lent — réservé au rendu final. Tout rendu est **repris** chapitre + par chapitre (relancer ne refait pas les MP3 déjà produits). diff --git a/backend/inkflow/__init__.py b/backend/inkflow/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/inkflow/analysis/__init__.py b/backend/inkflow/analysis/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/inkflow/analysis/gemma.py b/backend/inkflow/analysis/gemma.py new file mode 100644 index 0000000..fff1954 --- /dev/null +++ b/backend/inkflow/analysis/gemma.py @@ -0,0 +1,123 @@ +"""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) + + +@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) + + +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 + + def _ensure_loaded(self) -> None: + if self._model is None: + self._model, self._tokenizer = _load(self.model_id) + + 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 + if temperature is None: + temperature = settings.gemma_temperature + from mlx_lm import generate + from mlx_lm.sample_utils import make_sampler + + messages = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": prompt}) + formatted = self._tokenizer.apply_chat_template( + messages, add_generation_prompt=True, tokenize=False + ) + sampler = make_sampler(temp=temperature) + return generate( + self._model, + self._tokenizer, + prompt=formatted, + max_tokens=max_tokens, + sampler=sampler, + verbose=False, + ) + + 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 _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/pronunciation.py b/backend/inkflow/analysis/pronunciation.py new file mode 100644 index 0000000..c175765 --- /dev/null +++ b/backend/inkflow/analysis/pronunciation.py @@ -0,0 +1,59 @@ +"""Dictionnaire de prononciation : application + proposition de candidats. + +L'application est une simple reecriture de surface du texte (graphie guidee) +avant synthese. Les candidats (noms propres, termes SF) peuvent etre proposes +par Gemma puis valides par l'utilisateur dans l'UI. +""" +from __future__ import annotations + +import re +from typing import Iterable + +from ..models import Pronunciation, PronunciationEntry +from ..settings import get_settings +from .gemma import Gemma + + +def apply_pronunciation(text: str, pron: Pronunciation) -> str: + """Remplace chaque terme actif par sa graphie phonetique (mot entier).""" + for entry in pron.entries: + if not entry.enabled or not entry.term: + continue + pattern = re.compile(rf"\b{re.escape(entry.term)}\b") + text = pattern.sub(entry.replacement, text) + return text + + +# Le prompt systeme est editable dans les reglages (settings.prompt_pronunciation). + + +def propose_pronunciations(text: str, gemma: Gemma, *, max_chars: int = 16000) -> list[PronunciationEntry]: + """Propose des candidats de prononciation a valider.""" + sample = text[:max_chars] + prompt = ( + "Repere dans cet extrait les mots a risque de mauvaise prononciation par " + "une voix de synthese francaise. Pour chacun, propose une graphie " + "phonetique francaise (replacement) qui guide la prononciation.\n\n" + f"EXTRAIT:\n{sample}\n\n" + 'Reponds par un tableau JSON: ' + '[{"term":"Tiamat","replacement":"Tia-matt","note":"nom propre"}]' + ) + result = gemma.generate_json(prompt, system=get_settings().prompt_pronunciation) + entries: list[PronunciationEntry] = [] + for item in result: + if isinstance(item, dict) and item.get("term") and item.get("replacement"): + entries.append(PronunciationEntry( + term=str(item["term"]).strip(), + replacement=str(item["replacement"]).strip(), + note=item.get("note"), + )) + return entries + + +def merge_pronunciations( + existing: Pronunciation, new: Iterable[PronunciationEntry] +) -> Pronunciation: + by_term = {e.term.lower(): e for e in existing.entries} + for e in new: + by_term.setdefault(e.term.lower(), e) + return Pronunciation(entries=list(by_term.values())) diff --git a/backend/inkflow/analysis/segmenter.py b/backend/inkflow/analysis/segmenter.py new file mode 100644 index 0000000..62caf18 --- /dev/null +++ b/backend/inkflow/analysis/segmenter.py @@ -0,0 +1,622 @@ +"""Segmentation narration/dialogue + attribution de locuteur + casting. + +Approche hybride : +1. Pre-segmentation deterministe au niveau paragraphe (regles de ponctuation + francaise : un paragraphe commencant par un cadratin "—" est une replique). +2. Gemma attribue un locuteur a chaque replique, en un seul appel par chapitre + (liste numerotee + contexte), et extrait le casting (personnages + attributs). + +Le decoupage fin des incises ("..., dit-il") est laisse a une passe ulterieure ; +en v1 la replique entiere est portee par la voix du personnage. +""" +from __future__ import annotations + +import re +from typing import Optional + +from ..models import ( + Cast, + Chapter, + ChapterAnalysis, + ChapterText, + Character, + Incise, + Segment, + SegmentType, +) +from ..settings import get_settings +from .gemma import Gemma + +# Un paragraphe de dialogue commence par un cadratin (U+2014) ou un tiret long. +_DIALOGUE_LEAD_RE = re.compile(r"^\s*[—―]\s*") + +# --- Detection des incises (inversion verbe-sujet francaise) ------------------ +# Une incise est un groupe de narration insere dans une replique ("..., dit-il."). +# On exclut tu/nous/vous (imperatifs "Donne-le-moi", "Crois-tu ?") pour limiter +# les faux positifs. Voir `detect_incises` plus bas pour les deux passes +# (inversion verbe-pronom + nominale "lanca Drummer", conscience du casting). +_INCISE_PRON = r"(?:il|elle|on|ils|elles|je)" +# Verbe de parole, eventuellement reflechi ("s'ecria", "s'exclama"). +_INCISE_VERB = r"(?:[A-Za-zÀ-ÿ]+['’])?[A-Za-zÀ-ÿ]{2,}" + + +def segment_chapter_text(ct: ChapterText) -> list[Segment]: + """Decoupe un chapitre en segments narration/dialogue (regles seules).""" + segments: list[Segment] = [] + for para in ct.paragraphs: + if _DIALOGUE_LEAD_RE.match(para): + text = _DIALOGUE_LEAD_RE.sub("", para).strip() + segments.append(Segment( + type=SegmentType.DIALOGUE, text=text, speaker="?")) + else: + segments.append(Segment( + type=SegmentType.NARRATION, text=para, speaker="narrateur")) + return segments + + +# --- Attribution des locuteurs (Gemma) -------------------------------------- +# Le prompt systeme est editable dans les reglages (settings.prompt_speakers). + + +_UNKNOWN = {"", "?", "inconnu", "narrateur"} +_CTX_CHARS = 160 # troncature du contexte narratif avant/apres +_CHUNK_MAX_DIALOGUES = 30 # repliques par appel (fiabilite du modele) + + +def attribute_speakers( + segments: list[Segment], + gemma: Gemma, + *, + characters: Optional[list[Character]] = None, + pov: Optional[str] = None, +) -> dict[int, str]: + """Renseigne `speaker` pour chaque dialogue (mutation en place). + + Fournit au modele la liste canonique enrichie des personnages (nom, genre, + description) et, pour chaque replique, le contexte narratif AVANT et APRES + (l'incise d'attribution est souvent placee apres : "— Bonjour. dit Marie."). + + Renvoie une map {index_de_segment: confidence} ("high"/"medium"/"low"), + conservee en memoire (non persistee) pour piloter la 2e passe retroactive. + Une replique dont le nom rendu sort de la liste fournie est gardee mais + marquee "low" afin d'etre reexaminee. + """ + dialogues = [(i, s) for i, s in enumerate(segments) + if s.type is SegmentType.DIALOGUE] + if not dialogues: + return {} + + # Repliques deja resolues (seed par incise) : montrees comme contexte fixe, + # jamais re-demandees au modele. Si tout est resolu, rien a faire. + locked = {i for i, s in dialogues if _is_resolved(s.speaker)} + if len(locked) == len(dialogues): + return {i: "high" for i, _ in dialogues} + + hint = _speakers_hint(characters, pov) + valid = {c.name.strip().lower() for c in (characters or [])} + confidence: dict[int, str] = {} + + for chunk in _chunk_dialogues(dialogues, segments, hint): + prompt = ( + "Voici les repliques de dialogue d'un extrait, numerotees, avec la " + "narration qui precede et qui suit chaque replique. Les repliques " + "deja attribuees affichent (locuteur: X) : ne les modifie pas, " + "sers-t'en comme contexte (alternance des tours). Pour les AUTRES, " + "indique le personnage qui parle (recopie son nom depuis la liste " + "fournie ; 'inconnu' si vraiment indeterminable) et ta confiance " + "(high/medium/low)." + f"{hint}\n\n" + "\n".join(line for _, line in chunk) + + '\n\nReponds par un tableau JSON: ' + '[{"i": 0, "speaker": "Holden", "confidence": "high"}, ...]' + ) + result = gemma.generate_json(prompt, system=get_settings().prompt_speakers) + by_i: dict[int, dict] = {item["i"]: item for item in result + if isinstance(item, dict) and "i" in item} + for j, (seg_idx, _line) in enumerate(chunk): + if seg_idx in locked: # seed conserve + confidence[seg_idx] = "high" + continue + seg = segments[seg_idx] + item = by_i.get(j) or {} + speaker = (str(item.get("speaker") or "inconnu").strip() + or "inconnu") + conf = str(item.get("confidence") or "low").strip().lower() + if conf not in {"high", "medium", "low"}: + conf = "low" + # Nom hors liste connue -> on garde le nom mais on le rejuge. + if (valid and speaker.lower() not in _UNKNOWN + and speaker.lower() not in valid): + conf = "low" + seg.speaker = speaker + confidence[seg_idx] = conf + return confidence + + +def _speakers_hint(characters: Optional[list[Character]], pov: Optional[str]) -> str: + hint = "" + if characters: + lines = [] + for c in characters: + attrs = c.gender or "" + desc = f" — {c.description}" if c.description else "" + lines.append(f"- {c.name}" + (f" ({attrs})" if attrs else "") + desc) + hint += "\nPersonnages du chapitre:\n" + "\n".join(lines) + if pov: + hint += f"\nLe point de vue de ce chapitre est: {pov}." + return hint + + +def _is_resolved(speaker: str) -> bool: + """Vrai si la replique a deja un locuteur sur (seed incise, etc.).""" + return (speaker or "").strip().lower() not in _UNKNOWN + + +def _dialogue_line(n: int, segments: list[Segment], idx: int) -> str: + seg = segments[idx] + # Replique deja resolue (ex: seed par incise) -> montree comme contexte fixe. + if _is_resolved(seg.speaker): + return f"[{n}] (locuteur: {seg.speaker}) REPLIQUE: {seg.text!r}" + before = _adjacent_narration(segments, idx, -1) + after = _adjacent_narration(segments, idx, +1) + parts = [f"[{n}]"] + if before: + parts.append(f"(avant: {before!r})") + parts.append(f"REPLIQUE: {seg.text!r}") + if after: + parts.append(f"(apres: {after!r})") + return " ".join(parts) + + +def _adjacent_narration(segments: list[Segment], idx: int, direction: int) -> str: + """Texte de la narration immediatement adjacente (incise d'attribution).""" + j = idx + direction + if 0 <= j < len(segments) and segments[j].type is SegmentType.NARRATION: + return segments[j].text[:_CTX_CHARS] + return "" + + +def _chunk_dialogues( + dialogues: list[tuple[int, Segment]], + segments: list[Segment], + hint: str, +) -> list[list[tuple[int, str]]]: + """Decoupe les repliques en lots tenant sous `_MAX_PROMPT_CHARS`. + + Chaque lot est une liste de (index_segment, ligne_rendue) ; la ligne est + numerotee localement (0..k) pour le prompt, l'index segment sert au mapping + retour. Evite la troncature brutale sur les longs chapitres. + """ + budget = _MAX_PROMPT_CHARS - len(hint) - 400 # marge pour les consignes + chunks: list[list[tuple[int, str]]] = [] + current: list[tuple[int, str]] = [] + size = 0 + for idx, _seg in dialogues: + line = _dialogue_line(len(current), segments, idx) + if current and (size + len(line) > budget + or len(current) >= _CHUNK_MAX_DIALOGUES): + chunks.append(current) + current = [] + size = 0 + line = _dialogue_line(0, segments, idx) + current.append((idx, line)) + size += len(line) + 1 + if current: + chunks.append(current) + return chunks + + +# --- Passe retroactive : re-resolution des repliques indeterminees ---------- +# Le prompt systeme est editable (settings.prompt_speakers_refine). + + +def _refine_unknown_speakers( + segments: list[Segment], + gemma: Gemma, + *, + characters: Optional[list[Character]] = None, + confidence: dict[int, str], +) -> None: + """2e passe : re-resout les repliques restees indeterminees/peu sures. + + Chaque replique douteuse est presentee avec ses voisines de dialogue DEJA + identifiees (alternance des tours) et son contexte narratif, pour exploiter + l'information venant des repliques *suivantes*. Mutation en place ; aucun + appel Gemma si rien n'est douteux. + """ + dialogues = [(i, s) for i, s in enumerate(segments) + if s.type is SegmentType.DIALOGUE] + if not dialogues: + return + pos = {seg_idx: n for n, (seg_idx, _s) in enumerate(dialogues)} + doubtful = [seg_idx for seg_idx, _s in dialogues + if segments[seg_idx].speaker.strip().lower() in _UNKNOWN + or confidence.get(seg_idx) == "low"] + if not doubtful: + return + + hint = _speakers_hint(characters, pov=None) + lines = [] + for j, seg_idx in enumerate(doubtful): + n = pos[seg_idx] + ctx = [] + if n > 0: + prev_idx = dialogues[n - 1][0] + ctx.append(f"replique precedente (dite par " + f"{segments[prev_idx].speaker}): " + f"{segments[prev_idx].text[:_CTX_CHARS]!r}") + before = _adjacent_narration(segments, seg_idx, -1) + if before: + ctx.append(f"narration avant: {before!r}") + after = _adjacent_narration(segments, seg_idx, +1) + if after: + ctx.append(f"narration apres: {after!r}") + if n < len(dialogues) - 1: + next_idx = dialogues[n + 1][0] + ctx.append(f"replique suivante (dite par " + f"{segments[next_idx].speaker}): " + f"{segments[next_idx].text[:_CTX_CHARS]!r}") + ctx_str = (" [" + " ; ".join(ctx) + "]") if ctx else "" + lines.append(f"[{j}]{ctx_str} REPLIQUE: {segments[seg_idx].text!r}") + + prompt = ( + "Repliques au locuteur indetermine. Pour chacune, en t'appuyant sur les " + "repliques voisines DEJA attribuees (alternance des tours) et le " + "contexte, indique qui parle (recopie le nom depuis la liste ; " + "'inconnu' si toujours indeterminable)." + f"{hint}\n\n" + "\n".join(lines) + + '\n\nReponds par un tableau JSON: [{"i": 0, "speaker": "Holden"}, ...]' + ) + result = gemma.generate_json(_truncate(prompt), + system=get_settings().prompt_speakers_refine) + by_i = {item["i"]: item.get("speaker") for item in result + if isinstance(item, dict) and "i" in item} + for j, seg_idx in enumerate(doubtful): + new = (str(by_i.get(j) or "").strip()) + if new and new.lower() not in _UNKNOWN: + segments[seg_idx].speaker = new + + +# --- Extraction du casting (Gemma) ------------------------------------------ +# Le prompt systeme est editable dans les reglages (settings.prompt_characters). + + +def extract_characters(text: str, gemma: Gemma) -> 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 " + "sont nommes. Pour chacun, donne: name (nom court canonique), gender " + "(male/female/unknown), age (child/young/adult/old/unknown), et une " + "courte description. Ignore les figurants sans nom.\n\n" + f"EXTRAIT:\n{_truncate(text)}\n\n" + 'Reponds par un tableau JSON: ' + '[{"name":"Holden","gender":"male","age":"adult","description":"..."}]' + ) + result = gemma.generate_json(prompt, system=get_settings().prompt_characters) + characters: list[Character] = [] + for item in result: + if not isinstance(item, dict) or not item.get("name"): + continue + characters.append(Character( + name=str(item["name"]).strip(), + gender=_norm(item.get("gender")), + age=_norm(item.get("age")), + description=(item.get("description") or None), + )) + return characters + + +def merge_characters(existing: list[Character], new: list[Character]) -> list[Character]: + """Fusionne deux listes de personnages par nom (insensible a la casse).""" + by_key = {c.name.lower(): c for c in existing} + for c in new: + key = c.name.lower() + if key in by_key: + cur = by_key[key] + cur.gender = cur.gender or c.gender + cur.age = cur.age or c.age + cur.description = cur.description or c.description + else: + by_key[key] = c + return list(by_key.values()) + + +def _norm(value) -> Optional[str]: + if not value: + return None + v = str(value).strip().lower() + return v if v and v != "unknown" else None + + +# --- Helpers ----------------------------------------------------------------- + +# Garde-fou de contexte (caracteres) pour rester dans une fenetre raisonnable. +_MAX_PROMPT_CHARS = 24000 + + +def _truncate(text: str) -> str: + return text if len(text) <= _MAX_PROMPT_CHARS else text[:_MAX_PROMPT_CHARS] + + +# --- Detection des incises (deterministe, conscience du casting) ------------- +# Les incises sont annotees par des bornes (offsets) sur la replique persistee +# (non destructif) ; le rendu les fait porter par la voix du narrateur. Deux +# passes complementaires : +# 1. inversion verbe-pronom ("dit-il", "coupa-t-elle") ; +# 2. nominale : verbe de parole + sujet connu (nom du casting OU nom de role, +# ex: "compatit Holden", "lanca Drummer", "informa le soldat"). +# La passe nominale s'appuie sur la liste des personnages -> peu de faux positifs +# et permet d'extraire le locuteur explicite (seeding de l'attribution). + +# Pronom objet eventuel devant le verbe ("lui demanda un garde"). +_CLITIC = r"(?:lui|leur|nous|vous|me|te|se|y|en|[mts]['’])" + +# Formes conjuguees de verbes de parole (3e pers., passe simple / present / +# imparfait). Liste curee : on prefere rater une incise que d'en inventer une. +_SPEECH_VERBS = { + "dit", "disait", "redit", "répondit", "repondit", "répond", "repond", + "répondait", "repondait", "demanda", "demandait", "demande", "interrogea", + "questionna", "ecria", "écria", "exclama", "enquit", "lança", "lanca", + "lançait", "lance", "murmura", "chuchota", "souffla", "soupira", "ajouta", + "ajoute", "reprit", "poursuivit", "poursuit", "continua", "enchaîna", + "enchaina", "fit", "faisait", "remarqua", "observa", "nota", "déclara", + "declara", "affirma", "assura", "rétorqua", "retorqua", "répliqua", + "repliqua", "riposta", "objecta", "protesta", "insista", "renchérit", + "rencherit", "acquiesça", "acquiesca", "admit", "avoua", "convint", + "concéda", "conceda", "rectifia", "corrigea", "précisa", "precisa", + "expliqua", "raconta", "annonça", "annonca", "proclama", "ordonna", + "commanda", "supplia", "implora", "gémit", "gemit", "grogna", "ronchonna", + "maugréa", "maugrea", "marmonna", "glissa", "lâcha", "lacha", "coupa", + "interrompit", "conclut", "compléta", "completa", "suggéra", "suggera", + "proposa", "promit", "jura", "menaça", "menaca", "ironisa", "plaisanta", + "railla", "cria", "hurla", "tonna", "gronda", "rugit", "susurra", + "compatit", "salua", "appela", "héla", "hela", "interpella", "balbutia", + "bredouilla", "bafouilla", "gloussa", "ricana", "siffla", "tempêta", + "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", +} + +# Noms de role pouvant etre sujet d'une incise ("informa le soldat"). +_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", + "inconnu", "inconnue", "étranger", "etranger", "enfant", "serviteur", + "servante", "messager", "domestique", "médecin", "medecin", +} + +# 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", + "mademoiselle", "m", "mme", "mlle", "mr", "dr", "docteur", "saint", "sainte", +} + +# Ponctuations qui terminent la partie parlee : si l'incise les suit, tout le +# reste de la replique est de la narration (la parole est finie). Apres une +# simple virgule au contraire, le dialogue reprend apres l'incise. +_SENTENCE_FINAL = {"", ".", "!", "?", "…"} + + +def _incise_end(text: str, close_end: int, lead: str) -> int: + """Fin effective de l'incise : jusqu'au bout de la replique si la parole + etait deja close a gauche (`.`/`!`/`?`/`…` ou debut), sinon la cloture.""" + return len(text) if lead in _SENTENCE_FINAL else close_end + + +# Passe 1 : inversion verbe-(t-)pronom, ancree sur une ponctuation a gauche +# (virgule, point, ?, !, …) ou le debut de la replique. +_INVERSION_RE = re.compile( + r"(?P[,.!?…]|^)\s*" + r"(?P" + _INCISE_VERB + r"-(?:t-)?" + _INCISE_PRON + + r"(?:\s+[^.!?…»\",;]*?)?)" # complements eventuels ("dit-il en souriant") + r"(?P[.!?…,])", # cloture : ponctuation forte OU virgule + re.IGNORECASE, +) + + +def _inversion_spans(text: str) -> list[tuple[int, int]]: + return [(m.start("inc"), _incise_end(text, m.end("close"), m.group("lead"))) + for m in _INVERSION_RE.finditer(text)] + + +def _name_token_index(names) -> dict[str, str]: + """Index token -> nom canonique (tokens distinctifs uniquement). + + Un token partage par plusieurs personnages est ambigu et ecarte. + """ + idx: dict[str, str] = {} + ambiguous: set[str] = set() + for name in names or (): + for tok in re.split(r"[^\wÀ-ÿ]+", name): + t = tok.lower() + if len(t) < 2 or t in _NAME_STOP: + continue + if t in idx and idx[t] != name: + ambiguous.add(t) + else: + idx[t] = name + for t in ambiguous: + idx.pop(t, None) + return idx + + +# Nom propre : initiale majuscule (motif sensible a la casse). +_PROPER = r"[A-ZÀ-Ÿ][\wÀ-ÿ’'\-]+" +_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. + + - 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) ; + - mot quelconque -> _REJECT (pas une incise). + """ + low = subj.lower() + if low in idx: + return idx[low] + if low in _ROLE_NOUNS: + return None + if subj[:1].isupper() and len(low) >= 2 and low not in _NAME_STOP: + return subj.strip("’'") + return _REJECT + + +def _nominal_matches(text: str, names) -> list[tuple[int, int, Optional[str]]]: + """Passe 2 : (start, end, locuteur) 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. + """ + idx = _name_token_index(names) + literals = sorted(set(idx) | _ROLE_NOUNS, key=len, reverse=True) + lit_alt = "|".join(re.escape(s) for s in literals) + # Sujet : nom connu/role (insensible casse) OU nom propre (capitalise, sensible + # casse pour ne pas happer un determiner "un"/"le"). Pas d'IGNORECASE global. + subj_alt = (f"(?i:{lit_alt})|{_PROPER}") if lit_alt else _PROPER + verbs = "|".join(re.escape(v) for v in sorted(_SPEECH_VERBS, key=len, reverse=True)) + pat = re.compile( + r"(?P[,.!?…]|^)\s*" + r"(?P(?:(?i:" + _CLITIC + r")\s+)?" + r"(?i:" + verbs + r")\b" + r"[^.!?…»\",;]{0,40}?\b" + r"(?P" + subj_alt + r")\b" + r"[^.!?…»\",;]*?)" + r"(?P[.!?…,])", + ) + out: list[tuple[int, int, Optional[str]]] = [] + for m in pat.finditer(text): + spk = _classify_subject(m.group("subj"), idx) + if spk is _REJECT: + continue + out.append((m.start("inc"), + _incise_end(text, m.end("close"), m.group("lead")), spk)) + return out + + +def _merge_spans(spans: list[tuple[int, int]]) -> list[Incise]: + """Trie et fusionne (sans chevauchement) une liste de bornes -> Incise.""" + out: list[Incise] = [] + last_end = -1 + for s, e in sorted(set(spans)): + if s < last_end: # chevauchement -> on garde le premier vu + continue + out.append(Incise(start=s, end=e)) + last_end = e + return out + + +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())] + 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): + if s == incise.start and e == incise.end: + return spk + return None + + +def iter_incise_pieces( + text: str, incises: list[Incise] +) -> list[tuple[bool, str]]: + """Decoupe `text` en morceaux (is_incise, sous_texte) via les bornes. + + Utilise au rendu : pieces dialogue -> voix du personnage, pieces incise -> + voix du narrateur. Texte conserve modulo espaces de bordure. + """ + pieces: list[tuple[bool, str]] = [] + cursor = 0 + for inc in sorted(incises, key=lambda i: i.start): + if inc.start < cursor: # garde-fou chevauchement + continue + before = text[cursor:inc.start] + if before.strip(): + pieces.append((False, before.strip())) + body = text[inc.start:inc.end] + if body.strip(): + pieces.append((True, body.strip())) + cursor = inc.end + tail = text[cursor:] + if tail.strip(): + pieces.append((False, tail.strip())) + return pieces + + +def analyze_chapter( + chapter: Chapter, + ct: ChapterText, + gemma: Gemma, + *, + book_chars: Optional[list[Character]] = None, + dedup_gemma: Optional[Gemma] = None, +) -> tuple[ChapterAnalysis, list[Character]]: + """Analyse complete d'un chapitre. + + Sequence : segmentation -> extraction des personnages -> reconciliation + (dedup contre le cast cumule du livre) -> annotation des incises + seeding + du locuteur explicite -> attribution LLM des repliques restantes -> passe + retroactive. Les repliques sont persistees entieres (incises = bornes). + + `book_chars` : cast cumule du livre (personnages canoniques deja connus). + `dedup_gemma` : si fourni, tranche les cas de dedup ambigus. + + Renvoie (analyse, cast cumule mis a jour) ; le 2e element est l'ensemble du + casting du livre reconcilie, pret a etre persiste tel quel. + """ + from ..casting.dedup import reconcile_characters + + segments = segment_chapter_text(ct) + full_text = "\n".join(ct.paragraphs) + found = extract_characters(full_text, gemma) + + # Dedup AVANT l'attribution : le modele recevra des noms canoniques. + chars, name_map = reconcile_characters(book_chars or [], found, dedup_gemma) + + # Liste canonique restreinte a ce chapitre (personnages detectes + POV). + chapter_canon = {(name_map.get(c.name.strip().lower()) or c.name).strip().lower() + for c in found} + chapter_chars = [c for c in chars if c.name.strip().lower() in chapter_canon] + if chapter.pov: + pv = chapter.pov.strip().lower() + for c in chars: + if (c not in chapter_chars and + (pv in c.name.lower() + or any(pv in a.lower() for a in c.aliases))): + chapter_chars.append(c) + + # 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). + 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: + spk = incise_speaker(seg.text, inc, names) + if spk: + seg.speaker = spk + break + + conf = attribute_speakers(segments, gemma, characters=chapter_chars, + pov=chapter.pov) + if get_settings().retro_pass_use_gemma: + _refine_unknown_speakers(segments, gemma, characters=chapter_chars, + confidence=conf) + + # Absorbe les locuteurs residuels (hors liste) en aliases (heuristique seule). + chars, _ = reconcile_characters( + chars, [], None, speaker_names=[s.speaker for s in segments]) + + # Les repliques sont persistees entieres ; les incises restent des bornes + # (rendu : voix narrateur). Plus de fragmentation a l'analyse. + analysis = ChapterAnalysis(index=chapter.index, title=ct.title, + segments=segments) + return analysis, chars diff --git a/backend/inkflow/api/__init__.py b/backend/inkflow/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/inkflow/api/app.py b/backend/inkflow/api/app.py new file mode 100644 index 0000000..d7ae376 --- /dev/null +++ b/backend/inkflow/api/app.py @@ -0,0 +1,295 @@ +"""Application FastAPI : pilote le pipeline et sert l'UI. + +Toutes les routes lourdes (analyse, casting, rendu) sont *enfilees* dans +l'orchestrateur et rendent la main immediatement ; l'avancement arrive par +WebSocket. Les operations rapides (preview de voix) tournent dans un threadpool. +""" +from __future__ import annotations + +import asyncio +import io +from pathlib import Path +from typing import Optional + +import soundfile as sf +from fastapi import FastAPI, HTTPException, UploadFile, WebSocket, WebSocketDisconnect +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, Response +from fastapi.staticfiles import StaticFiles +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 ..pipeline.orchestrator import load_state, orchestrator +from ..settings import Settings, get_settings, save_settings +from ..store import artifacts +from ..util import slugify +from .ws import manager + +app = FastAPI(title="InkFlow API") +app.add_middleware( + CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], +) + + +@app.on_event("startup") +async def _startup() -> None: + ensure_dirs() + manager.bind_loop(asyncio.get_running_loop()) + orchestrator.set_broadcaster(manager.broadcast_threadsafe) + + +# --- Helpers ----------------------------------------------------------------- + +def _list_book_slugs() -> list[str]: + if not DATA_DIR.exists(): + return [] + return sorted(p.parent.name for p in DATA_DIR.glob("*/book.json")) + + +def _book_summary(slug: str) -> dict: + book = load_book(slug) + state = load_state(slug) + rendered = sum(1 for r in state.render.values() if r.mp3) + return { + "slug": slug, + "title": book.title, + "author": book.author, + "chapters": len(book.render_chapters), + "rendered": rendered, + "cover": f"/api/books/{slug}/cover" if book.cover_file else None, + } + + +# --- Bibliotheque / upload --------------------------------------------------- + +@app.get("/api/books") +def list_books() -> list[dict]: + return [_book_summary(s) for s in _list_book_slugs()] + + +@app.post("/api/books") +async def upload_book(file: UploadFile) -> dict: + ensure_dirs() + uploads = DATA_DIR / "_uploads" + uploads.mkdir(parents=True, exist_ok=True) + dest = uploads / (file.filename or "livre.epub") + dest.write_bytes(await file.read()) + book = await asyncio.to_thread(parse_epub, dest) + # Initialise l'etat. + load_state(book.slug) + return {"slug": book.slug, "title": book.title} + + +@app.get("/api/books/{slug}") +def get_book(slug: str) -> dict: + _require(slug) + book = load_book(slug) + return {"book": book.model_dump(mode="json"), + "state": load_state(slug).model_dump(mode="json")} + + +@app.get("/api/books/{slug}/cover") +def get_cover(slug: str): + book = load_book(slug) + if not book.cover_file: + raise HTTPException(404, "pas de couverture") + return FileResponse(str(book_data_dir(slug) / book.cover_file)) + + +@app.get("/api/books/{slug}/chapters/{index}") +def get_chapter(slug: str, index: int) -> dict: + _require(slug) + book = load_book(slug) + ch = next((c for c in book.chapters if c.index == index), None) + if ch is None: + raise HTTPException(404, "chapitre inconnu") + out: dict = {"chapter": ch.model_dump(mode="json")} + apath = artifacts.analysis_path(slug, index) + if apath.exists(): + out["analysis"] = artifacts.load_analysis(slug, index).model_dump(mode="json") + elif ch.text_file: + out["text"] = load_chapter_text(slug, ch).model_dump(mode="json") + return out + + +@app.put("/api/books/{slug}/chapters/{index}/analysis") +def put_analysis(slug: str, index: int, analysis: ChapterAnalysis) -> dict: + _require(slug) + if analysis.index != index: + raise HTTPException(400, "index incoherent") + artifacts.save_analysis(slug, analysis) + return {"saved": True} + + +# --- Etapes (enfilees) ------------------------------------------------------- + +class ChaptersBody(BaseModel): + chapters: Optional[list[int]] = None + + +@app.post("/api/books/{slug}/analyze") +def analyze(slug: str, body: ChaptersBody) -> dict: + _require(slug) + orchestrator.run_analyze(slug, body.chapters) + return {"queued": True} + + +@app.post("/api/books/{slug}/pronounce") +def pronounce(slug: str) -> dict: + _require(slug) + orchestrator.run_pronounce(slug) + return {"queued": True} + + +@app.post("/api/books/{slug}/cast/auto") +def cast_auto(slug: str) -> dict: + _require(slug) + orchestrator.run_cast(slug) + return {"queued": True} + + +@app.post("/api/books/{slug}/cast/analyze") +def cast_analyze(slug: str, body: ChaptersBody) -> dict: + """(Re)analyse le casting d'un/des chapitre(s) avec reconciliation.""" + _require(slug) + orchestrator.run_cast_analyze(slug, body.chapters) + return {"queued": True} + + +@app.post("/api/books/{slug}/cast/dedup") +def cast_dedup(slug: str) -> dict: + """Deduplique le casting existant (variantes de noms -> aliases).""" + _require(slug) + orchestrator.run_dedup_cast(slug) + return {"queued": True} + + +class RenderBody(BaseModel): + chapters: list[int] + backend: Optional[str] = None + mono: bool = False + + +@app.post("/api/books/{slug}/render") +def render(slug: str, body: RenderBody) -> dict: + _require(slug) + orchestrator.run_render(slug, body.chapters, backend=body.backend, mono=body.mono) + return {"queued": True} + + +# --- Casting / prononciation (lecture-ecriture directe) ---------------------- + +@app.get("/api/books/{slug}/cast") +def get_cast(slug: str) -> dict: + from ..casting.voicebank import load_voicebank + _require(slug) + return {"cast": artifacts.load_cast(slug).model_dump(mode="json"), + "voicebank": load_voicebank().model_dump(mode="json")} + + +@app.put("/api/books/{slug}/cast") +def put_cast(slug: str, cast: Cast) -> dict: + _require(slug) + artifacts.save_cast(slug, cast) + return {"saved": True} + + +@app.get("/api/books/{slug}/pronunciation") +def get_pron(slug: str) -> dict: + _require(slug) + return artifacts.load_pronunciation(slug).model_dump(mode="json") + + +@app.put("/api/books/{slug}/pronunciation") +def put_pron(slug: str, pron: Pronunciation) -> dict: + _require(slug) + artifacts.save_pronunciation(slug, pron) + return {"saved": True} + + +# --- Reglages techniques globaux --------------------------------------------- + +@app.get("/api/settings") +def read_settings() -> dict: + return get_settings().model_dump(mode="json") + + +@app.put("/api/settings") +def write_settings(settings: Settings) -> dict: + save_settings(settings) + return {"saved": True} + + +# --- Voicebank + preview ----------------------------------------------------- + +@app.get("/api/voicebank") +def get_voicebank() -> dict: + from ..casting.voicebank import load_voicebank + return load_voicebank().model_dump(mode="json") + + +class PreviewBody(BaseModel): + voice_id: str + text: str = "Bonjour, voici un aperçu de cette voix pour votre livre audio." + + +@app.post("/api/voicebank/preview") +async def preview_voice(body: PreviewBody): + from ..casting.voicebank import load_voicebank + from ..tts.base import VoiceSpec + + entry = load_voicebank().by_id(body.voice_id) + if entry is None: + raise HTTPException(404, "voix inconnue") + + def _synth() -> bytes: + from ..tts.factory import get_backend + backend = get_backend("kokoro") + audio, sr = backend.synthesize(body.text, VoiceSpec(preset=entry.kokoro_voice)) + buf = io.BytesIO() + sf.write(buf, audio, sr, format="WAV") + return buf.getvalue() + + data = await asyncio.to_thread(_synth) + return Response(content=data, media_type="audio/wav") + + +@app.get("/api/books/{slug}/audio/{index}") +def get_audio(slug: str, index: int): + state = load_state(slug) + rs = state.render.get(index) + if not rs or not rs.mp3: + raise HTTPException(404, "audio non genere") + path = book_output_dir(load_book(slug).title) / rs.mp3 + if not path.exists(): + raise HTTPException(404, "fichier introuvable") + return FileResponse(str(path), media_type="audio/mpeg", filename=rs.mp3) + + +# --- WebSocket --------------------------------------------------------------- + +@app.websocket("/ws/{slug}") +async def ws_endpoint(ws: WebSocket, slug: str) -> None: + await manager.connect(slug, ws) + try: + # Envoi de l'etat courant a la connexion. + await ws.send_json({"type": "state", "state": load_state(slug).model_dump(mode="json")}) + while True: + await ws.receive_text() # garde la connexion ouverte + except WebSocketDisconnect: + manager.disconnect(slug, ws) + except Exception: # noqa: BLE001 + manager.disconnect(slug, ws) + + +def _require(slug: str) -> None: + if not (book_data_dir(slug) / "book.json").exists(): + raise HTTPException(404, "livre inconnu") + + +# --- Service du frontend build (si present) ---------------------------------- +_FRONTEND_DIST = Path(__file__).resolve().parents[2].parent / "frontend" / "dist" +if _FRONTEND_DIST.exists(): + app.mount("/", StaticFiles(directory=str(_FRONTEND_DIST), html=True), name="ui") diff --git a/backend/inkflow/api/ws.py b/backend/inkflow/api/ws.py new file mode 100644 index 0000000..d776a47 --- /dev/null +++ b/backend/inkflow/api/ws.py @@ -0,0 +1,47 @@ +"""Gestionnaire de connexions WebSocket avec diffusion thread-safe. + +L'orchestrateur tourne dans un thread worker ; il appelle `broadcast_threadsafe` +qui replanifie l'envoi sur la boucle asyncio de l'API. +""" +from __future__ import annotations + +import asyncio +from collections import defaultdict +from typing import Optional + +from fastapi import WebSocket + + +class ConnectionManager: + def __init__(self) -> None: + self.active: dict[str, set[WebSocket]] = defaultdict(set) + self._loop: Optional[asyncio.AbstractEventLoop] = None + + def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None: + self._loop = loop + + async def connect(self, slug: str, ws: WebSocket) -> None: + await ws.accept() + self.active[slug].add(ws) + + def disconnect(self, slug: str, ws: WebSocket) -> None: + self.active[slug].discard(ws) + + def broadcast_threadsafe(self, slug: str, data: dict) -> None: + """Appelable depuis n'importe quel thread (worker orchestrateur).""" + if self._loop is None: + return + self._loop.call_soon_threadsafe(self._dispatch, slug, data) + + def _dispatch(self, slug: str, data: dict) -> None: + for ws in list(self.active.get(slug, ())): + asyncio.create_task(self._safe_send(slug, ws, data)) + + async def _safe_send(self, slug: str, ws: WebSocket, data: dict) -> None: + try: + await ws.send_json({"type": "state", "state": data}) + except Exception: # noqa: BLE001 — connexion fermee + self.disconnect(slug, ws) + + +manager = ConnectionManager() diff --git a/backend/inkflow/audio/__init__.py b/backend/inkflow/audio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/inkflow/audio/postprocess.py b/backend/inkflow/audio/postprocess.py new file mode 100644 index 0000000..54779b1 --- /dev/null +++ b/backend/inkflow/audio/postprocess.py @@ -0,0 +1,125 @@ +"""Assemblage audio final : concat -> normalisation -> WAV -> MP3 taggue. + +Pas de pydub (casse en Python 3.13) : concat/normalisation en numpy, encodage +mp3 + cover via ffmpeg CLI, tags via les metadonnees ffmpeg. +""" +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path +from typing import Optional + +import numpy as np +import soundfile as sf + +from ..settings import get_settings + + +def _resample(audio: np.ndarray, src_sr: int, dst_sr: int) -> np.ndarray: + if src_sr == dst_sr or audio.size == 0: + return audio + duration = audio.size / src_sr + n_dst = int(round(duration * dst_sr)) + x_src = np.linspace(0.0, duration, num=audio.size, endpoint=False) + x_dst = np.linspace(0.0, duration, num=n_dst, endpoint=False) + return np.interp(x_dst, x_src, audio).astype(np.float32) + + +def silence(seconds: float, sr: int) -> np.ndarray: + return np.zeros(int(seconds * sr), dtype=np.float32) + + +def concat_segments( + parts: list[tuple[np.ndarray, int]], + *, + target_sr: Optional[int] = None, + gap_seconds: float = 0.35, + intra_gap_seconds: float = 0.12, + glued: Optional[list[bool]] = None, +) -> tuple[np.ndarray, int]: + """Concatene des segments (audio, sr) avec un silence entre chacun. + + `glued[i] == True` (ex: une incise et sa replique, issues du meme paragraphe) + insere un silence court `intra_gap_seconds` au lieu de `gap_seconds`. + """ + if target_sr is None: + target_sr = get_settings().target_sample_rate + gap = silence(gap_seconds, target_sr) + intra_gap = silence(intra_gap_seconds, target_sr) + buf: list[np.ndarray] = [] + first = True + for i, (audio, sr) in enumerate(parts): + if audio is None or audio.size == 0: + continue + if not first: + use_intra = glued is not None and i < len(glued) and glued[i] + buf.append(intra_gap if use_intra else gap) + first = False + buf.append(_resample(np.asarray(audio, dtype=np.float32), sr, target_sr)) + if not buf: + return np.zeros(0, dtype=np.float32), target_sr + return np.concatenate(buf), target_sr + + +def normalize_loudness(audio: np.ndarray, target_dbfs: Optional[float] = None) -> np.ndarray: + """Normalise le niveau RMS vers target_dbfs, avec garde anti-saturation.""" + if audio.size == 0: + return audio + if target_dbfs is None: + target_dbfs = get_settings().target_dbfs + rms = float(np.sqrt(np.mean(audio.astype(np.float64) ** 2))) + if rms < 1e-6: + return audio + current_dbfs = 20.0 * np.log10(rms) + gain = 10.0 ** ((target_dbfs - current_dbfs) / 20.0) + out = audio * gain + peak = float(np.max(np.abs(out))) if out.size else 0.0 + if peak > 0.99: # limiteur simple pour eviter le clipping + out *= 0.99 / peak + return out.astype(np.float32) + + +def write_wav(path: str | Path, audio: np.ndarray, sr: int) -> Path: + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + sf.write(str(path), audio, sr) + return path + + +def encode_mp3( + wav_path: str | Path, + mp3_path: str | Path, + *, + bitrate: Optional[str] = None, + title: Optional[str] = None, + album: Optional[str] = None, + artist: Optional[str] = None, + track: Optional[int] = None, + cover_path: Optional[str | Path] = None, +) -> Path: + """Encode un WAV en MP3 (ffmpeg) avec tags ID3 et cover optionnelle.""" + if bitrate is None: + bitrate = get_settings().mp3_bitrate + if not shutil.which("ffmpeg"): + raise RuntimeError("ffmpeg introuvable — brew install ffmpeg") + wav_path, mp3_path = Path(wav_path), Path(mp3_path) + mp3_path.parent.mkdir(parents=True, exist_ok=True) + + cmd = ["ffmpeg", "-y", "-i", str(wav_path)] + has_cover = cover_path and Path(cover_path).exists() + if has_cover: + cmd += ["-i", str(cover_path), "-map", "0:a", "-map", "1:v", + "-c:v", "mjpeg", "-disposition:v", "attached_pic"] + cmd += ["-c:a", "libmp3lame", "-b:a", bitrate] + + meta = {"title": title, "album": album, "artist": artist} + if track is not None: + meta["track"] = str(track) + for key, val in meta.items(): + if val: + cmd += ["-metadata", f"{key}={val}"] + cmd += ["-id3v2_version", "3", str(mp3_path)] + + subprocess.run(cmd, check=True, capture_output=True) + return mp3_path diff --git a/backend/inkflow/casting/__init__.py b/backend/inkflow/casting/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/inkflow/casting/assign.py b/backend/inkflow/casting/assign.py new file mode 100644 index 0000000..2f2e5f4 --- /dev/null +++ b/backend/inkflow/casting/assign.py @@ -0,0 +1,86 @@ +"""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. +L'utilisateur pourra surcharger ces choix dans l'UI. +""" +from __future__ import annotations + +from collections import Counter +from typing import Optional + +from ..models import Cast, Character, Voicebank + +# Voix narrateur preferee (FR native). +PREFERRED_NARRATOR = "fr_f_siwis" + + +def _pick_pool(vb: Voicebank, gender: Optional[str], narrator_id: str) -> list[str]: + """Voix candidates : on privilegie STRICTEMENT le genre (quitte a reutiliser). + + 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. + """ + 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] + non_narrator = [vid for vid in pool if vid != narrator_id] + return non_narrator or pool # garde le narrateur seulement s'il est seul + + +def assign_voices( + characters: list[Character], + vb: Voicebank, + *, + narrator_voice_id: Optional[str] = None, + respect_existing: bool = False, +) -> Cast: + """Renvoie un Cast avec narrateur + voix par personnage (mutation des chars). + + `respect_existing=True` conserve les voix deja attribuees (overrides UI) ; + sinon tout est re-calcule (auto-casting frais). + """ + if not vb.entries: + return Cast(narrator_voice_id=narrator_voice_id, characters=characters) + + narrator_id = narrator_voice_id or ( + PREFERRED_NARRATOR if vb.by_id(PREFERRED_NARRATOR) else vb.entries[0].id) + + usage: Counter[str] = Counter() + usage[narrator_id] += 1 # le narrateur compte deja + + for ch in sorted(characters, key=lambda c: c.name.lower()): + 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) + # 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 + usage[best] += 1 + + return Cast(narrator_voice_id=narrator_id, characters=characters) + + +def resolve_speaker_voice( + speaker: str, cast: Cast, vb: Voicebank +) -> Optional[str]: + """Mappe un nom de locuteur (segment) vers un id de voix. + + Matche d'abord par nom/alias exact (rapide), puis en dernier recours par + rapprochement heuristique de tokens (ex: un "Jim" qui n'aurait pas encore + ete absorbe comme alias de "James Holden"). + """ + if speaker == "narrateur": + return cast.narrator_voice_id + low = speaker.lower() + for ch in cast.characters: + if ch.name.lower() == low or low in (a.lower() for a in ch.aliases): + return ch.voice_id + from .dedup import heuristic_match + match = heuristic_match(speaker, cast.characters) + if isinstance(match, Character): + return match.voice_id + return None # inconnu -> le rendu repliera sur le narrateur diff --git a/backend/inkflow/casting/dedup.py b/backend/inkflow/casting/dedup.py new file mode 100644 index 0000000..137c1d5 --- /dev/null +++ b/backend/inkflow/casting/dedup.py @@ -0,0 +1,345 @@ +"""Reconciliation du casting : deduplication des variantes de noms. + +Probleme : un meme personnage apparait sous plusieurs formes ("Holden", +"James Holden", "James", "Jim"). Sans reconciliation, chaque forme devient un +personnage distinct avec sa propre voix -> incoherence a l'ecoute. + +Strategie hybride : +1. Heuristique (sans LLM) : match exact sur nom/alias, puis sous-ensemble de + tokens ("Holden" contenu dans "James Holden"). +2. Gemma tranche les cas ambigus (plusieurs candidats compatibles, ou variante + non evidente type "Jim" <-> "James") a l'aide des descriptions. + +Chaque variante rencontree est conservee comme `alias` du personnage canonique ; +le nom canonique est la forme la plus complete vue ("James Holden"). Les +artefacts d'analyse (segments) ne sont PAS modifies : la resolution de voix au +rendu s'appuie sur les aliases (`casting/assign.py`). +""" +from __future__ import annotations + +import re +from typing import Optional + +from ..models import Character +from ..settings import get_settings + +# Sentinelles internes. +_AMBIGUOUS = object() # heuristique : plusieurs candidats -> on delegue a Gemma +_NEW = object() # decision Gemma : nouveau personnage + +# Mots vides / titres a ignorer pour le rapprochement par tokens. +_STOPWORDS = { + "le", "la", "les", "un", "une", "de", "du", "des", "monsieur", "madame", + "mademoiselle", "m", "mme", "mlle", "mr", "dr", "docteur", "capitaine", + "lieutenant", "sergent", "general", "amiral", "the", "of", +} +_SPLIT_RE = re.compile(r"[^\wÀ-ÿ]+") + +# Garde-fou de contexte (caracteres) pour le prompt Gemma. +_MAX_PROMPT_CHARS = 24000 + + +def _norm(name: str) -> str: + return name.strip().lower() + + +def _tokens(name: str) -> set[str]: + """Tokens significatifs d'un nom (minuscules, sans titres ni mots vides).""" + parts = [p for p in _SPLIT_RE.split(name.strip()) if p] + return {p.lower() for p in parts + if len(p) >= 2 and p.lower() not in _STOPWORDS} + + +def _completeness(name: str) -> tuple[int, int]: + """Cle de tri du nom le plus "complet" : plus de tokens, puis plus long.""" + return (len(_tokens(name)), len(name.strip())) + + +def _forms(c: Character) -> list[str]: + return [c.name, *c.aliases] + + +def _token_freq(characters: list[Character], extra: Optional[list[str]] = None): + """Compte, pour chaque token, le nb de surfaces distinctes le contenant. + + Sert a juger la distinctivite d'un token : "holden" present dans une seule + famille est sur a fusionner ; "alex" present dans plusieurs ne l'est pas. + """ + from collections import Counter + freq: Counter[str] = Counter() + surfaces = {_norm(f) for c in characters for f in _forms(c)} + surfaces |= {_norm(s) for s in (extra or [])} + for s in surfaces: + for t in _tokens(s): + freq[t] += 1 + return freq + + +def heuristic_match(surface: str, characters: list[Character], tokfreq=None): + """Rapproche `surface` d'un personnage connu sans LLM (conservateur). + + Renvoie le `Character` correspondant, `None` si aucun, ou `_AMBIGUOUS` si le + rapprochement est plausible mais incertain (decision laissee a Gemma). + + Un lien par sous-ensemble de tokens n'est considere SUR que si le plus petit + cote a >=2 tokens, ou si les tokens partages sont globalement distinctifs + (presents dans <=2 surfaces). Sinon le lien est ambigu (ex: un prenom + courant "Alex" partage par plusieurs personnages). + """ + s_norm = _norm(surface) + for c in characters: + if _norm(c.name) == s_norm or any(_norm(a) == s_norm for a in c.aliases): + return c + s_tok = _tokens(surface) + if not s_tok: + return None + if tokfreq is None: + tokfreq = _token_freq(characters, [surface]) + + safe: list[Character] = [] + ambiguous = False + for c in characters: + linked = is_safe = False + for form in _forms(c): + f_tok = _tokens(form) + if not f_tok or not (s_tok <= f_tok or f_tok <= s_tok): + continue + linked = True + shared = s_tok & f_tok + if min(len(s_tok), len(f_tok)) >= 2 or all(tokfreq[t] <= 2 for t in shared): + is_safe = True + if is_safe: + safe.append(c) + elif linked: + ambiguous = True + if len(safe) == 1 and not ambiguous: + return safe[0] + if safe or ambiguous: + return _AMBIGUOUS + return None + + +def canonical_of(a: str, b: str) -> str: + """Forme canonique entre deux variantes : la plus complete.""" + return a if _completeness(a) >= _completeness(b) else b + + +def _absorb( + target: Character, + name: str, + *, + gender: Optional[str] = None, + age: Optional[str] = None, + description: Optional[str] = None, + voice_id: Optional[str] = None, +) -> 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. + """ + target.gender = target.gender or gender + target.age = target.age or age + target.description = target.description or description + target.voice_id = target.voice_id or voice_id + + forms: dict[str, str] = {} # norm -> graphie d'origine (1re vue conservee) + for f in [target.name, *target.aliases, name]: + f = (f or "").strip() + if f: + forms.setdefault(_norm(f), f) + canon = 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) + + +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, + "description": None, "voice_id": None} + + +def _find(chars: list[Character], name: str) -> Optional[Character]: + n = _norm(name) + return next((c for c in chars + if _norm(c.name) == n or any(_norm(a) == n for a in c.aliases)), + None) + + +def _create(chars: list[Character], it: dict, name_map: dict[str, str]) -> None: + new = Character(name=it["name"].strip(), gender=it["gender"], age=it["age"], + description=it["description"], voice_id=it["voice_id"]) + chars.append(new) + name_map[_norm(it["name"])] = new.name + + +def reconcile_characters( + book_chars: list[Character], + new_chars, + gemma=None, + *, + speaker_names: Optional[list[str]] = None, +) -> tuple[list[Character], dict[str, str]]: + """Reconcilie de nouvelles detections dans le casting du livre. + + `new_chars` : personnages extraits (objets `Character`) du/des chapitre(s). + `speaker_names` : formes de locuteur brutes vues dans les segments (absorbees + comme aliases pour que la resolution de voix matche au rendu). + `gemma` : si fourni, tranche les cas ambigus ; sinon heuristique seule. + + Renvoie (liste canonique mise a jour, map nom_surface_normalise -> canonique). + """ + chars = [c.model_copy(deep=True) for c in book_chars] + name_map: dict[str, str] = {} + + items = [_item(c) for c in new_chars] + seen = {_norm(it["name"]) for it in items} + for sp in (speaker_names or []): + n = _norm(sp or "") + if n and n not in seen and n not in {"narrateur", "inconnu", "?"}: + items.append(_item(sp)) + seen.add(n) + + # Fréquence globale des tokens (base + entrants) -> distinctivite stable, + # independante de l'ordre de traitement. + tokfreq = _token_freq(chars, [it["name"] for it in items]) + + pending: list[dict] = [] + for it in items: + m = heuristic_match(it["name"], chars, tokfreq) + if m is _AMBIGUOUS: + 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"]) + name_map[_norm(it["name"])] = m.name + elif gemma is not None: + pending.append(it) # peut etre une variante non evidente ("Jim") + else: + _create(chars, it, name_map) + + if pending and gemma is not None: + decisions = _gemma_reconcile(chars, pending, gemma) + for it in pending: + canon = decisions.get(_norm(it["name"])) + target = _find(chars, canon) if isinstance(canon, str) else None + if target is None: # Gemma dit NOUVEAU/inconnu : ultime essai heuristique + hm = heuristic_match(it["name"], chars, tokfreq) + 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"]) + name_map[_norm(it["name"])] = target.name + else: + _create(chars, it, name_map) + elif pending: + # Sans Gemma : on ne devine pas les cas ambigus, on les garde distincts. + for it in pending: + _create(chars, it, name_map) + + return chars, name_map + + +def dedup_cast(characters: list[Character], gemma=None) -> list[Character]: + """Replie les doublons d'un casting existant (conserve les voix attribuees). + + Deux phases : (1) regroupement heuristique sur (gemma=None) -> liste reduite + et sure ; (2) si `gemma` fourni, passe de regroupement Gemma sur les seuls + noms candidats (partageant un token avec un autre), pour fusionner les + variantes que l'heuristique laisse de cote (ex: "Okoye" -> "Elvi Okoye"). + """ + base, _ = reconcile_characters([], characters, gemma=None) + if gemma is None: + return base + return _gemma_merge_pass(base, gemma) + + +def _gemma_merge_pass(base: list[Character], gemma) -> list[Character]: + """Rattache via Gemma les formes courtes a un nom complet (ancre). + + Tache volontairement contrainte (et plus fiable qu'un regroupement libre) : + une "forme courte" est un nom dont les tokens sont strictement inclus dans + ceux d'un autre (ex: "Okoye" vs "Elvi Okoye"). Gemma mappe chaque forme + courte vers le nom canonique EXACT d'une ancre, ou "NOUVEAU". Traite par + petits lots pour rester dans la zone de fiabilite du modele. + """ + shorts: list[Character] = [] + anchors: list[Character] = [] + for i, c in enumerate(base): + ts = _tokens(c.name) + if ts and any(j != i and ts < _tokens(d.name) for j, d in enumerate(base)): + shorts.append(c) + else: + anchors.append(c) + if not shorts: + return base + + result = [a.model_copy(deep=True) for a in anchors] + leftovers: list[Character] = [] + for start in range(0, len(shorts), 12): + chunk = shorts[start:start + 12] + decisions = _gemma_reconcile(result, [_item(s) for s in chunk], gemma) + for s in chunk: + canon = decisions.get(_norm(s.name)) + tgt = _find(result, canon) if isinstance(canon, str) else None + if tgt is None: + hm = heuristic_match(s.name, result) + tgt = hm if isinstance(hm, Character) else None + # Garde-fou : ne pas fusionner deux genres connus opposes. + if tgt is not None and s.gender and tgt.gender and s.gender != tgt.gender: + tgt = None + if tgt is not None: + _absorb(tgt, s.name, gender=s.gender, age=s.age, + description=s.description, voice_id=s.voice_id) + for a in s.aliases: + _absorb(tgt, a) + else: + leftovers.append(s) + return result + leftovers + + +def _gemma_reconcile( + chars: list[Character], pending: list[dict], gemma +) -> dict[str, object]: + """Un appel groupe : pour chaque nom en attente, son canonique ou _NEW.""" + known = [] + for c in chars: + al = f" (alias: {', '.join(c.aliases)})" if c.aliases else "" + desc = f" — {c.description}" if c.description else "" + known.append(f"- {c.name}{al}{desc}") + new_lines = [] + for n, it in enumerate(pending): + desc = f" — {it['description']}" if it.get("description") else "" + new_lines.append(f"[{n}] {it['name']}{desc}") + + prompt = ( + "Personnages DEJA connus du livre :\n" + + ("\n".join(known) if known else "(aucun)") + + "\n\nNoms DETECTES a classer :\n" + "\n".join(new_lines) + + "\n\nPour chaque nom detecte, indique s'il designe un personnage deja " + "connu (donne alors son nom canonique EXACT tel qu'ecrit ci-dessus) ou " + "s'il s'agit d'un nouveau personnage (\"NOUVEAU\"). Ne fusionne que si " + "c'est, avec certitude, la meme personne. EN CAS DE DOUTE, ou si " + "plusieurs personnages connus pourraient correspondre, reponds " + "\"NOUVEAU\". Ne rapproche jamais deux personnes differentes qui " + "partagent seulement un prenom ou un nom de famille.\n\n" + 'Reponds par un tableau JSON: ' + '[{"i":0,"canonical":"James Holden"},{"i":1,"canonical":"NOUVEAU"}]' + ) + if len(prompt) > _MAX_PROMPT_CHARS: + prompt = prompt[:_MAX_PROMPT_CHARS] + result = gemma.generate_json(prompt, system=get_settings().prompt_dedup) + + decisions: dict[str, object] = {} + for item in result: + if not isinstance(item, dict) or "i" not in item: + continue + n = item["i"] + canon = str(item.get("canonical") or "").strip() + if isinstance(n, int) and 0 <= n < len(pending) and canon: + decisions[_norm(pending[n]["name"])] = ( + _NEW if canon.upper() == "NOUVEAU" else canon) + return decisions diff --git a/backend/inkflow/casting/voicebank.py b/backend/inkflow/casting/voicebank.py new file mode 100644 index 0000000..cfc650c --- /dev/null +++ b/backend/inkflow/casting/voicebank.py @@ -0,0 +1,91 @@ +"""Banque de voix : un jeu de voix variees (genre/age) auto-suffisant. + +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. + +Resolution moteur : +- Kokoro -> VoiceSpec(preset=kokoro_voice) (rapide, preview / draft) +- Qwen3 -> VoiceSpec(ref_audio=clip, ref_text=…) (qualite, clonage) +""" +from __future__ import annotations + +from pathlib import Path + +import soundfile as sf + +from ..config import VOICEBANK_DIR +from ..models import VoiceEntry, Voicebank +from ..tts.base import VoiceSpec + +# Passage de reference lu par chaque voix pour creer son clip de clonage. +REFERENCE_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." +) + +# Jeu de voix par defaut (varie en genre). ff_siwis est la seule voix FR native ; +# les autres empruntent un timbre anglais mais lisent un texte phonemise en FR. +SEED: list[VoiceEntry] = [ + VoiceEntry(id="fr_f_siwis", kokoro_voice="ff_siwis", gender="female", age="adult", label="Siwis (FR)"), + VoiceEntry(id="f_bella", kokoro_voice="af_bella", gender="female", age="adult", label="Bella"), + VoiceEntry(id="f_heart", kokoro_voice="af_heart", gender="female", age="young", label="Heart"), + VoiceEntry(id="f_emma", kokoro_voice="bf_emma", gender="female", age="adult", label="Emma"), + VoiceEntry(id="f_nicole", kokoro_voice="af_nicole", gender="female", age="adult", label="Nicole"), + VoiceEntry(id="m_fenrir", kokoro_voice="am_fenrir", gender="male", age="adult", label="Fenrir"), + VoiceEntry(id="m_michael", kokoro_voice="am_michael", gender="male", age="adult", label="Michael"), + VoiceEntry(id="m_george", kokoro_voice="bm_george", gender="male", age="adult", label="George"), + VoiceEntry(id="m_lewis", kokoro_voice="bm_lewis", gender="male", age="adult", label="Lewis"), + VoiceEntry(id="m_eric", kokoro_voice="am_eric", gender="male", age="young", label="Eric"), + VoiceEntry(id="m_santa", kokoro_voice="am_santa", gender="male", age="old", label="Santa"), +] + + +def metadata_path() -> Path: + return VOICEBANK_DIR / "metadata.json" + + +def clips_dir() -> Path: + return VOICEBANK_DIR / "clips" + + +def load_voicebank() -> Voicebank: + path = metadata_path() + if path.exists(): + return Voicebank.model_validate_json(path.read_text(encoding="utf-8")) + return Voicebank(entries=list(SEED)) + + +def save_voicebank(vb: Voicebank) -> Path: + VOICEBANK_DIR.mkdir(parents=True, exist_ok=True) + metadata_path().write_text(vb.model_dump_json(indent=2), encoding="utf-8") + return metadata_path() + + +def build_voicebank(*, regenerate: bool = False) -> Voicebank: + """Genere les clips de reference manquants et ecrit metadata.json.""" + from ..tts.kokoro import KokoroBackend + + clips_dir().mkdir(parents=True, exist_ok=True) + backend = KokoroBackend() + entries: list[VoiceEntry] = [] + for seed in SEED: + clip_rel = f"clips/{seed.id}.wav" + clip_abs = VOICEBANK_DIR / clip_rel + if regenerate or not clip_abs.exists(): + audio, sr = backend.synthesize(REFERENCE_TEXT, VoiceSpec(preset=seed.kokoro_voice)) + sf.write(str(clip_abs), audio, sr) + entry = seed.model_copy(update={"ref_audio": clip_rel, "ref_text": REFERENCE_TEXT}) + entries.append(entry) + vb = Voicebank(entries=entries) + save_voicebank(vb) + return vb + + +def voice_spec_for(entry: VoiceEntry, engine: str, *, speed: float = 1.0) -> VoiceSpec: + """Construit la VoiceSpec adaptee au moteur cible.""" + if engine == "qwen3" and entry.ref_audio: + ref_abs = str(VOICEBANK_DIR / entry.ref_audio) + return VoiceSpec(ref_audio=ref_abs, ref_text=entry.ref_text, speed=speed) + return VoiceSpec(preset=entry.kokoro_voice, speed=speed) diff --git a/backend/inkflow/cli.py b/backend/inkflow/cli.py new file mode 100644 index 0000000..f08d947 --- /dev/null +++ b/backend/inkflow/cli.py @@ -0,0 +1,239 @@ +"""CLI InkFlow (typer). + +Commandes : +- parse : EPUB -> book.json + chapters/chNN.json +- analyze : analyse Gemma d'un (ou de tous les) chapitre(s) -> analysis + cast +- info : affiche la structure d'un livre deja parse +""" +from __future__ import annotations + +from typing import Optional + +import typer +from rich.console import Console +from rich.table import Table + +from .config import ensure_dirs +from .epub.parser import load_book, load_chapter_text, parse_epub +from .models import Cast +from .store import artifacts + +app = typer.Typer(add_completion=False, help="InkFlow : EPUB -> livre audio (local, MLX).") +console = Console() + + +@app.command() +def parse(epub_path: str, slug: Optional[str] = typer.Option(None, help="Slug interne (def: depuis le titre).")): + """Parse un EPUB en structure normalisee.""" + ensure_dirs() + book = parse_epub(epub_path, slug=slug) + console.print(f"[green]Parse:[/] {book.title} — slug=[cyan]{book.slug}[/]") + console.print(f" {len(book.chapters)} items, {len(book.render_chapters)} a rendre.") + _print_chapters(book) + + +@app.command() +def info(slug: str): + """Affiche la structure d'un livre deja parse.""" + _print_chapters(load_book(slug)) + + +@app.command() +def serve(host: str = "127.0.0.1", port: int = 8000): + """Lance l'API + l'UI web (sert frontend/dist si build).""" + import uvicorn + ensure_dirs() + console.print(f"[green]InkFlow[/] sur http://{host}:{port}") + uvicorn.run("inkflow.api.app:app", host=host, port=port, log_level="info") + + +@app.command() +def analyze( + slug: str, + 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."), +): + """Analyse Gemma : segments narration/dialogue + locuteurs + casting.""" + from .analysis.gemma import Gemma + from .analysis.segmenter import analyze_chapter + from .settings import get_settings + + book = load_book(slug) + gemma = Gemma() + dedup_gemma = gemma if get_settings().dedup_use_gemma else None + cast = artifacts.load_cast(slug) + chars = list(cast.characters) + + targets = [c for c in book.render_chapters] + if chapter is not None: + targets = [c for c in book.chapters if c.index == chapter] + elif limit: + targets = targets[:limit] + + for ch in targets: + if not force and artifacts.analysis_path(slug, ch.index).exists(): + console.print(f"[dim]ch{ch.index:02d} deja analyse — ignore.[/]") + continue + ct = load_chapter_text(slug, ch) + console.print(f"[blue]Analyse[/] ch{ch.index:02d} — {ch.title} ({ct.word_count} mots)…") + try: + # La dedup est faite dans analyze_chapter : `chars` recoit le cast + # cumule reconcilie. + analysis, chars = analyze_chapter( + ch, ct, gemma, book_chars=chars, dedup_gemma=dedup_gemma) + except Exception as exc: # noqa: BLE001 — un chapitre ne doit pas tout stopper + console.print(f" [yellow]! echec, chapitre ignore: {exc}[/]") + continue + artifacts.save_analysis(slug, analysis) + n_dlg = sum(1 for s in analysis.segments if s.type.value == "dialogue") + console.print(f" -> {len(analysis.segments)} segments ({n_dlg} repliques), " + f"{len(chars)} personnages cumules.") + + cast = Cast(narrator_voice_id=cast.narrator_voice_id, characters=chars) + artifacts.save_cast(slug, cast) + console.print(f"[green]Casting[/] : {len(chars)} personnages -> cast.json") + + +@app.command() +def pronounce( + slug: str, + chapter: Optional[int] = typer.Option(None, help="Index de chapitre (def: 1er rendu)."), +): + """Propose des candidats de prononciation (Gemma) -> pronunciation.json.""" + from .analysis.gemma import Gemma + from .analysis.pronunciation import merge_pronunciations, propose_pronunciations + + book = load_book(slug) + ch = (next((c for c in book.chapters if c.index == chapter), None) + if chapter is not None else (book.render_chapters[0] if book.render_chapters else None)) + if ch is None or not ch.text_file: + console.print("[red]Chapitre introuvable.[/]"); raise typer.Exit(1) + + ct = load_chapter_text(slug, ch) + gemma = Gemma() + with console.status("Recherche des mots a risque…"): + new = propose_pronunciations("\n".join(ct.paragraphs), gemma) + pron = merge_pronunciations(artifacts.load_pronunciation(slug), new) + artifacts.save_pronunciation(slug, pron) + + table = Table("terme", "prononciation", "note") + for e in pron.entries: + table.add_row(e.term, e.replacement, e.note or "") + console.print(table) + console.print(f"[green]{len(pron.entries)} entrees[/] -> pronunciation.json") + + +@app.command() +def cast( + slug: str, + 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)."), +): + """Construit la voicebank (si besoin) et auto-assigne les voix au casting.""" + from .casting.assign import assign_voices + from .casting.voicebank import build_voicebank, load_voicebank + + cast = artifacts.load_cast(slug) + if not cast.characters: + console.print("[yellow]Aucun personnage — lance d'abord `analyze`.[/]") + raise typer.Exit(1) + + if dedup: + from .casting.dedup import dedup_cast + from .models import Cast + gemma = None + if llm: + from .analysis.gemma import Gemma + gemma = Gemma() + before = len(cast.characters) + with console.status("Deduplication du casting…"): + chars = dedup_cast(cast.characters, gemma) + cast = Cast(narrator_voice_id=cast.narrator_voice_id, characters=chars) + artifacts.save_cast(slug, cast) + console.print(f"[green]Dedup[/] : {before} -> {len(chars)} personnages.") + + vb = load_voicebank() + if rebuild_voicebank or not vb.entries or not any(e.ref_audio for e in vb.entries): + with console.status("Generation des clips de la voicebank…"): + vb = build_voicebank(regenerate=rebuild_voicebank) + console.print(f"[green]Voicebank[/] : {len(vb.entries)} voix, clips generes.") + + cast = assign_voices(cast.characters, vb, narrator_voice_id=cast.narrator_voice_id) + artifacts.save_cast(slug, cast) + + table = Table("personnage", "genre", "voix") + table.add_row("[narrateur]", "", cast.narrator_voice_id or "") + for ch in cast.characters: + table.add_row(ch.name, ch.gender or "?", ch.voice_id or "") + console.print(table) + + +@app.command() +def render( + slug: str, + chapter: int = typer.Argument(..., help="Index du chapitre a synthetiser."), + backend: str = typer.Option("kokoro", help="Moteur TTS: kokoro | qwen3."), + mono: bool = typer.Option(True, help="Mono-narrateur (sinon multi-voix via cast)."), + max_paragraphs: Optional[int] = typer.Option(None, help="Limiter (test rapide)."), +): + """Synthetise un chapitre en MP3 dans output//.""" + from .pipeline.render import ( + build_units_mono, + build_units_multi, + render_chapter_to_mp3, + ) + from .tts.base import VoiceSpec + from .tts.factory import get_backend + + book = load_book(slug) + ch = next((c for c in book.chapters if c.index == chapter), None) + if ch is None or not ch.text_file: + console.print(f"[red]Chapitre {chapter} introuvable ou non rendu.[/]") + raise typer.Exit(1) + + ct = load_chapter_text(slug, ch) + if max_paragraphs: + ct.paragraphs = ct.paragraphs[:max_paragraphs] + tts = get_backend(backend) + pron = artifacts.load_pronunciation(slug) + + if mono: + units = build_units_mono(ct, tts.default_voice()) + else: + from .casting.voicebank import load_voicebank, voice_spec_for + from .pipeline.render import make_voice_resolver + + analysis = artifacts.load_analysis(slug, chapter) + cast_data = artifacts.load_cast(slug) + vb = load_voicebank() + # Voix narrateur par defaut depuis la voicebank si disponible. + narrator_entry = vb.by_id(cast_data.narrator_voice_id) if cast_data.narrator_voice_id else None + default_voice = (voice_spec_for(narrator_entry, backend) + if narrator_entry else tts.default_voice()) + resolver = make_voice_resolver(cast_data, vb, backend) + units = build_units_multi(analysis, resolver, default_voice) + + with console.status(f"Synthese de {len(units)} unites ({backend})…"): + def _p(done, total): + console.print(f" unite {done}/{total}", end="\r") + track = (book.render_chapters.index(ch) + 1) if ch in book.render_chapters else None + mp3 = render_chapter_to_mp3(book, ch, units, tts, pron=pron, track=track, progress=_p) + console.print(f"\n[green]MP3:[/] {mp3}") + + +def _print_chapters(book) -> None: + table = Table(show_header=True, header_style="bold") + for col in ("idx", "kind", "render", "pov", "mots", "sortie", "titre"): + table.add_column(col) + for c in book.chapters: + table.add_row( + str(c.index), c.kind.value, "✓" if c.render else "·", + c.pov or "", str(c.word_count), c.output_name or "", + c.title) + console.print(table) + + +if __name__ == "__main__": + app() diff --git a/backend/inkflow/config.py b/backend/inkflow/config.py new file mode 100644 index 0000000..38029ef --- /dev/null +++ b/backend/inkflow/config.py @@ -0,0 +1,96 @@ +"""Configuration centrale d'InkFlow. + +Toutes les constantes (chemins, identifiants de modeles MLX, parametres par +defaut) sont regroupees ici pour rester facilement surchargeables via variables +d'environnement. +""" +from __future__ import annotations + +import os +from pathlib import Path + +# --- Racines du projet ------------------------------------------------------- +# config.py est dans backend/inkflow/, la racine projet est donc deux niveaux +# au-dessus de backend/. +BACKEND_DIR = Path(__file__).resolve().parents[1] +PROJECT_ROOT = BACKEND_DIR.parent + + +def _env_path(var: str, default: Path) -> Path: + return Path(os.environ.get(var, default)).expanduser().resolve() + + +# Donnees de travail (etat par livre : json, db, wav intermediaires) +DATA_DIR = _env_path("INKFLOW_DATA_DIR", PROJECT_ROOT / "data") +# Sortie finale (1 dossier par livre, 1 mp3 par chapitre) +OUTPUT_DIR = _env_path("INKFLOW_OUTPUT_DIR", PROJECT_ROOT / "output") +# Banque de voix de reference (clips + metadata.json) +VOICEBANK_DIR = _env_path("INKFLOW_VOICEBANK_DIR", PROJECT_ROOT / "voicebank") +# Echantillons fournis +SAMPLES_DIR = PROJECT_ROOT / "samples" + +# --- Modeles MLX (HuggingFace mlx-community) --------------------------------- +# Analyse de texte : Gemma via mlx-lm. +GEMMA_MODEL = os.environ.get( + "INKFLOW_GEMMA_MODEL", "mlx-community/gemma-3-4b-it-4bit" +) + +# TTS : Qwen3-TTS (rendu final, clonage) et Kokoro (preview rapide). +QWEN3_TTS_MODEL = os.environ.get( + "INKFLOW_QWEN3_MODEL", "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-8bit" +) +KOKORO_MODEL = os.environ.get( + "INKFLOW_KOKORO_MODEL", "mlx-community/Kokoro-82M-bf16" +) + +# --- Parametres TTS ---------------------------------------------------------- +DEFAULT_LANGUAGE = os.environ.get("INKFLOW_LANGUAGE", "French") +# Code langue Kokoro (misaki) : 'f' = francais. +KOKORO_LANG_CODE = os.environ.get("INKFLOW_KOKORO_LANG", "f") +# Voix Kokoro par defaut pour les previews / mono-narrateur rapide. +KOKORO_DEFAULT_VOICE = os.environ.get("INKFLOW_KOKORO_VOICE", "ff_siwis") +# Voix Qwen3 par defaut (narrateur) si aucun clip de reference fourni. +QWEN3_DEFAULT_VOICE = os.environ.get("INKFLOW_QWEN3_VOICE", "Chelsie") + +# Frequence d'echantillonnage cible pour la concatenation (Hz). Les backends +# renvoient leur propre sr ; postprocess reechantillonne au besoin. +TARGET_SAMPLE_RATE = int(os.environ.get("INKFLOW_SAMPLE_RATE", "24000")) + +# Encodage mp3 final. +MP3_BITRATE = os.environ.get("INKFLOW_MP3_BITRATE", "128k") +# Cible de normalisation loudness (LUFS approx via pydub gain). +TARGET_DBFS = float(os.environ.get("INKFLOW_TARGET_DBFS", "-18.0")) + + +def book_data_dir(book_slug: str) -> Path: + """Dossier de travail pour un livre (artefacts intermediaires).""" + return DATA_DIR / book_slug + + +def book_output_dir(book_title: str) -> Path: + """Dossier de sortie final pour un livre (mp3 par chapitre).""" + return OUTPUT_DIR / book_title + + +def ensure_dirs() -> None: + for d in (DATA_DIR, OUTPUT_DIR, VOICEBANK_DIR): + d.mkdir(parents=True, exist_ok=True) + + +def setup_espeak() -> None: + """Localise libespeak-ng pour phonemizer (requis par Kokoro non-anglais). + + phonemizer ne trouve pas toujours la lib installee via brew ; on pointe + explicitement PHONEMIZER_ESPEAK_LIBRARY si la variable n'est pas deja fixee. + """ + if os.environ.get("PHONEMIZER_ESPEAK_LIBRARY"): + return + candidates = [ + "/opt/homebrew/lib/libespeak-ng.dylib", + "/usr/local/lib/libespeak-ng.dylib", + "/opt/homebrew/lib/libespeak-ng.1.dylib", + ] + for path in candidates: + if os.path.exists(path): + os.environ["PHONEMIZER_ESPEAK_LIBRARY"] = path + return diff --git a/backend/inkflow/epub/__init__.py b/backend/inkflow/epub/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/inkflow/epub/parser.py b/backend/inkflow/epub/parser.py new file mode 100644 index 0000000..62dff32 --- /dev/null +++ b/backend/inkflow/epub/parser.py @@ -0,0 +1,267 @@ +"""Parsing EPUB -> structure de livre normalisee. + +Strategie : +- ebooklib lit l'archive (manifest + spine + ncx). +- L'ordre de lecture vient du spine. +- Les titres viennent de la table des matieres (ncx/nav), mappes par href. +- Le texte de chaque document est extrait via BeautifulSoup (paragraphes). +- On classe chaque item en front / chapter / back et on decide s'il faut le lire. + +Sorties ecrites dans data// : +- book.json : metadonnees + liste des chapitres (modele Book) +- chapters/chNN.json : texte normalise par chapitre (modele ChapterText) +- cover. : couverture extraite (si presente) +""" +from __future__ import annotations + +import re +import warnings +from pathlib import Path +from typing import Optional +from urllib.parse import unquote, urldefrag + +import ebooklib +from bs4 import BeautifulSoup +from ebooklib import epub + +# Les xhtml d'epub declenchent un avertissement bs4 inoffensif ; on le tait. +try: + from bs4 import XMLParsedAsHTMLWarning + warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning) +except ImportError: # pragma: no cover + pass + +from ..config import book_data_dir +from ..models import Book, Chapter, ChapterKind, ChapterText +from ..util import safe_filename, slugify + +# Un titre de chapitre commence par un numero, PROLOGUE ou EPILOGUE. +_CHAPTER_RE = re.compile(r"^\s*(\d+|prologue|[ée]pilogue)\b", re.IGNORECASE) +# Capture " - " ou juste "". +_TITLE_PARTS_RE = re.compile(r"^\s*([^-\n]+?)(?:\s*[-–—]\s*(.+))?\s*$") + +# Seuil de mots pour qu'un element de back matter (remerciements...) soit lu. +_BACK_MATTER_MIN_WORDS = 40 + + +def _build_toc_titles(book: epub.EpubBook) -> dict[str, str]: + """Mappe href (sans fragment) -> titre, en aplatissant la toc ncx/nav.""" + titles: dict[str, str] = {} + + def walk(items) -> None: + for it in items: + if isinstance(it, tuple): # (Section, [children]) + section, children = it + if isinstance(section, epub.Link): + _add(section) + walk(children) + elif isinstance(it, list): + walk(it) + elif isinstance(it, epub.Link): + _add(it) + + def _add(link: epub.Link) -> None: + href = unquote(urldefrag(link.href)[0]) + if href and href not in titles and link.title: + titles[href] = link.title.strip() + + walk(book.toc) + return titles + + +def _extract_paragraphs(html: bytes) -> list[str]: + """Extrait les paragraphes lisibles d'un document xhtml.""" + soup = BeautifulSoup(html, "lxml") + # Retire les elements non narratifs. + for tag in soup(["script", "style", "sup", "table"]): + tag.decompose() + + paragraphs: list[str] = [] + blocks = soup.find_all(["p", "h1", "h2", "h3", "h4", "blockquote", "li"]) + if not blocks and soup.body: + blocks = [soup.body] + + for block in blocks: + text = block.get_text(" ", strip=True) + text = re.sub(r"\s+", " ", text).strip() + if text: + paragraphs.append(text) + return paragraphs + + +def _parse_title(title: str) -> tuple[Optional[str], Optional[str]]: + """Decoupe un titre de chapitre en (numero, pov).""" + m = _TITLE_PARTS_RE.match(title) + if not m: + return None, None + number = (m.group(1) or "").strip() or None + pov = (m.group(2) or "").strip() or None + return number, pov + + +def _output_name(seq: int, kind: ChapterKind, number: Optional[str], title: str) -> str: + """Nom de mp3 calque sur le format du sample (NN-.mp3).""" + prefix = f"{seq:02d}" + label: str + if kind is ChapterKind.CHAPTER and number: + low = number.lower() + if low == "prologue": + label = "Prologue" + elif low in ("epilogue", "épilogue"): + label = "Épilogue" + elif number.isdigit(): + label = f"Chapitre {int(number)}" + else: + label = number.capitalize() + else: + label = title + if label.isupper(): # titres tout-majuscule (ex "REMERCIEMENTS") + label = label.capitalize() + return safe_filename(f"{prefix}-{label}") + ".mp3" + + +def _classify(ordered: list[dict]) -> None: + """Affecte kind/render a chaque item (mutation en place). + + front = avant le premier chapitre numerote (couverture, page de titre...) + chapter = correspond au motif de titre de chapitre + back = apres le dernier chapitre (remerciements, glossaire...) + """ + chapter_idxs = [ + i for i, it in enumerate(ordered) + if it["title"] and _CHAPTER_RE.match(it["title"]) + ] + first = chapter_idxs[0] if chapter_idxs else len(ordered) + last = chapter_idxs[-1] if chapter_idxs else -1 + + for i, it in enumerate(ordered): + is_chapter = bool(it["title"]) and bool(_CHAPTER_RE.match(it["title"])) + if is_chapter: + it["kind"] = ChapterKind.CHAPTER + it["render"] = it["word_count"] > 0 + elif i < first: + it["kind"] = ChapterKind.FRONT + it["render"] = False + else: # i > last (back matter) + it["kind"] = ChapterKind.BACK + it["render"] = it["word_count"] >= _BACK_MATTER_MIN_WORDS + + +def _extract_cover(book: epub.EpubBook, dest_dir: Path) -> Optional[str]: + cover_item = None + for item in book.get_items_of_type(ebooklib.ITEM_COVER): + cover_item = item + break + if cover_item is None: # fallback : item nomme "cover" + for item in book.get_items_of_type(ebooklib.ITEM_IMAGE): + if "cover" in item.get_name().lower(): + cover_item = item + break + if cover_item is None: + return None + ext = Path(cover_item.get_name()).suffix or ".jpg" + dest = dest_dir / f"cover{ext}" + dest.write_bytes(cover_item.get_content()) + return dest.name + + +def parse_epub(epub_path: str | Path, slug: Optional[str] = None) -> Book: + """Parse un EPUB et ecrit book.json + chapters/chNN.json dans data//.""" + epub_path = Path(epub_path) + book_ml = epub.read_epub(str(epub_path), options={"ignore_ncx": False}) + + title = _meta(book_ml, "title") or epub_path.stem + author = _meta(book_ml, "creator") + description = _meta(book_ml, "description") + language = _meta(book_ml, "language") or "fr" + slug = slug or slugify(title) + + data_dir = book_data_dir(slug) + chapters_dir = data_dir / "chapters" + chapters_dir.mkdir(parents=True, exist_ok=True) + + toc_titles = _build_toc_titles(book_ml) + + # Documents dans l'ordre du spine. + id_to_item = {it.get_id(): it for it in book_ml.get_items()} + ordered: list[dict] = [] + for idref, _linear in book_ml.spine: + item = id_to_item.get(idref) + if item is None or item.get_type() != ebooklib.ITEM_DOCUMENT: + continue + href = unquote(item.get_name()) + paragraphs = _extract_paragraphs(item.get_content()) + title_txt = toc_titles.get(href, "") + ordered.append({ + "item_id": idref, + "src": href, + "title": title_txt, + "paragraphs": paragraphs, + "word_count": sum(len(p.split()) for p in paragraphs), + }) + + _classify(ordered) + + cover_file = _extract_cover(book_ml, data_dir) + + chapters: list[Chapter] = [] + seq = 0 # compteur de prefixe sur les seuls chapitres rendus + for index, it in enumerate(ordered): + number = pov = None + if it["kind"] is ChapterKind.CHAPTER: + number, pov = _parse_title(it["title"]) + + text_file = None + output_name = None + if it["render"]: + seq += 1 + ct = ChapterText(index=index, title=it["title"] or it["src"], + paragraphs=it["paragraphs"]) + text_file = f"chapters/ch{index:02d}.json" + (data_dir / text_file).write_text( + ct.model_dump_json(indent=2), encoding="utf-8") + output_name = _output_name(seq, it["kind"], number, it["title"] or "") + + chapters.append(Chapter( + index=index, + item_id=it["item_id"], + src=it["src"], + title=it["title"] or it["src"], + kind=it["kind"], + render=it["render"], + number=number, + pov=pov, + word_count=it["word_count"], + text_file=text_file, + output_name=output_name, + )) + + book = Book( + slug=slug, + title=title, + author=author, + language=(language[:2] if language else "fr"), + description=description, + cover_file=cover_file, + chapters=chapters, + ) + (data_dir / "book.json").write_text( + book.model_dump_json(indent=2), encoding="utf-8") + return book + + +def _meta(book: epub.EpubBook, name: str) -> Optional[str]: + values = book.get_metadata("DC", name) + if values: + return values[0][0] + return None + + +def load_book(slug: str) -> Book: + path = book_data_dir(slug) / "book.json" + return Book.model_validate_json(path.read_text(encoding="utf-8")) + + +def load_chapter_text(slug: str, chapter: Chapter) -> ChapterText: + path = book_data_dir(slug) / chapter.text_file + return ChapterText.model_validate_json(path.read_text(encoding="utf-8")) diff --git a/backend/inkflow/models.py b/backend/inkflow/models.py new file mode 100644 index 0000000..e31f63a --- /dev/null +++ b/backend/inkflow/models.py @@ -0,0 +1,176 @@ +"""Schemas de donnees partages dans tout le pipeline (pydantic v2). + +Ces modeles sont serialises en JSON sur disque (book.json, analysis/chNN.json, +cast.json, pronunciation.json) et constituent le contrat entre les etapes du +pipeline. Chaque etape lit l'artefact de la precedente et ecrit le sien. +""" +from __future__ import annotations + +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, Field + + +class ChapterKind(str, Enum): + FRONT = "front" # couverture, page de titre, mentions editeur (non lu) + CHAPTER = "chapter" # prologue, chapitres numerotes, epilogue (lu) + BACK = "back" # remerciements, glossaire... (lu si texte significatif) + + +class Chapter(BaseModel): + index: int # ordre dans le spine (0-based) + item_id: str # idref du manifest opf + src: str # chemin interne xhtml + title: str # titre toc brut, ex "1 - ELVI" + kind: ChapterKind + render: bool # doit-on synthetiser l'audio ? + number: Optional[str] = None # "1", "PROLOGUE", "EPILOGUE"... + pov: Optional[str] = None # personnage point de vue, ex "ELVI" + word_count: int = 0 + text_file: Optional[str] = None # chemin relatif du json de texte (chapters/chNN.json) + output_name: Optional[str] = None # nom du mp3 final, ex "02-Chapitre 1.mp3" + + +class Book(BaseModel): + slug: str # identifiant interne (dossier data) + title: str + author: Optional[str] = None + language: str = "fr" + description: Optional[str] = None + cover_file: Optional[str] = None # chemin du cover extrait dans data// + chapters: list[Chapter] = Field(default_factory=list) + + @property + def render_chapters(self) -> list[Chapter]: + return [c for c in self.chapters if c.render] + + +class ChapterText(BaseModel): + """Texte brut normalise d'un chapitre (sortie du parser).""" + index: int + title: str + paragraphs: list[str] = Field(default_factory=list) + + @property + def word_count(self) -> int: + return sum(len(p.split()) for p in self.paragraphs) + + +# --- Analyse (etape Gemma) --------------------------------------------------- + +class SegmentType(str, Enum): + NARRATION = "narration" + DIALOGUE = "dialogue" + + +class Incise(BaseModel): + """Borne d'une incise de narration inseree dans une replique de dialogue. + + Offsets (caracteres) dans `Segment.text` : la sous-chaine `text[start:end]` + est de la narration (ex: "dit-il", "lanca Drummer") a porter par la voix du + narrateur au rendu, sans fragmenter la replique persistee. + """ + start: int # offset inclus + end: int # offset exclu + + +class Segment(BaseModel): + """Unite de synthese : un bout de texte attribue a un locuteur.""" + type: SegmentType + text: str + speaker: str = "narrateur" # "narrateur" ou nom de personnage + glued_to_prev: bool = False # sous-segment issu du meme paragraphe (incise) + # -> gap audio reduit avec le segment precedent + incises: list[Incise] = Field(default_factory=list) # spans narrateur DANS text + + +class ChapterAnalysis(BaseModel): + index: int + title: str + segments: list[Segment] = Field(default_factory=list) + + +class Character(BaseModel): + name: str # nom canonique + aliases: list[str] = Field(default_factory=list) + gender: Optional[str] = None # "male" | "female" | "unknown" + age: Optional[str] = None # "child" | "young" | "adult" | "old" + description: Optional[str] = None + voice_id: Optional[str] = None # id dans la voicebank (assigne au casting) + + +class Cast(BaseModel): + narrator_voice_id: Optional[str] = None + characters: list[Character] = Field(default_factory=list) + + +class VoiceEntry(BaseModel): + """Une voix de la banque, agnostique du moteur. + + `kokoro_voice` est l'identite (rendu Kokoro direct + clip de reference) ; + `ref_audio`/`ref_text` servent au clonage Qwen3 (rendu final). + """ + id: str # ex "fr_f_siwis" + kokoro_voice: str # ex "ff_siwis" + gender: str = "unknown" # male | female | unknown + age: str = "adult" # child | young | adult | old + lang: str = "fr" + 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 + + +class Voicebank(BaseModel): + entries: list[VoiceEntry] = Field(default_factory=list) + + 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] + + +class PronunciationEntry(BaseModel): + term: str # graphie d'origine, ex "Tiamat" + replacement: str # graphie phonetique guidee, ex "Tia-mat" + note: Optional[str] = None + enabled: bool = True + + +class Pronunciation(BaseModel): + entries: list[PronunciationEntry] = Field(default_factory=list) + + +# --- Etat du projet (orchestration / UI) ------------------------------------ + +class StageStatus(str, Enum): + PENDING = "pending" + RUNNING = "running" + DONE = "done" + ERROR = "error" + + +class ChapterRenderState(BaseModel): + index: int + status: StageStatus = StageStatus.PENDING + progress: float = 0.0 # 0..1 + mp3: Optional[str] = None # nom du fichier de sortie + backend: Optional[str] = None + error: Optional[str] = None + + +class ProjectState(BaseModel): + """Etat persistant d'un livre, pilote par l'orchestrateur et lu par l'UI.""" + slug: str + title: str + stages: dict[str, StageStatus] = Field(default_factory=dict) # parse/analyze/cast/pronounce + analyzed_chapters: list[int] = Field(default_factory=list) + render: dict[int, ChapterRenderState] = Field(default_factory=dict) + # Job courant (pour l'affichage temps reel). + active_stage: Optional[str] = None + active_detail: Optional[str] = None + active_progress: float = 0.0 + + def stage(self, name: str) -> StageStatus: + return self.stages.get(name, StageStatus.PENDING) diff --git a/backend/inkflow/pipeline/__init__.py b/backend/inkflow/pipeline/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/inkflow/pipeline/orchestrator.py b/backend/inkflow/pipeline/orchestrator.py new file mode 100644 index 0000000..af73f36 --- /dev/null +++ b/backend/inkflow/pipeline/orchestrator.py @@ -0,0 +1,364 @@ +"""Orchestrateur : execute les etapes du pipeline en tache de fond, piste l'etat +et diffuse l'etat complet a l'UI a chaque changement. + +- Un seul worker thread execute les jobs en serie (un Mac = une charge MLX a la + fois). Les jobs sont enfiles et rendent la main immediatement a l'API. +- L'etat (ProjectState) est persiste dans data//state.json -> reprenable. +- La diffusion passe par un `broadcaster` injecte par la couche API (pour rester + independant de FastAPI). Il recoit (slug, dict_etat). +""" +from __future__ import annotations + +import queue +import threading +import traceback +from pathlib import Path +from typing import Callable, Optional + +from ..config import book_data_dir, book_output_dir +from ..epub.parser import load_book, load_chapter_text +from ..models import ChapterRenderState, ProjectState, StageStatus +from ..store import artifacts + +Broadcaster = Callable[[str, dict], None] + + +def state_path(slug: str) -> Path: + return book_data_dir(slug) / "state.json" + + +def load_state(slug: str) -> ProjectState: + path = state_path(slug) + if path.exists(): + state = ProjectState.model_validate_json(path.read_text(encoding="utf-8")) + else: + book = load_book(slug) + state = ProjectState(slug=slug, title=book.title, + stages={"parse": StageStatus.DONE}) + return _reconcile(slug, state) + + +def _reconcile(slug: str, state: ProjectState) -> ProjectState: + """Aligne l'etat sur les artefacts presents sur disque (reprise robuste). + + Permet a l'UI de refleter ce qui a deja ete fait, meme via la CLI ou apres + un redemarrage, sans rejouer les etapes. + """ + book = load_book(slug) + state.stages.setdefault("parse", StageStatus.DONE) + + # Analyse : chapitres possedant un artefact d'analyse. + analyzed = [c.index for c in book.render_chapters + if artifacts.analysis_path(slug, c.index).exists()] + if analyzed: + for idx in analyzed: + if idx not in state.analyzed_chapters: + state.analyzed_chapters.append(idx) + if state.stage("analyze") == StageStatus.PENDING: + state.stages["analyze"] = ( + StageStatus.DONE if len(analyzed) == len(book.render_chapters) + else StageStatus.RUNNING) + + # Casting : au moins une voix attribuee. + cast = artifacts.load_cast(slug) + if cast.narrator_voice_id or any(c.voice_id for c in cast.characters): + state.stages.setdefault("cast", StageStatus.DONE) + + # Prononciation : au moins une entree. + if artifacts.load_pronunciation(slug).entries: + state.stages.setdefault("pronounce", StageStatus.DONE) + + # Rendu : mp3 presents en sortie. + out_dir = book_output_dir(book.title) + for ch in book.render_chapters: + existing = state.render.get(ch.index) + if existing and existing.mp3: + continue + if ch.output_name and (out_dir / ch.output_name).exists(): + state.render[ch.index] = ChapterRenderState( + index=ch.index, status=StageStatus.DONE, progress=1.0, + mp3=ch.output_name) + return state + + +class Orchestrator: + def __init__(self) -> None: + self._q: "queue.Queue[tuple[str, Callable[[], None]]]" = queue.Queue() + self._worker: Optional[threading.Thread] = None + self._broadcaster: Optional[Broadcaster] = None + self._lock = threading.Lock() + self.busy_slug: Optional[str] = None + + # --- infra --------------------------------------------------------------- + def set_broadcaster(self, fn: Broadcaster) -> None: + self._broadcaster = fn + + def _ensure_worker(self) -> None: + if self._worker is None or not self._worker.is_alive(): + self._worker = threading.Thread(target=self._loop, daemon=True) + self._worker.start() + + def _loop(self) -> None: + while True: + slug, job = self._q.get() + self.busy_slug = slug + try: + job() + except Exception: # noqa: BLE001 + traceback.print_exc() + finally: + self.busy_slug = None + self._q.task_done() + + def _save_and_emit(self, state: ProjectState) -> None: + path = state_path(state.slug) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(state.model_dump_json(indent=2), encoding="utf-8") + if self._broadcaster: + self._broadcaster(state.slug, state.model_dump(mode="json")) + + def enqueue(self, slug: str, job: Callable[[], None]) -> None: + self._ensure_worker() + self._q.put((slug, job)) + + # --- etapes -------------------------------------------------------------- + def run_analyze(self, slug: str, chapter_indexes: Optional[list[int]] = None) -> None: + def job() -> None: + from ..analysis.gemma import Gemma + from ..analysis.segmenter import analyze_chapter + from ..models import Cast + from ..settings import get_settings + + state = load_state(slug) + book = load_book(slug) + targets = [c for c in book.render_chapters + if chapter_indexes is None or c.index in chapter_indexes] + state.stages["analyze"] = StageStatus.RUNNING + state.active_stage = "analyze" + self._save_and_emit(state) + + gemma = Gemma() + dedup_gemma = gemma if get_settings().dedup_use_gemma else None + cast = artifacts.load_cast(slug) + chars = list(cast.characters) + total = len(targets) + for i, ch in enumerate(targets): + state.active_detail = f"Analyse {ch.title}" + state.active_progress = i / max(total, 1) + self._save_and_emit(state) + ct = load_chapter_text(slug, ch) + try: + # La dedup est faite dans analyze_chapter : `chars` recoit le + # cast cumule reconcilie. + analysis, chars = analyze_chapter( + ch, ct, gemma, book_chars=chars, dedup_gemma=dedup_gemma) + except Exception: # noqa: BLE001 — chapitre ignore, on continue + traceback.print_exc() + continue + artifacts.save_analysis(slug, analysis) + if ch.index not in state.analyzed_chapters: + state.analyzed_chapters.append(ch.index) + self._save_and_emit(state) + + artifacts.save_cast(slug, Cast( + narrator_voice_id=cast.narrator_voice_id, characters=chars)) + state.stages["analyze"] = StageStatus.DONE + self._finish(state) + self.enqueue(slug, job) + + def run_cast(self, slug: str) -> None: + def job() -> None: + from ..casting.assign import assign_voices + from ..casting.voicebank import build_voicebank, load_voicebank + + state = load_state(slug) + state.stages["cast"] = StageStatus.RUNNING + state.active_stage = "cast" + state.active_detail = "Preparation de la voicebank" + self._save_and_emit(state) + + vb = load_voicebank() + if not vb.entries or not any(e.ref_audio for e in vb.entries): + vb = build_voicebank() + cast = artifacts.load_cast(slug) + cast = assign_voices(cast.characters, vb, + narrator_voice_id=cast.narrator_voice_id) + artifacts.save_cast(slug, cast) + state.stages["cast"] = StageStatus.DONE + self._finish(state) + self.enqueue(slug, job) + + def run_cast_analyze(self, slug: str, chapter_indexes: Optional[list[int]] = None) -> None: + """(Re)extrait les personnages d'un/des chapitre(s) et les reconcilie. + + Plus leger que `run_analyze` : ne re-segmente pas (les artefacts d'analyse + existants restent intacts). Sert le casting "a l'echelle d'un chapitre" + tout en maintenant la coherence du livre (deduplication). + """ + def job() -> None: + from ..analysis.gemma import Gemma + from ..analysis.segmenter import extract_characters + from ..casting.dedup import reconcile_characters + from ..models import Cast + from ..settings import get_settings + + state = load_state(slug) + book = load_book(slug) + targets = [c for c in book.render_chapters + if chapter_indexes is None or c.index in chapter_indexes] + state.active_stage = "cast" + self._save_and_emit(state) + + gemma = Gemma() + dedup_gemma = gemma if get_settings().dedup_use_gemma else None + cast = artifacts.load_cast(slug) + chars = list(cast.characters) + total = len(targets) + for i, ch in enumerate(targets): + state.active_detail = f"Casting — {ch.title}" + state.active_progress = i / max(total, 1) + self._save_and_emit(state) + ct = load_chapter_text(slug, ch) + try: + found = extract_characters("\n".join(ct.paragraphs), gemma) + speakers: list[str] = [] + if artifacts.analysis_path(slug, ch.index).exists(): + analysis = artifacts.load_analysis(slug, ch.index) + speakers = [s.speaker for s in analysis.segments] + chars, _ = reconcile_characters( + chars, found, dedup_gemma, speaker_names=speakers) + except Exception: # noqa: BLE001 — chapitre ignore, on continue + traceback.print_exc() + continue + artifacts.save_cast(slug, Cast( + narrator_voice_id=cast.narrator_voice_id, characters=chars)) + self._save_and_emit(state) + self._finish(state) + self.enqueue(slug, job) + + 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 ..casting.dedup import dedup_cast + from ..models import Cast + from ..settings import get_settings + + state = load_state(slug) + state.active_stage = "cast" + state.active_detail = "Deduplication du casting" + self._save_and_emit(state) + + cast = artifacts.load_cast(slug) + gemma = Gemma() 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)) + self._finish(state) + self.enqueue(slug, job) + + def run_pronounce(self, slug: str) -> None: + def job() -> None: + from ..analysis.gemma import Gemma + from ..analysis.pronunciation import ( + merge_pronunciations, + propose_pronunciations, + ) + + state = load_state(slug) + book = load_book(slug) + state.stages["pronounce"] = StageStatus.RUNNING + state.active_stage = "pronounce" + self._save_and_emit(state) + + gemma = Gemma() + pron = artifacts.load_pronunciation(slug) + targets = book.render_chapters[:3] # echantillon de chapitres + for i, ch in enumerate(targets): + state.active_detail = f"Mots a risque — {ch.title}" + state.active_progress = i / max(len(targets), 1) + self._save_and_emit(state) + ct = load_chapter_text(slug, ch) + pron = merge_pronunciations( + pron, propose_pronunciations("\n".join(ct.paragraphs), gemma)) + artifacts.save_pronunciation(slug, pron) + state.stages["pronounce"] = StageStatus.DONE + self._finish(state) + self.enqueue(slug, job) + + def run_render(self, slug: str, chapter_indexes: list[int], + backend: Optional[str] = None, mono: bool = False) -> None: + from ..settings import get_settings + backend = backend or get_settings().default_backend + + def job() -> None: + from ..casting.voicebank import load_voicebank, voice_spec_for + from ..pipeline.render import ( + build_units_mono, + build_units_multi, + make_voice_resolver, + render_chapter_to_mp3, + ) + from ..tts.factory import get_backend + + state = load_state(slug) + book = load_book(slug) + state.stages["render"] = StageStatus.RUNNING + state.active_stage = "render" + self._save_and_emit(state) + + tts = get_backend(backend) + pron = artifacts.load_pronunciation(slug) + cast = artifacts.load_cast(slug) + vb = load_voicebank() + render_list = [c for c in book.render_chapters if c.index in chapter_indexes] + + for ch in render_list: + rs = state.render.get(ch.index) or ChapterRenderState(index=ch.index) + rs.status = StageStatus.RUNNING + rs.progress = 0.0 + rs.backend = backend + state.render[ch.index] = rs + state.active_detail = f"Synthese — {ch.title}" + self._save_and_emit(state) + try: + ct = load_chapter_text(slug, ch) + if mono or ch.index not in state.analyzed_chapters: + units = build_units_mono(ct, tts.default_voice()) + else: + analysis = artifacts.load_analysis(slug, ch.index) + narr = vb.by_id(cast.narrator_voice_id) if cast.narrator_voice_id else None + default_voice = (voice_spec_for(narr, backend) + if narr else tts.default_voice()) + resolver = make_voice_resolver(cast, vb, backend) + units = build_units_multi(analysis, resolver, default_voice) + + def _p(done: int, total: int, _rs=rs, _state=state) -> None: + _rs.progress = done / max(total, 1) + _state.active_progress = _rs.progress + self._save_and_emit(_state) + + track = book.render_chapters.index(ch) + 1 + mp3 = render_chapter_to_mp3(book, ch, units, tts, pron=pron, + track=track, progress=_p) + rs.status = StageStatus.DONE + rs.progress = 1.0 + rs.mp3 = mp3.name + except Exception as exc: # noqa: BLE001 + rs.status = StageStatus.ERROR + rs.error = str(exc) + self._save_and_emit(state) + + state.stages["render"] = StageStatus.DONE + self._finish(state) + self.enqueue(slug, job) + + def _finish(self, state: ProjectState) -> None: + state.active_stage = None + state.active_detail = None + state.active_progress = 0.0 + self._save_and_emit(state) + + +# Singleton partage par l'API. +orchestrator = Orchestrator() diff --git a/backend/inkflow/pipeline/render.py b/backend/inkflow/pipeline/render.py new file mode 100644 index 0000000..6f70c21 --- /dev/null +++ b/backend/inkflow/pipeline/render.py @@ -0,0 +1,158 @@ +"""Rendu audio d'un chapitre : (segments + voix) -> WAV -> MP3. + +Une `RenderUnit` = un bout de texte + la voix a employer. On construit la liste +d'unites (mono-narrateur ou multi-voix selon le casting), on synthetise chacune, +on concatene avec des silences, on normalise puis on encode en MP3. +""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Optional + +from ..analysis.pronunciation import apply_pronunciation +from ..audio.postprocess import concat_segments, encode_mp3, normalize_loudness, write_wav +from ..config import book_data_dir, book_output_dir +from ..models import ( + Book, + Chapter, + ChapterAnalysis, + ChapterText, + Pronunciation, + SegmentType, +) +from ..tts.base import TTSBackend, VoiceSpec + +# Resout un nom de locuteur en une voix concrete. +VoiceResolver = Callable[[str], VoiceSpec] + + +@dataclass +class RenderUnit: + text: str + voice: VoiceSpec + speaker: str = "narrateur" + glued_to_prev: bool = False # incise -> gap reduit avec l'unite precedente + + +def build_units_mono(ct: ChapterText, narrator: VoiceSpec) -> list[RenderUnit]: + """Mono-narrateur : chaque paragraphe est lu par la voix du narrateur.""" + return [RenderUnit(text=p, voice=narrator) for p in ct.paragraphs if p.strip()] + + +def make_voice_resolver(cast, voicebank, engine: str) -> VoiceResolver: + """Construit un resolver locuteur -> VoiceSpec via le casting + la voicebank. + + Replie sur la voix du narrateur si le locuteur n'a pas de voix attribuee. + """ + from ..casting.assign import resolve_speaker_voice + from ..casting.voicebank import voice_spec_for + + def resolve(speaker: str): + vid = resolve_speaker_voice(speaker, cast, voicebank) + if vid is None: + vid = cast.narrator_voice_id + entry = voicebank.by_id(vid) if vid else None + if entry is None: + return None # le backend utilisera sa voix par defaut + return voice_spec_for(entry, engine) + + return resolve + + +def build_units_multi( + analysis: ChapterAnalysis, + resolve: VoiceResolver, + default_voice: "VoiceSpec", +) -> list[RenderUnit]: + """Multi-voix : narration -> narrateur, dialogue -> voix du personnage. + + Les incises annotees sur une replique (bornes dans le texte) sont detachees + ici, au dernier moment : la sous-chaine d'incise est portee par la voix du + narrateur (`glued_to_prev` pour reduire le silence), le reste par la voix du + personnage. Les repliques sans incise sont rendues entieres. + """ + from ..analysis.segmenter import iter_incise_pieces + + narrator = resolve("narrateur") or default_voice + units: list[RenderUnit] = [] + for seg in analysis.segments: + if not seg.text.strip(): + continue + if seg.type is SegmentType.NARRATION: + units.append(RenderUnit(text=seg.text, voice=narrator, + speaker="narrateur", + glued_to_prev=seg.glued_to_prev)) + continue + + char_voice = resolve(seg.speaker) or default_voice + if not seg.incises: + units.append(RenderUnit(text=seg.text, voice=char_voice, + speaker=seg.speaker, + glued_to_prev=seg.glued_to_prev)) + continue + + for k, (is_incise, piece) in enumerate( + iter_incise_pieces(seg.text, seg.incises)): + glued = seg.glued_to_prev if k == 0 else True + if is_incise: + units.append(RenderUnit(text=piece, voice=narrator, + speaker="narrateur", glued_to_prev=glued)) + else: + units.append(RenderUnit(text=piece, voice=char_voice, + speaker=seg.speaker, glued_to_prev=glued)) + return units + + +def render_units( + units: list[RenderUnit], + backend: TTSBackend, + *, + pron: Optional[Pronunciation] = None, + progress: Optional[Callable[[int, int], None]] = None, +) -> tuple["list", int]: + """Synthetise toutes les unites et renvoie (liste (audio,sr), n_units).""" + parts = [] + total = len(units) + for i, unit in enumerate(units): + text = apply_pronunciation(unit.text, pron) if pron else unit.text + audio, sr = backend.synthesize(text, unit.voice) + parts.append((audio, sr)) + if progress: + progress(i + 1, total) + return parts, total + + +def render_chapter_to_mp3( + book: Book, + chapter: Chapter, + units: list[RenderUnit], + backend: TTSBackend, + *, + pron: Optional[Pronunciation] = None, + track: Optional[int] = None, + progress: Optional[Callable[[int, int], None]] = None, +) -> Path: + """Pipeline complet pour un chapitre -> output//NN-...mp3.""" + parts, _ = render_units(units, backend, pron=pron, progress=progress) + # parts est aligne 1:1 avec units -> on transmet les marqueurs d'incise. + audio, sr = concat_segments(parts, glued=[u.glued_to_prev for u in units]) + audio = normalize_loudness(audio) + + # WAV intermediaire dans data/, MP3 final dans output/. + wav_path = book_data_dir(book.slug) / "audio" / f"ch{chapter.index:02d}.wav" + write_wav(wav_path, audio, sr) + + out_dir = book_output_dir(book.title) + mp3_path = out_dir / (chapter.output_name or f"ch{chapter.index:02d}.mp3") + cover = None + if book.cover_file: + candidate = book_data_dir(book.slug) / book.cover_file + cover = candidate if candidate.exists() else None + + encode_mp3( + wav_path, mp3_path, + title=chapter.title, album=book.title, artist=book.author, + track=track, cover_path=cover, + ) + return mp3_path diff --git a/backend/inkflow/settings.py b/backend/inkflow/settings.py new file mode 100644 index 0000000..434a65e --- /dev/null +++ b/backend/inkflow/settings.py @@ -0,0 +1,170 @@ +"""Reglages techniques editables au runtime (globaux a l'app). + +Contrairement a `config.py` (constantes figees lues a l'import, surchargeables +seulement par variables d'environnement au demarrage), ce module expose un objet +`Settings` *persiste* dans `data/settings.json` et modifiable depuis l'UI. + +Les valeurs par defaut reprennent celles de `config.py`. Le code du pipeline +consulte `get_settings()` au moment de l'execution ; une sauvegarde invalide les +caches de modeles (backends TTS, chargement Gemma) pour que les nouveaux +identifiants/parametres prennent effet sans redemarrage. +""" +from __future__ import annotations + +import threading +from typing import Optional + +from pydantic import BaseModel, Field + +from . import config + +# --- Prompts systeme par defaut (source canonique) --------------------------- +# Ces chaines pilotent les trois taches Gemma. L'utilisateur peut les editer. +DEFAULT_PROMPT_SPEAKERS = ( + "Tu es un assistant d'analyse litteraire. Tu identifies QUI prononce chaque " + "replique de dialogue dans un extrait de roman en francais. Une liste des " + "personnages du chapitre t'est fournie : choisis le locuteur dans cette " + "liste en recopiant son nom EXACTEMENT. Appuie-toi sur la narration qui " + "PRECEDE et qui SUIT chaque replique (incise d'attribution type 'dit " + "Marie'), sur les vocatifs (le personnage a qui l'on s'adresse) et sur " + "l'alternance des tours de parole. Mets 'inconnu' si tu n'es pas sur. Tu " + "reponds UNIQUEMENT en JSON valide, sans texte autour." +) +DEFAULT_PROMPT_SPEAKERS_REFINE = ( + "Tu es un assistant d'analyse litteraire. On te donne des repliques dont le " + "locuteur est reste indetermine, avec le locuteur DEJA identifie des " + "repliques voisines. Deduis qui parle en exploitant l'alternance des tours " + "de parole et le contexte narratif autour. Choisis le nom dans la liste des " + "personnages fournie, en le recopiant exactement, ou 'inconnu' si vraiment " + "indeterminable. Tu reponds UNIQUEMENT en JSON valide, sans texte autour." +) +DEFAULT_PROMPT_CHARACTERS = ( + "Tu es un assistant d'analyse litteraire. Tu extrais la liste des " + "personnages d'un extrait de roman et leurs attributs vocaux. Tu reponds " + "UNIQUEMENT en JSON valide." +) +DEFAULT_PROMPT_PRONUNCIATION = ( + "Tu es un assistant de preparation de livre audio en francais. Tu reperes " + "les mots dont la prononciation par un synthetiseur vocal francais risque " + "d'etre incorrecte (noms propres etrangers, termes de science-fiction, " + "acronymes). Tu reponds UNIQUEMENT en JSON valide." +) +DEFAULT_PROMPT_INCISES = ( + "Tu es un assistant d'analyse litteraire. Tu reperes les INCISES de " + "narration inserees dans une replique de dialogue (ex: 'dit Mamie', " + "'repondit le capitaine'). Tu reponds UNIQUEMENT en JSON valide, sans " + "texte autour." +) +DEFAULT_PROMPT_DEDUP = ( + "Tu es un assistant d'analyse litteraire. Tu rapproches les differentes " + "facons de nommer un meme personnage (nom complet, prenom, surnom, " + "diminutif) pour eviter les doublons dans le casting d'un livre audio. Tu " + "ne fusionnes deux noms que si c'est, avec certitude, la meme personne. Tu " + "reponds UNIQUEMENT en JSON valide, sans texte autour." +) + + +class Settings(BaseModel): + """Reglages techniques globaux, persistes dans data/settings.json.""" + + # --- Modeles MLX (identifiants HuggingFace) --- + gemma_model: str = config.GEMMA_MODEL + qwen3_model: str = config.QWEN3_TTS_MODEL + kokoro_model: str = config.KOKORO_MODEL + + # --- Generation Gemma --- + gemma_temperature: float = Field(0.1, ge=0.0, le=2.0) + gemma_max_tokens: int = Field(2048, ge=64, le=8192) + + # --- Prompts systeme (analyse) --- + prompt_speakers: str = DEFAULT_PROMPT_SPEAKERS + prompt_speakers_refine: str = DEFAULT_PROMPT_SPEAKERS_REFINE + prompt_characters: str = DEFAULT_PROMPT_CHARACTERS + prompt_pronunciation: str = DEFAULT_PROMPT_PRONUNCIATION + prompt_incises: str = DEFAULT_PROMPT_INCISES # DEPRECIE (detection deterministe) + prompt_dedup: str = DEFAULT_PROMPT_DEDUP + + # --- Incises --- + # DEPRECIE : la detection d'incises est desormais deterministe et conscience + # du casting (analysis.segmenter.detect_incises), sans fallback Gemma. Champ + # conserve pour charger les settings.json existants sans erreur. + split_incises_use_gemma: bool = True + + # --- Attribution retroactive (2e passe sur les repliques indeterminees) --- + # Apres la 1re passe, une 2e passe ciblee re-resout les repliques restees + # 'inconnu' (ou peu sures) en s'appuyant sur les voisins deja identifies. + # Declenchee seulement s'il reste des doutes -> cout nul sinon. + retro_pass_use_gemma: bool = True + + # --- Deduplication du casting --- + # Heuristique (sure, deterministe) par defaut. La passe Gemma rattache en + # plus les variantes non evidentes (diminutifs, titres) mais, avec un petit + # modele local, produit des fusions erronees -> opt-in. + dedup_use_gemma: bool = False + + # --- TTS --- + default_backend: str = "kokoro" + language: str = config.DEFAULT_LANGUAGE + kokoro_lang_code: str = config.KOKORO_LANG_CODE + kokoro_default_voice: str = config.KOKORO_DEFAULT_VOICE + qwen3_default_voice: str = config.QWEN3_DEFAULT_VOICE + + # --- Audio (encodage final) --- + target_sample_rate: int = Field(config.TARGET_SAMPLE_RATE, ge=8000, le=48000) + mp3_bitrate: str = config.MP3_BITRATE + target_dbfs: float = Field(config.TARGET_DBFS, ge=-40.0, le=0.0) + + +_LOCK = threading.Lock() +_cache: Optional[Settings] = None + + +def settings_path(): + return config.DATA_DIR / "settings.json" + + +def get_settings() -> Settings: + """Renvoie les reglages courants (charges depuis le disque une seule fois).""" + global _cache + with _LOCK: + if _cache is None: + path = settings_path() + if path.exists(): + try: + _cache = Settings.model_validate_json( + path.read_text(encoding="utf-8")) + except Exception: # noqa: BLE001 — fichier corrompu -> defauts + _cache = Settings() + else: + _cache = Settings() + return _cache + + +def save_settings(settings: Settings) -> Settings: + """Persiste les reglages et invalide les caches de modeles.""" + global _cache + with _LOCK: + _cache = settings + path = settings_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(settings.model_dump_json(indent=2), encoding="utf-8") + _invalidate_model_caches() + return settings + + +def _invalidate_model_caches() -> None: + """Force le rechargement des modeles apres un changement d'identifiant/param. + + `get_backend` est cache par *nom* de backend, pas par id de modele ; sans + purge, un changement d'id serait ignore. Idem pour le chargement Gemma. + """ + try: + from .tts.factory import get_backend + get_backend.cache_clear() + except Exception: # noqa: BLE001 + pass + try: + from .analysis.gemma import _load + _load.cache_clear() + except Exception: # noqa: BLE001 + pass diff --git a/backend/inkflow/store/__init__.py b/backend/inkflow/store/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/inkflow/store/artifacts.py b/backend/inkflow/store/artifacts.py new file mode 100644 index 0000000..3817e14 --- /dev/null +++ b/backend/inkflow/store/artifacts.py @@ -0,0 +1,63 @@ +"""Lecture/ecriture des artefacts du pipeline dans data//. + +Chaque etape ecrit un JSON ; les etapes suivantes les relisent. C'est aussi ce +qui rend le pipeline reprenable : on peut detecter qu'un artefact existe deja. +""" +from __future__ import annotations + +from pathlib import Path + +from ..config import book_data_dir +from ..models import Cast, ChapterAnalysis, Pronunciation + + +def analysis_path(slug: str, chapter_index: int) -> Path: + return book_data_dir(slug) / "analysis" / f"ch{chapter_index:02d}.json" + + +def cast_path(slug: str) -> Path: + return book_data_dir(slug) / "cast.json" + + +def pronunciation_path(slug: str) -> Path: + return book_data_dir(slug) / "pronunciation.json" + + +def save_analysis(slug: str, analysis: ChapterAnalysis) -> Path: + path = analysis_path(slug, analysis.index) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(analysis.model_dump_json(indent=2), encoding="utf-8") + return path + + +def load_analysis(slug: str, chapter_index: int) -> ChapterAnalysis: + path = analysis_path(slug, chapter_index) + return ChapterAnalysis.model_validate_json(path.read_text(encoding="utf-8")) + + +def save_cast(slug: str, cast: Cast) -> Path: + path = cast_path(slug) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(cast.model_dump_json(indent=2), encoding="utf-8") + return path + + +def load_cast(slug: str) -> Cast: + path = cast_path(slug) + if not path.exists(): + return Cast() + return Cast.model_validate_json(path.read_text(encoding="utf-8")) + + +def save_pronunciation(slug: str, pron: Pronunciation) -> Path: + path = pronunciation_path(slug) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(pron.model_dump_json(indent=2), encoding="utf-8") + return path + + +def load_pronunciation(slug: str) -> Pronunciation: + path = pronunciation_path(slug) + if not path.exists(): + return Pronunciation() + return Pronunciation.model_validate_json(path.read_text(encoding="utf-8")) diff --git a/backend/inkflow/tts/__init__.py b/backend/inkflow/tts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/inkflow/tts/base.py b/backend/inkflow/tts/base.py new file mode 100644 index 0000000..773cdd1 --- /dev/null +++ b/backend/inkflow/tts/base.py @@ -0,0 +1,48 @@ +"""Abstraction des moteurs TTS (backend pluggable). + +Deux implementations : Kokoro (rapide, voix preglees -> previews) et Qwen3-TTS +(qualite + clonage par audio de reference -> rendu final). Toutes deux renvoient +de l'audio mono float32 + une frequence d'echantillonnage. +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Optional + +import numpy as np + + +@dataclass +class VoiceSpec: + """Decrit la voix a utiliser pour une synthese. + + - `preset` : nom d'une voix preglee (Kokoro: "ff_siwis" ; Qwen3: "Chelsie"). + - `ref_audio` / `ref_text` : clip de reference pour le clonage (Qwen3). + """ + preset: Optional[str] = None + ref_audio: Optional[str] = None + ref_text: Optional[str] = None + speed: float = 1.0 + + +class TTSBackend(ABC): + """Interface commune a tous les moteurs TTS.""" + + name: str = "base" + + @abstractmethod + def synthesize(self, text: str, voice: VoiceSpec) -> tuple[np.ndarray, int]: + """Synthetise `text` et renvoie (audio mono float32, sample_rate).""" + + def default_voice(self) -> VoiceSpec: + return VoiceSpec() + + +def to_mono_float32(audio) -> np.ndarray: + """Normalise une sortie de modele (mx.array / np / list) en mono float32.""" + arr = np.asarray(audio, dtype=np.float32) + if arr.ndim > 1: + # (channels, n) ou (n, channels) -> moyenne sur l'axe des canaux. + arr = arr.mean(axis=0) if arr.shape[0] < arr.shape[-1] else arr.mean(axis=-1) + return np.ascontiguousarray(arr.reshape(-1)) diff --git a/backend/inkflow/tts/chunk.py b/backend/inkflow/tts/chunk.py new file mode 100644 index 0000000..5b2c664 --- /dev/null +++ b/backend/inkflow/tts/chunk.py @@ -0,0 +1,62 @@ +"""Decoupage de texte en morceaux synthese-friendly. + +Les modeles TTS (Kokoro notamment) tronquent les textes trop longs. On decoupe +donc sur les frontieres de phrases en respectant une longueur max par morceau. +""" +from __future__ import annotations + +import re + +# Fin de phrase : ponctuation forte suivie d'un espace. +_SENTENCE_END_RE = re.compile(r"(?<=[.!?…])\s+|\n+") +# Pour les phrases tres longues, on coupe aussi sur les virgules / points-virgules. +_SOFT_BREAK_RE = re.compile(r"(?<=[,;:])\s+") + +DEFAULT_MAX_CHARS = 350 + + +def split_sentences(text: str) -> list[str]: + parts = [p.strip() for p in _SENTENCE_END_RE.split(text)] + return [p for p in parts if p] + + +def _split_long(sentence: str, max_chars: int) -> list[str]: + """Coupe une phrase trop longue sur les virgules, puis par fenetre dure.""" + if len(sentence) <= max_chars: + return [sentence] + out: list[str] = [] + buf = "" + for piece in _SOFT_BREAK_RE.split(sentence): + cand = f"{buf} {piece}".strip() + if len(cand) <= max_chars: + buf = cand + else: + if buf: + out.append(buf) + if len(piece) <= max_chars: + buf = piece + else: # mot/segment plus long que la fenetre : coupe brute + for i in range(0, len(piece), max_chars): + out.append(piece[i:i + max_chars]) + buf = "" + if buf: + out.append(buf) + return out + + +def chunk_text(text: str, max_chars: int = DEFAULT_MAX_CHARS) -> list[str]: + """Regroupe les phrases en morceaux <= max_chars, sans couper une phrase.""" + chunks: list[str] = [] + buf = "" + for sentence in split_sentences(text): + for part in _split_long(sentence, max_chars): + cand = f"{buf} {part}".strip() + if len(cand) <= max_chars: + buf = cand + else: + if buf: + chunks.append(buf) + buf = part + if buf: + chunks.append(buf) + return chunks diff --git a/backend/inkflow/tts/factory.py b/backend/inkflow/tts/factory.py new file mode 100644 index 0000000..6524eef --- /dev/null +++ b/backend/inkflow/tts/factory.py @@ -0,0 +1,20 @@ +"""Selection du backend TTS par nom (pluggable).""" +from __future__ import annotations + +from functools import lru_cache + +from .base import TTSBackend + +BACKENDS = ("kokoro", "qwen3") + + +@lru_cache(maxsize=4) +def get_backend(name: str = "kokoro") -> TTSBackend: + name = name.lower() + if name == "kokoro": + from .kokoro import KokoroBackend + return KokoroBackend() + if name == "qwen3": + from .qwen3 import Qwen3Backend + return Qwen3Backend() + raise ValueError(f"Backend TTS inconnu: {name!r} (dispo: {', '.join(BACKENDS)})") diff --git a/backend/inkflow/tts/kokoro.py b/backend/inkflow/tts/kokoro.py new file mode 100644 index 0000000..7963611 --- /dev/null +++ b/backend/inkflow/tts/kokoro.py @@ -0,0 +1,93 @@ +"""Backend Kokoro (rapide, voix preglees) — ideal pour les previews. + +Kokoro tronque les textes longs : on synthetise morceau par morceau (decoupage +par phrases) puis on concatene. Le francais passe par espeak-ng via phonemizer. +""" +from __future__ import annotations + +import logging + +import numpy as np + +from ..config import setup_espeak +from ..settings import get_settings +from .base import TTSBackend, VoiceSpec, to_mono_float32 +from .chunk import chunk_text + +logger = logging.getLogger(__name__) + +# Le port MLX de Kokoro a un bug d'alignement intermittent (mx.random.normal +# dans le generateur harmonique) qui leve un broadcast_shapes sur certains +# tirages. Comme c'est aleatoire, un simple retry suffit le plus souvent ; +# en dernier recours on coupe le morceau en deux. +_KOKORO_RETRIES = 8 + + +class KokoroBackend(TTSBackend): + name = "kokoro" + + def __init__(self, model_id: str | None = None, lang_code: str | None = None): + setup_espeak() + settings = get_settings() + self.model_id = model_id or settings.kokoro_model + self.lang_code = lang_code or settings.kokoro_lang_code + self._model = None + self._sample_rate = 24000 + + def _ensure_loaded(self) -> None: + if self._model is None: + from mlx_audio.tts.utils import load_model + self._model = load_model(self.model_id) + + def default_voice(self) -> VoiceSpec: + return VoiceSpec(preset=get_settings().kokoro_default_voice) + + def synthesize(self, text: str, voice: VoiceSpec) -> tuple[np.ndarray, int]: + self._ensure_loaded() + preset = voice.preset or get_settings().kokoro_default_voice + pieces: list[np.ndarray] = [] + for chunk in chunk_text(text): + pieces.extend(self._gen_resilient(chunk, preset, voice.speed)) + if not pieces: + return np.zeros(0, dtype=np.float32), self._sample_rate + return np.concatenate(pieces), self._sample_rate + + def _gen_once(self, text: str, preset: str, speed: float) -> list[np.ndarray]: + out: list[np.ndarray] = [] + for result in self._model.generate( + text=text, voice=preset, speed=speed, lang_code=self.lang_code, + ): + self._sample_rate = getattr(result, "sample_rate", self._sample_rate) + out.append(to_mono_float32(result.audio)) + return out + + def _gen_resilient(self, text: str, preset: str, speed: float, + depth: int = 0) -> list[np.ndarray]: + """Genere un morceau avec retries, puis re-decoupe en secours.""" + for _ in range(_KOKORO_RETRIES): + try: + return self._gen_once(text, preset, speed) + except Exception: # noqa: BLE001 — bug intermittent du vocoder + continue + # Toujours en echec : on coupe en deux et on reessaie chaque moitie. + if depth < 3 and len(text) > 40: + mid = _split_point(text) + left = self._gen_resilient(text[:mid].strip(), preset, speed, depth + 1) + right = self._gen_resilient(text[mid:].strip(), preset, speed, depth + 1) + return left + right + logger.warning("Kokoro: morceau abandonne apres echecs: %r", text[:60]) + return [] + + +def _split_point(text: str) -> int: + """Point de coupe au plus proche du milieu (espace de preference).""" + mid = len(text) // 2 + left = text.rfind(" ", 0, mid) + right = text.find(" ", mid) + if left == -1 and right == -1: + return mid + if left == -1: + return right + if right == -1: + return left + return left if (mid - left) <= (right - mid) else right diff --git a/backend/inkflow/tts/qwen3.py b/backend/inkflow/tts/qwen3.py new file mode 100644 index 0000000..76d0969 --- /dev/null +++ b/backend/inkflow/tts/qwen3.py @@ -0,0 +1,58 @@ +"""Backend Qwen3-TTS (qualite + clonage par audio de reference) — rendu final. + +Deux modes : +- voix preglee : `voice` (ex "Chelsie") + `language` ("French"). +- clonage : `ref_audio` (+ `ref_text` transcription du clip) pour imiter une + voix de la voicebank, attribuee a un personnage. +""" +from __future__ import annotations + +import numpy as np + +from ..settings import get_settings +from .base import TTSBackend, VoiceSpec, to_mono_float32 +from .chunk import chunk_text + +# Qwen3 tolere des sequences plus longues que Kokoro, mais on borne quand meme. +_QWEN_MAX_CHARS = 500 + + +class Qwen3Backend(TTSBackend): + name = "qwen3" + + def __init__(self, model_id: str | None = None, language: str | None = None): + settings = get_settings() + self.model_id = model_id or settings.qwen3_model + self.language = language or settings.language + self._model = None + self._sample_rate = 24000 + + def _ensure_loaded(self) -> None: + if self._model is None: + from mlx_audio.tts.utils import load_model + self._model = load_model(self.model_id) + + def default_voice(self) -> VoiceSpec: + return VoiceSpec(preset=get_settings().qwen3_default_voice) + + def _gen_kwargs(self, voice: VoiceSpec) -> dict: + kwargs: dict = {"language": self.language, "speed": voice.speed} + if voice.ref_audio: # mode clonage + kwargs["ref_audio"] = voice.ref_audio + if voice.ref_text: + kwargs["ref_text"] = voice.ref_text + else: # mode voix preglee + kwargs["voice"] = voice.preset or get_settings().qwen3_default_voice + return kwargs + + 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)) + if not pieces: + return np.zeros(0, dtype=np.float32), self._sample_rate + return np.concatenate(pieces), self._sample_rate diff --git a/backend/inkflow/util.py b/backend/inkflow/util.py new file mode 100644 index 0000000..6def93d --- /dev/null +++ b/backend/inkflow/util.py @@ -0,0 +1,22 @@ +"""Petits utilitaires partages (slug, noms de fichiers surs).""" +from __future__ import annotations + +import re +import unicodedata + +_SLUG_STRIP = re.compile(r"[^a-z0-9]+") +_FS_UNSAFE = re.compile(r'[<>:"/\\|?*\x00-\x1f]') + + +def slugify(text: str) -> str: + """Slug ascii minuscule, utilise pour les identifiants de dossiers internes.""" + norm = unicodedata.normalize("NFKD", text) + norm = norm.encode("ascii", "ignore").decode("ascii").lower() + return _SLUG_STRIP.sub("-", norm).strip("-") or "livre" + + +def safe_filename(name: str) -> str: + """Nettoie un nom de fichier en conservant les accents (sortie utilisateur).""" + name = _FS_UNSAFE.sub("", name).strip() + name = re.sub(r"\s+", " ", name) + return name or "sans-titre" diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..21ede53 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,40 @@ +[project] +name = "inkflow" +version = "0.1.0" +description = "EPUB -> livre audio, 100% local sur Mac (MLX). Analyse Gemma + TTS Qwen3/Kokoro." +requires-python = ">=3.11" +dependencies = [ + # MLX (Apple Silicon) + "mlx", + "mlx-lm", + "mlx-audio", + "misaki", # phonemizer pour Kokoro (français inclus) + # Parsing EPUB + "ebooklib", + "beautifulsoup4", + "lxml", + # Audio + "soundfile", # lecture/ecriture wav + "numpy", # concat audio + normalisation + "mutagen", # tags id3 + cover (encodage mp3 via ffmpeg CLI) + # API web + "fastapi", + "uvicorn[standard]", + "websockets", + "python-multipart", # upload de fichiers + # Divers + "pydantic>=2", + "rich", # logs CLI lisibles + "typer", # CLI +] + +[project.scripts] +inkflow = "inkflow.cli:app" + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["."] +include = ["inkflow*"] diff --git a/backend/scripts/setup_models.py b/backend/scripts/setup_models.py new file mode 100644 index 0000000..5b36c7b --- /dev/null +++ b/backend/scripts/setup_models.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python +"""Verifie l'environnement InkFlow et pre-telecharge les modeles MLX. + +Usage : + python scripts/setup_models.py # tout verifier + telecharger + python scripts/setup_models.py --check # verifier sans telecharger + +Pre-requis systeme : Apple Silicon, Python >= 3.11, ffmpeg (brew install ffmpeg). +""" +from __future__ import annotations + +import argparse +import platform +import shutil +import sys + +# Permet de lancer le script directement depuis backend/. +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parents[1])) + +from inkflow.config import ( # noqa: E402 + GEMMA_MODEL, + KOKORO_MODEL, + QWEN3_TTS_MODEL, + ensure_dirs, +) + + +def check_env() -> bool: + ok = True + print(f"• Plateforme : {platform.platform()} ({platform.machine()})") + if platform.machine() != "arm64": + print(" ! Attendu arm64 (Apple Silicon) — MLX ne sera pas optimal.") + print(f"• Python : {sys.version.split()[0]}") + if sys.version_info < (3, 11): + print(" ! Python >= 3.11 requis."); ok = False + + for mod in ("mlx", "mlx_lm", "mlx_audio", "ebooklib", "bs4", + "soundfile", "mutagen", "fastapi"): + try: + __import__(mod) + print(f"• import {mod:12s}: OK") + except Exception as exc: # noqa: BLE001 + print(f"• import {mod:12s}: ECHEC ({exc})"); ok = False + + ff = shutil.which("ffmpeg") + print(f"• ffmpeg : {ff or 'INTROUVABLE — brew install ffmpeg'}") + ok = ok and bool(ff) + return ok + + +def download_lm(model_id: str) -> None: + from mlx_lm import load + print(f" -> LM {model_id}") + load(model_id) + + +def download_tts(model_id: str) -> None: + from mlx_audio.tts.utils import load_model + print(f" -> TTS {model_id}") + load_model(model_id) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--check", action="store_true", help="verifier sans telecharger") + args = ap.parse_args() + + ensure_dirs() + print("== Verification de l'environnement ==") + env_ok = check_env() + + if args.check: + return 0 if env_ok else 1 + if not env_ok: + print("\nEnvironnement incomplet — corrige les points ci-dessus avant de continuer.") + return 1 + + print("\n== Telechargement des modeles (peut etre long la 1re fois) ==") + download_lm(GEMMA_MODEL) + download_tts(KOKORO_MODEL) + download_tts(QWEN3_TTS_MODEL) + print("\nTout est pret.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/tests/test_incises.py b/backend/tests/test_incises.py new file mode 100644 index 0000000..c46558a --- /dev/null +++ b/backend/tests/test_incises.py @@ -0,0 +1,204 @@ +"""Tests de la detection deterministe des incises. + +`detect_incises` / `incise_speaker` / `iter_incise_pieces` sont pures et +testables sans Gemma. Deux passes : inversion verbe-pronom ("dit-il") et +nominale consciente du casting ("compatit Holden", "informa le soldat"). +""" +from __future__ import annotations + +from inkflow.analysis.segmenter import ( + detect_incises, + incise_speaker, + iter_incise_pieces, +) + +NAMES = {"Holden", "Kajri", "Camina Drummer"} + + +def _pieces(text: str, names=NAMES) -> list[tuple[bool, str]]: + return iter_incise_pieces(text, detect_incises(text, names=names)) + + +# --- Passe inversion (verbe-pronom) ----------------------------------------- + +def test_inversion_au_milieu(): + assert _pieces("James Holden, coupa-t-elle. Je sais qui vous êtes.") == [ + (False, "James Holden,"), + (True, "coupa-t-elle."), + (False, "Je sais qui vous êtes."), + ] + + +def test_inversion_en_fin(): + assert _pieces("C'est fini, dit-elle.") == [ + (False, "C'est fini,"), + (True, "dit-elle."), + ] + + +def test_inversion_reflechi_exclamation(): + assert _pieces("Viens ici, s'écria-t-il !") == [ + (False, "Viens ici,"), + (True, "s'écria-t-il !"), + ] + + +def test_inversion_fermee_par_virgule(): + assert _pieces("Pars, répondit-elle, et ne reviens pas.") == [ + (False, "Pars,"), + (True, "répondit-elle,"), + (False, "et ne reviens pas."), + ] + + +def test_inversion_complements_apres_pronom(): + assert _pieces("Trop tard, murmura-t-il en souriant. Partons.") == [ + (False, "Trop tard,"), + (True, "murmura-t-il en souriant."), + (False, "Partons."), + ] + + +def test_double_inversion(): + assert _pieces("Stop, dit-il. Non, reprit-elle.") == [ + (False, "Stop,"), + (True, "dit-il."), + (False, "Non,"), + (True, "reprit-elle."), + ] + + +# --- Incise en fin de parole : tout le reste de la replique est narration ---- + +def test_incise_apres_fin_de_phrase_va_jusqu_au_bout(): + # Apres "…" la parole est close : "dit-il ... provisoires." est narration. + text = ("Dans une minute, oui. Je voudrais juste… dit-il avec un geste vague, " + "comme si tout cela n'avait plus d'importance.") + assert _pieces(text) == [ + (False, "Dans une minute, oui. Je voudrais juste…"), + (True, "dit-il avec un geste vague, comme si tout cela n'avait plus " + "d'importance."), + ] + + +def test_incise_apres_virgule_reprend_le_dialogue(): + # Apres une simple virgule, le dialogue reprend (contraste avec ci-dessus). + assert _pieces("Pars, répondit-elle, et ne reviens pas.") == [ + (False, "Pars,"), + (True, "répondit-elle,"), + (False, "et ne reviens pas."), + ] + + +def test_incise_nominale_apres_point_interrogation_va_au_bout(): + text = "Vraiment ? demanda-t-il en se levant. Il s'éloigna." + assert _pieces(text) == [ + (False, "Vraiment ?"), + (True, "demanda-t-il en se levant. Il s'éloigna."), + ] + + +# --- Passe nominale (verbe + sujet connu) ----------------------------------- + +def test_nominale_nom_propre(): + assert _pieces("Toutes mes condoléances, compatit Holden.") == [ + (False, "Toutes mes condoléances,"), + (True, "compatit Holden."), + ] + + +def test_nominale_alias_apres_ponctuation_forte(): + # "?" comme delimiteur a gauche + sujet = alias d'un personnage connu. + assert _pieces("Flippant, cet enfoiré, hein ? lança Drummer.") == [ + (False, "Flippant, cet enfoiré, hein ?"), + (True, "lança Drummer."), + ] + + +def test_nominale_clitic_et_nom_de_role(): + assert _pieces("Vous venez, monsieur ? lui demanda un garde.") == [ + (False, "Vous venez, monsieur ?"), + (True, "lui demanda un garde."), + ] + + +# --- incise_speaker : seeding du locuteur explicite ------------------------- + +def test_seed_speaker_nom_propre(): + text = "Toutes mes condoléances, compatit Holden." + inc = detect_incises(text, names=NAMES)[0] + assert incise_speaker(text, inc, NAMES) == "Holden" + + +def test_seed_speaker_alias_vers_canonique(): + text = "Hein ? lança Drummer." + inc = detect_incises(text, names=NAMES)[0] + assert incise_speaker(text, inc, NAMES) == "Camina Drummer" + + +def test_seed_speaker_role_non_nomme_est_none(): + # Un nom de role ("un garde") n'est pas un personnage du casting -> pas de seed. + text = "Vous venez ? lui demanda un garde." + inc = detect_incises(text, names=NAMES)[0] + assert incise_speaker(text, inc, NAMES) is None + + +def test_seed_speaker_inversion_est_none(): + text = "C'est fini, dit-elle." + inc = detect_incises(text, names=NAMES)[0] + assert incise_speaker(text, inc, NAMES) is None + + +def test_seed_nom_propre_absent_du_casting(): + # Le nom est ecrit dans l'incise -> seede meme si l'extraction l'a rate. + text = "Bonjour, lança Drummer." + inc = detect_incises(text, names=set())[0] + assert incise_speaker(text, inc, set()) == "Drummer" + assert _pieces(text, names=set()) == [ + (False, "Bonjour,"), + (True, "lança Drummer."), + ] + + +# --- Faux positifs a NE PAS detecter ---------------------------------------- + +def test_vocatif_adresse_pas_incise(): + # Le personnage est interpelle, pas une incise (aucun verbe de parole). + text = "Vous n'avez pas l'air en mesure de rendre service, capitaine Holden." + assert detect_incises(text, names=NAMES) == [] + + +def test_imperatif_sans_incise(): + assert detect_incises("Donne-le-moi.", names=NAMES) == [] + + +def test_pronom_tu_exclu(): + assert detect_incises("Crois-tu ?", names=NAMES) == [] + + +def test_replique_simple_sans_incise(): + assert detect_incises("Bonjour à tous.", names=NAMES) == [] + + +def test_sans_noms_inversion_seule(): + # Sans casting fourni, la passe inversion fonctionne toujours. + assert _pieces("C'est fini, dit-elle.", names=set()) == [ + (False, "C'est fini,"), + (True, "dit-elle."), + ] + + +# --- Invariants ------------------------------------------------------------- + +def test_texte_preserve_modulo_espaces(): + text = "James Holden, coupa-t-elle. Je sais qui vous êtes." + joined = "".join(p for _, p in _pieces(text)) + assert joined.replace(" ", "") == text.replace(" ", "") + + +def test_bornes_non_chevauchantes_et_triees(): + text = "Stop, dit-il. Non, reprit-elle." + incs = detect_incises(text, names=NAMES) + 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) diff --git a/frontend/dist/assets/index-CMUl6Yfl.js b/frontend/dist/assets/index-CMUl6Yfl.js new file mode 100644 index 0000000..df08d2a --- /dev/null +++ b/frontend/dist/assets/index-CMUl6Yfl.js @@ -0,0 +1,40 @@ +(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-DlPmWkkU.css new file mode 100644 index 0000000..3d7f777 --- /dev/null +++ b/frontend/dist/assets/index-DlPmWkkU.css @@ -0,0 +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))}} diff --git a/frontend/dist/index.html b/frontend/dist/index.html new file mode 100644 index 0000000..0c488c1 --- /dev/null +++ b/frontend/dist/index.html @@ -0,0 +1,13 @@ + + + + + + InkFlow — EPUB → Livre audio + + + + +
+ + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..8a53505 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + InkFlow — EPUB → Livre audio + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..6749403 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2767 @@ +{ + "name": "inkflow-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "inkflow-frontend", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "vite": "^6.0.7" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.375", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.375.tgz", + "integrity": "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.13", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", + "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", + "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b0e6c78 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "inkflow-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "vite": "^6.0.7" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/AnalysisEditor.jsx b/frontend/src/AnalysisEditor.jsx new file mode 100644 index 0000000..9a7e614 --- /dev/null +++ b/frontend/src/AnalysisEditor.jsx @@ -0,0 +1,245 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { api } from "./api.js"; +import { Spinner } from "./ui.jsx"; + +const NARRATOR = "narrateur"; +let _seq = 0; +const nextId = () => ++_seq; + +export default function AnalysisEditor({ slug, book, state }) { + // Chapitres analysés (intersection ordre du livre x analyzed_chapters). + const analyzed = useMemo(() => { + const set = new Set(state.analyzed_chapters || []); + return book.chapters.filter((c) => set.has(c.index)); + }, [book, state.analyzed_chapters]); + + const [index, setIndex] = useState(() => analyzed[0]?.index ?? null); + const [analysis, setAnalysis] = useState(null); // { index, title, segments:[{_id,type,text,speaker}] } + const [names, setNames] = useState([]); // noms de personnages pour la datalist + const [loading, setLoading] = useState(false); + const [saved, setSaved] = useState(false); + // Derniere selection de texte dans une replique (pour "marquer comme incise"). + const [sel, setSel] = useState({ id: null, start: 0, end: 0 }); + + // Filtres d'affichage (n'altèrent pas la sauvegarde). + const [query, setQuery] = useState(""); + const [typeFilter, setTypeFilter] = useState("all"); + const [speakerFilter, setSpeakerFilter] = useState("all"); + + // Si la liste des chapitres analysés change et que l'index courant disparaît. + useEffect(() => { + if (index == null || !analyzed.some((c) => c.index === index)) { + setIndex(analyzed[0]?.index ?? null); + } + }, [analyzed]); // eslint-disable-line react-hooks/exhaustive-deps + + // Noms des personnages du casting (une fois). + useEffect(() => { + api.getCast(slug) + .then((d) => setNames((d.cast?.characters || []).map((c) => c.name))) + .catch(() => setNames([])); + }, [slug]); + + // Chargement de l'analyse du chapitre sélectionné. + useEffect(() => { + if (index == null) { setAnalysis(null); return; } + setLoading(true); + setSaved(false); + api.getChapter(slug, index).then((d) => { + if (d.analysis) { + setAnalysis({ + index: d.analysis.index, + title: d.analysis.title, + segments: (d.analysis.segments || []).map((s) => ({ ...s, _id: nextId() })), + }); + } else { + setAnalysis({ index, title: d.chapter?.title || "", segments: null }); + } + }).finally(() => setLoading(false)); + }, [slug, index]); + + const speakerOptions = useMemo(() => { + const set = new Set([NARRATOR, ...names]); + (analysis?.segments || []).forEach((s) => s.speaker && set.add(s.speaker)); + return [...set]; + }, [names, analysis]); + + if (!analyzed.length) + return

Lancez d'abord l'Analyse sur un chapitre.

; + + const touch = (segments) => { setAnalysis((a) => ({ ...a, segments })); setSaved(false); }; + + const setSeg = (id, patch) => + touch(analysis.segments.map((s) => { + if (s._id !== id) return s; + const next = { ...s, ...patch }; + if (next.type === "narration") { next.speaker = NARRATOR; next.incises = []; } + // Edition du texte : on ecarte les incises devenues hors-bornes. + if (patch.text !== undefined) { + const len = next.text.length; + next.incises = (next.incises || []).filter( + (inc) => inc.start < inc.end && inc.end <= len); + } + return next; + })); + + // Marque la portion [start,end) d'une replique comme incise (voix narrateur). + const addIncise = (id, start, end) => + touch(analysis.segments.map((s) => { + if (s._id !== id) return s; + const incises = [...(s.incises || []), { start, end }] + .sort((a, b) => a.start - b.start) + .filter((inc, i, arr) => i === 0 || inc.start >= arr[i - 1].end); + return { ...s, incises }; + })); + + const removeIncise = (id, i) => + touch(analysis.segments.map((s) => + s._id !== id ? s : { ...s, incises: (s.incises || []).filter((_, k) => k !== i) })); + + const removeSeg = (id) => touch(analysis.segments.filter((s) => s._id !== id)); + + const insertAfter = (id) => { + const segs = analysis.segments; + const pos = id == null ? segs.length : segs.findIndex((s) => s._id === id) + 1; + const next = [...segs]; + next.splice(pos, 0, { _id: nextId(), type: "narration", text: "", speaker: NARRATOR }); + touch(next); + }; + + const save = async () => { + const payload = { + index: analysis.index, + title: analysis.title, + segments: analysis.segments.map(({ _id, ...s }) => s), + }; + await api.putAnalysis(slug, analysis.index, payload); + setSaved(true); + }; + + const segments = analysis?.segments; + const visible = (segments || []).filter((s) => { + if (typeFilter !== "all" && s.type !== typeFilter) return false; + if (speakerFilter !== "all" && s.speaker !== speakerFilter) return false; + if (query && !s.text.toLowerCase().includes(query.toLowerCase())) return false; + return true; + }); + const dialogueCount = (segments || []).filter((s) => s.type === "dialogue").length; + + return ( +
+ + {speakerOptions.map((n) => + + {/* Barre de contrôle */} +
+ + + {segments && ( + + {segments.length} segments · {dialogueCount} dialogues + + )} + +
+ + {loading &&

chargement de l'analyse…

} + + {!loading && segments === null && ( +

Ce chapitre n'a pas encore d'analyse. Lancez l'Analyse.

+ )} + + {!loading && segments && ( + <> + {/* Filtres d'affichage */} +
+ setQuery(e.target.value)} /> + + + {visible.length !== segments.length && ( + {visible.length} affichés + )} +
+ +
+ {visible.map((s) => { + const canMark = s.type === "dialogue" + && sel.id === s._id && sel.end > sel.start; + const incises = s.incises || []; + return ( +
+
+ +