feat: setup wizard, donate page, CI fixes, performance optimizations, and style extraction
- Implement donate page and migrate API fetching to react-query hooks - Add setup wizard for batch job management and voice clip editing - Refactor setup router into package (wizard, models, download sub-modules) - Fix 9 CI test failures from setup router refactor - Fix cross-device link error in prefs.py atomic writes - Fix event loop mismatch in export test fixtures - Modernize README with architecture diagram and 13 app screenshots - Defer per-segment disk writes in dub_generate for ~6s faster dubs - Extract 45 inline styles from Launchpad, KeyboardCheatsheet, DubSegmentRow - Add playwright dev dep and screenshot capture script
@@ -77,3 +77,6 @@ examples/download*
|
||||
examples/exp*/
|
||||
omnivoice.zip
|
||||
frontend/src-tauri/binaries/ffmpeg
|
||||
|
||||
# cuDNN 8 compat libs (auto-installed by scripts/setup_cudnn.py)
|
||||
cudnn8_compat/
|
||||
|
||||
@@ -1,164 +1,190 @@
|
||||
<div align="center">
|
||||
<img src="frontend/public/favicon.svg" alt="OmniVoice Logo" width="120" />
|
||||
<img src="frontend/public/favicon.svg" alt="OmniVoice Logo" width="100" />
|
||||
<h1>OmniVoice Studio</h1>
|
||||
<p><b>Your Local Cinematic AI Dubbing Studio</b></p>
|
||||
<p><b>Local cinematic AI dubbing. No API keys. No cloud. Just run it.</b></p>
|
||||
<p>
|
||||
<a href="#-features">Features</a> •
|
||||
<a href="#-getting-started">Getting Started</a> •
|
||||
<a href="#%EF%B8%8F-roadmap">Roadmap</a> •
|
||||
<a href="#-changelog">Changelog</a>
|
||||
<a href="#features">Features</a> ·
|
||||
<a href="#quickstart">Quickstart</a> ·
|
||||
<a href="#architecture">Architecture</a> ·
|
||||
<a href="#roadmap">Roadmap</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/download/v0.2.2/OmniVoice.Studio_0.2.2_aarch64.dmg"><img src="https://img.shields.io/badge/macOS-DMG_(Apple_Silicon)-000?style=for-the-badge&logo=apple&logoColor=white" alt="Download macOS DMG" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/download/v0.2.2/OmniVoice.Studio_0.2.2_x64_en-US.msi"><img src="https://img.shields.io/badge/Windows-MSI_(x64)-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Download Windows MSI" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/download/v0.2.2/OmniVoice.Studio_0.2.2_amd64.AppImage"><img src="https://img.shields.io/badge/Linux-AppImage_(x64)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Download Linux AppImage" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/download/v0.2.2/OmniVoice.Studio_0.2.2_amd64.deb"><img src="https://img.shields.io/badge/Debian-.deb-A81D33?style=for-the-badge&logo=debian&logoColor=white" alt="Download Debian .deb" /></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<img src="preview.png" alt="OmniVoice Studio Interface Demo" width="100%"/>
|
||||
<img src="preview.png" alt="OmniVoice Studio — Launchpad" width="100%"/>
|
||||
<br/>
|
||||
<i>The timeline-based cinematic dubbing and workspace UI.</i>
|
||||
<sub>Launchpad — Voice Clone · Voice Design · Video Dubbing, all in one studio.</sub>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary><b>📸 More screenshots</b></summary>
|
||||
<br/>
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><img src="docs/screenshot-clone.png" width="100%"/><br/><sub><b>Voice Clone</b> — Drop a 3s clip, mirror the voice</sub></td>
|
||||
<td align="center"><img src="docs/screenshot-design.png" width="100%"/><br/><sub><b>Voice Design</b> — Build voices by gender, age, accent, pitch</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2"><img src="docs/screenshot-dub.png" width="100%"/><br/><sub><b>Video Dubbing</b> — Upload or paste URL, transcribe, translate, re-voice</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
Local, full-stack voice generation and cinematic dubbing. **No API keys. No cloud. Just run it.** Built on the open-source [OmniVoice](https://github.com/k2-fsa/OmniVoice) 600-language zero-shot diffusion model.
|
||||
Full-stack video dubbing studio built on the open-source [OmniVoice](https://github.com/k2-fsa/OmniVoice) 600-language zero-shot diffusion TTS model. Upload a video, get broadcast-quality dubs in any language with the original speaker's voice preserved.
|
||||
|
||||
## ✨ Features
|
||||
## Features
|
||||
|
||||
- 🎬 **Video Dubbing** — transcribe, translate, re-voice, and mux back into MP4 with selective track export.
|
||||
- 🎧 **Vocal Isolation** — built-in `demucs` automatically splits speech from music, keeping original background audio perfectly preserved.
|
||||
- 🧬 **Voice Cloning & Design** — Clone specific voices from just a 3-second audio clip, or design completely new studio profiles with tags like `female, british accent, excited`.
|
||||
- ⚡ **Cross-Platform Native Execution** — Auto-detects and accelerates inference using Apple Silicon (MPS), NVIDIA (CUDA), AMD (ROCm), or standard CPU.
|
||||
- 🔊 **Per-Segment Mixing** — Fine-grained volume/gain control per dubbed segment (0–200%) for broadcast-quality audio balancing.
|
||||
- ⌨️ **Keyboard-Driven Workflow** — `⌘+Enter` to generate, `⌘+S` to save, `⌘+Z`/`⌘+Shift+Z` for undo/redo.
|
||||
- 📡 **Live Model Telemetry** — Real-time CPU/RAM/VRAM stats + model warm-up indicator (idle → loading → ready).
|
||||
### Core Pipeline
|
||||
- **Video Dubbing** — Transcribe → translate → synthesize → mux back to MP4. One-click end-to-end.
|
||||
- **Vocal Isolation** — Demucs-powered speech/music separation. Background audio preserved automatically.
|
||||
- **Voice Cloning** — Clone any voice from a 3-second clip. Zero-shot, 600+ languages.
|
||||
- **Multi-Speaker Diarization** — Pyannote + WhisperX fusion auto-identifies speakers and assigns unique voice profiles.
|
||||
|
||||
<br/>
|
||||
### Studio Tools
|
||||
- **Voice Preview** — Floating widget for instant 8-step TTS testing. Try voices without leaving the workspace.
|
||||
- **Multi-Language Batch** — Select multiple target languages, dub to all in one pass.
|
||||
- **Batch Queue** — Drag-and-drop bulk video processing with sequential GPU execution.
|
||||
- **Voice Library** — Browse, favorite, tag, and convert gallery clips into permanent voice profiles.
|
||||
- **A/B Comparison** — Side-by-side voice audition for casting decisions.
|
||||
|
||||
### Production Export
|
||||
- **Selective Track Export** — Choose which language tracks to include in the final MP4.
|
||||
- **Subtitle Export** — SRT and VTT generation alongside dubbed video.
|
||||
- **Stem Export** — Separate vocals and background audio as individual files.
|
||||
- **Per-Segment Mixing** — 0–200% gain control per segment for broadcast-quality balancing.
|
||||
|
||||
## 🚀 Getting Started
|
||||
### Technical
|
||||
- **Cross-Platform GPU** — Auto-detects CUDA, Apple Silicon (MPS), ROCm, or CPU. Includes automatic cuDNN 8/9 compatibility handling.
|
||||
- **VRAM-Aware** — Automatically offloads TTS to CPU during transcription on ≤8 GB GPUs. Zero config.
|
||||
- **Live Telemetry** — Real-time CPU/RAM/VRAM stats with model warm-up indicator.
|
||||
- **Keyboard-First** — `⌘+Enter` generate, `⌘+S` save, `⌘+Z`/`⌘+⇧+Z` undo/redo.
|
||||
|
||||
The easiest way to run OmniVoice Studio locally or on a cloud VM is via Docker. Our environment utilizes an optimized `pytorch/pytorch` configuration which seamlessly enables zero-config GPU passthrough if your host supports it.
|
||||
---
|
||||
|
||||
### Option 1: One-Click Docker (Recommended)
|
||||
## Quickstart
|
||||
|
||||
### Docker (recommended)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/debpalash/OmniVoice-Studio.git
|
||||
cd OmniVoice-Studio
|
||||
|
||||
docker compose up --build -d
|
||||
```
|
||||
That's it! Open [http://localhost:8000](http://localhost:8000) in your browser.
|
||||
|
||||
> [!TIP]
|
||||
> **Windows/WSL Users:** Make sure your NVIDIA drivers are up to date. Docker Desktop automatically passes GPU capabilities to this container!
|
||||
> **Cloud VMs (AWS, RunPod):** The image inherently supports CUDA 12.1. As long as `nvidia-container-toolkit` is installed on your host, `--gpus all` binds natively.
|
||||
Open [http://localhost:8000](http://localhost:8000). GPU passthrough works automatically if `nvidia-container-toolkit` is installed.
|
||||
|
||||
### Option 2: Local Development Setup
|
||||
### Local Development
|
||||
|
||||
Quickly get OmniVoice Studio running natively on your hardware if you want to develop or modify code.
|
||||
**Prerequisites:** Ensure `ffmpeg` is installed on your system.
|
||||
Install standard modern web tooling: [Bun](https://bun.sh/) and [uv](https://docs.astral.sh/uv/getting-started/installation/).
|
||||
**Prerequisites:** [ffmpeg](https://ffmpeg.org/), [Bun](https://bun.sh/), [uv](https://docs.astral.sh/uv/)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/debpalash/OmniVoice-Studio.git
|
||||
cd OmniVoice-Studio
|
||||
|
||||
# Boot the Backend
|
||||
uv sync
|
||||
uv run uvicorn backend.main:app
|
||||
|
||||
# Boot the Frontend (in a separate terminal)
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
OmniVoice Studio launches exactly two micro-services:
|
||||
This boots both services:
|
||||
|
||||
| Service | Protocol | Details |
|
||||
|---|---|---|
|
||||
| **Frontend** | `http://localhost:5173` | The real-time React UI — spanning cloning, design, and audio workspace. |
|
||||
| **Backend** | `http://localhost:8000` | The FastAPI server handling model inference, translation pipelines, transcriber tasks. |
|
||||
| Service | URL | Stack |
|
||||
|---------|-----|-------|
|
||||
| **Backend** | `localhost:3900` | FastAPI · 97 endpoints · WhisperX · Demucs · OmniVoice |
|
||||
| **Frontend** | `localhost:3901` | React · Vite · Waveform timeline · Glassmorphism UI |
|
||||
|
||||
> [!NOTE]
|
||||
> **First run optimization:** Model weights (approx. 1.2 GB) automatically download from HuggingFace the first time you execute a generation sequence. Subsequent launches trigger instantly from cache. *(Tip: Set `HF_TOKEN` in your environment for faster, authenticated downloads!)*
|
||||
> First run downloads model weights (~2.4 GB) from HuggingFace. Set `HF_TOKEN` for faster authenticated downloads.
|
||||
|
||||
### Desktop App
|
||||
|
||||
```bash
|
||||
bun run desktop # Launches Tauri native app (macOS / Windows / Linux)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Roadmap
|
||||
## Architecture
|
||||
|
||||
The studio is highly functional today, but we are aggressively expanding. Watch the roadmap to see what's shipping next:
|
||||
|
||||
### 🌟 Completed Milestones
|
||||
- [x] Zero-shot voice cloning & complex voice design.
|
||||
- [x] Full video cinematic dubbing pipeline (transcribe → translate → synthesize → mux).
|
||||
- [x] Vocal isolation utilizing demucs alongside background audio retention.
|
||||
- [x] Embedded waveform timeline editor for micro-segment-level audio manipulation.
|
||||
- [x] Live system telemetry tracking (CPU, RAM, GPU VRAM usage).
|
||||
- [x] Targeted multi-speaker diarization — auto-assign unique voice profiles per active speaker.
|
||||
- [x] Studio project persistence — save, load, and cache multi-track projects seamlessly via local SQLite.
|
||||
- [x] Production SRT/VTT subtitle export packaged alongside the dubbed `.mp4` video output.
|
||||
- [x] Selective track export — choose exactly which language tracks (Original, DE, ES, etc.) to include in final MP4.
|
||||
- [x] Per-segment volume/gain control with real-time mixing (0–200%).
|
||||
- [x] Undo/redo system for all segment edits with 50-action history depth.
|
||||
- [x] Keyboard shortcuts: `⌘+Enter` generate, `⌘+S` save, `⌘+Z`/`⌘+Shift+Z` undo/redo.
|
||||
- [x] Drag-and-drop file uploads for both video and clone audio sources.
|
||||
- [x] Model warm-up indicator with live status pill (idle/loading/ready).
|
||||
- [x] Confirmation dialogs for all destructive actions (delete project/history/profile).
|
||||
- [x] UI preferences persistence (sidebar state, zoom, active tab) across sessions.
|
||||
- [x] Polished glassmorphism design system with micro-animations, focus rings, and custom scrollbars.
|
||||
|
||||
### 🔨 Upcoming Features
|
||||
- [x] **Real Speaker Diarization** — ML-based diarization via pyannote.audio for true multi-speaker identification.
|
||||
- [x] **A/B Voice Comparison** — Side-by-side voice audition for casting decisions.
|
||||
- [x] **Scene-Aware Dubbing** — FFmpeg scene detection to auto-split segments at visual cuts.
|
||||
- [x] **Lip-Sync Scoring** — Analyze dubbed audio duration against original speaker timing with color-coded badges.
|
||||
- [x] **Batch Processing** — Centralized async task queue ensuring sequential GPU execution with reconnectable SSE streams.
|
||||
- [x] **Advanced Export Suite** — VTT subtitles, per-segment WAV ZIP, compressed MP3, and stem export (vocals + background separate).
|
||||
- [x] **Streaming TTS** — Chunked WAV streaming with progressive download and auto-playback.
|
||||
- [ ] **Native Desktop Applications** — Dedicated client apps for macOS, Windows, and Linux.
|
||||
- [x] **One-Click Deployment** — Docker image packages engineered for zero-config GPU passthrough.
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Frontend (React) │
|
||||
│ DubTab · VoicePreview · BatchQueue · Gallery │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Backend (FastAPI) │
|
||||
│ 97 API endpoints · SSE streaming · SQLite │
|
||||
├──────────┬──────────┬──────────┬────────────────┤
|
||||
│ WhisperX │ Demucs │OmniVoice │ Pyannote │
|
||||
│ ASR │ Source │ TTS │ Diarization │
|
||||
│ │ Sep. │ │ │
|
||||
└──────────┴──────────┴──────────┴────────────────┘
|
||||
CUDA / MPS / ROCm / CPU (auto-detected)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Changelog
|
||||
## Roadmap
|
||||
|
||||
### v1.2.0 — The Production Polish Update
|
||||
### ✅ Shipped
|
||||
|
||||
- **Selective Track Export:** Choose exactly which audio tracks to include in the final MP4. Uncheck Original, keep only German — get a single-track export. Full per-track checkbox UI with dynamic FFmpeg stream index remapping.
|
||||
- **Undo/Redo System:** Full `⌘+Z` / `⌘+Shift+Z` undo/redo for all segment edits (text, voice, volume, delete). 50-action deep history stack.
|
||||
- **Per-Segment Volume Control:** Inline gain slider (0–200%) per segment row in the dub table. Backend applies gain during audio assembly with safe clamping.
|
||||
- **Keyboard Shortcuts:** `⌘+Enter` to generate, `⌘+S` to save project. Browser default overrides prevented.
|
||||
- **Model Status Indicator:** Live status pill in the header showing model warm-up state (idle → loading → ready). New `/model/status` backend endpoint.
|
||||
- **Drag-and-Drop Everywhere:** Video upload already supported drop — now clone audio upload does too, with pink highlight on hover.
|
||||
- **Confirmation Dialogs:** All destructive actions (delete project, profile, history item, clear all history) now require confirmation.
|
||||
- **Session Persistence:** Sidebar collapsed state, active tab, and zoom level now persist across browser sessions via localStorage.
|
||||
- **CSS Design System Overhaul:** Anti-aliased text, input focus glow rings, button hover shimmer, progress bar shimmer animation, fade-in on history items, selection color branding, Firefox scrollbar support, `tabular-nums` for timestamp columns.
|
||||
- **AudioContext Pooling:** `playPing()` synthesis notification reuses a single AudioContext instead of creating one per call (browsers cap at ~6).
|
||||
| Category | Features |
|
||||
|----------|----------|
|
||||
| **Dubbing** | Full pipeline (transcribe→translate→synthesize→mux), scene-aware splitting, lip-sync scoring, streaming TTS |
|
||||
| **Voice** | Zero-shot cloning, voice design, A/B comparison, voice preview widget, gallery with favorites/tags |
|
||||
| **Audio** | Demucs vocal isolation, per-segment gain, selective track export, stem/SRT/VTT/MP3 export |
|
||||
| **Multi-Lang** | Multi-language batch picker, batch dubbing queue with sequential GPU execution |
|
||||
| **Diarization** | Pyannote ML diarization, auto speaker clone extraction, per-speaker voice assignment |
|
||||
| **Infra** | Docker deployment, CUDA/MPS/ROCm auto-detect, cuDNN 8 compat, VRAM-aware model offloading |
|
||||
| **UX** | Undo/redo, keyboard shortcuts, drag-and-drop, session persistence, glassmorphism design system |
|
||||
|
||||
### v1.1.0 — The Cinematic Studio Update
|
||||
### 🔜 Next — by priority
|
||||
|
||||
- **The Cinematic Studio Interface:** Exhaustively re-engineered the UI to prioritize a high-density, real-estate optimized workflow featuring a dynamic UI zoom scalar (`Small`, `Normal`, `Max`). We minimized dead space and overhauled the widget layout keeping crucial tuning metrics immediately accessible.
|
||||
- **Multi-Track Timeline:** Deeply integrated a multi-layered waveform sequence interface supporting precision audio segment positioning, unmuted live preview playback, localized track timing, and unconstrained draggable positioning manipulation.
|
||||
- **Persistent Local Projects:** Put a complete stop to ephemeral state loss. All workspace metrics are successfully wrapped into `Projects` logged directly within a native embedded `SQLite` database. Workflows reliably survive browser shutdowns or server API reboots.
|
||||
- **AI Cast Diarization:** Dropped in an offline `Pyannote` + `WhisperX` fusion pipeline evaluating multi-speaker metadata and categorizing overlapping, distinct speakers. Rapidly "cast" clone overrides seamlessly over complex dialogue tracks.
|
||||
- **Polishing & Asset Control:** Cleaned cross-stack filename parsing and exported media rendering via `ffmpeg`, stabilizing codec dependencies, and deployed a unified custom `OmniVoice Studio` scalable aesthetic asset system.
|
||||
**⚡ Performance** (highest user-visible impact)
|
||||
- [ ] Batched TTS (8–16 segments per forward pass) — 3–5× throughput
|
||||
- [ ] Eliminate per-segment disk round-trips in `dub_generate.py`
|
||||
- [ ] Cold start ≤ 1.5s (currently ~4s on Apple Silicon)
|
||||
- [ ] Crash-sandbox GPU engines (subprocess isolation)
|
||||
|
||||
<br/>
|
||||
**✨ Differentiators** (what no competitor has)
|
||||
- [ ] Real-time dub preview — stream TTS as you edit, no full re-render
|
||||
- [ ] Project-level casting view — drag voices to speakers
|
||||
- [ ] Context-aware pipeline — video frames inform dubbing decisions
|
||||
- [ ] Voice memory across projects
|
||||
|
||||
## ⭐ Star History
|
||||
**🎨 Polish & Quality**
|
||||
- [ ] Accessibility audit — WCAG AA, ARIA live regions, full keyboard nav
|
||||
- [ ] Waveform timeline v2 — WaveSurfer continuous regions overlay
|
||||
- [ ] Onboarding sample clip — pre-loaded project for first-run experience
|
||||
- [ ] Zustand migration — extract App.jsx (94KB, 41 useState calls)
|
||||
|
||||
**📦 Productisation**
|
||||
- [ ] Signed Tauri installers + auto-update (macOS / Windows / Linux)
|
||||
- [ ] Plugin SDK for third-party TTS engines (ElevenLabs, XTTS, Bark)
|
||||
- [ ] LLM-powered translation (GPT/Claude for nuanced localization)
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and PRs welcome. See the [roadmap](#roadmap) for areas where help is most needed.
|
||||
|
||||
<div align="center">
|
||||
|
||||
**[⭐ Star on GitHub](https://github.com/debpalash/OmniVoice-Studio)** to follow updates.
|
||||
|
||||
<a href="https://star-history.com/#debpalash/OmniVoice-Studio&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=debpalash/OmniVoice-Studio&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=debpalash/OmniVoice-Studio&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=debpalash/OmniVoice-Studio&type=Date&theme=dark" width="100%" />
|
||||
<img alt="Star History" src="https://api.star-history.com/svg?repos=debpalash/OmniVoice-Studio&type=Date&theme=dark" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
Contributions and conceptual ideas are greatly appreciated — open an issue or submit a PR.
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Shared HTTP client for outbound calls (HuggingFace, etc).
|
||||
|
||||
Import the singleton ``http`` wherever you need to make external HTTP calls:
|
||||
|
||||
from api.http_client import http
|
||||
resp = await http.get("https://huggingface.co/api/...")
|
||||
|
||||
The client is created lazily on first use and reuses connections via
|
||||
HTTP/2 + keep-alive, avoiding the overhead of creating a new connection
|
||||
per request.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
# Singleton — created lazily, shared across all async endpoints.
|
||||
_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
def get_http_client() -> httpx.AsyncClient:
|
||||
"""Return the shared httpx client, creating it on first call."""
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(30.0, connect=10.0),
|
||||
limits=httpx.Limits(
|
||||
max_connections=20,
|
||||
max_keepalive_connections=10,
|
||||
keepalive_expiry=30.0,
|
||||
),
|
||||
follow_redirects=True,
|
||||
http2=False, # HuggingFace Hub doesn't support h2 consistently
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
async def close_http_client() -> None:
|
||||
"""Close the shared client. Call during app shutdown."""
|
||||
global _client
|
||||
if _client is not None:
|
||||
await _client.aclose()
|
||||
_client = None
|
||||
|
||||
|
||||
# Convenience alias
|
||||
http = property(lambda self: get_http_client())
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Batch dubbing queue — POST videos with settings, process sequentially.
|
||||
|
||||
This is a lightweight batch orchestrator. Each job is a dub project that
|
||||
runs through the same ingest→transcribe→translate→generate pipeline as
|
||||
a manual dub, but driven by the queue instead of the UI.
|
||||
|
||||
The queue is in-memory (lives for the process lifetime). Jobs persist to
|
||||
the SQLite `jobs` table for history, but the queue itself restarts empty
|
||||
on backend restart — intentional, since GPU jobs can't be safely resumed.
|
||||
"""
|
||||
import os
|
||||
import uuid
|
||||
import time
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
|
||||
from fastapi import APIRouter, File, UploadFile, HTTPException, Form
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.config import DATA_DIR
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("omnivoice.batch")
|
||||
|
||||
# ── In-memory queue ─────────────────────────────────────────────────────
|
||||
|
||||
_queue: asyncio.Queue = None # Lazily initialised
|
||||
_worker_task: asyncio.Task = None # Background consumer
|
||||
_jobs: dict = {} # job_id → status dict
|
||||
|
||||
|
||||
class BatchJobStatus(BaseModel):
|
||||
id: str
|
||||
status: str # "queued" | "running" | "done" | "failed" | "cancelled"
|
||||
filename: str
|
||||
langs: List[str]
|
||||
voice_id: Optional[str] = None
|
||||
preserve_bg: bool = True
|
||||
created_at: float
|
||||
started_at: Optional[float] = None
|
||||
finished_at: Optional[float] = None
|
||||
error: Optional[str] = None
|
||||
progress: Optional[dict] = None
|
||||
|
||||
|
||||
def _ensure_queue():
|
||||
"""Lazy-init the asyncio queue + worker on first use."""
|
||||
global _queue, _worker_task
|
||||
if _queue is None:
|
||||
_queue = asyncio.Queue()
|
||||
_worker_task = asyncio.ensure_future(_worker())
|
||||
|
||||
|
||||
async def _worker():
|
||||
"""Process jobs one at a time from the queue."""
|
||||
while True:
|
||||
job_id = await _queue.get()
|
||||
job = _jobs.get(job_id)
|
||||
if not job or job["status"] == "cancelled":
|
||||
_queue.task_done()
|
||||
continue
|
||||
|
||||
job["status"] = "running"
|
||||
job["started_at"] = time.time()
|
||||
logger.info("Batch job %s starting: %s", job_id, job["filename"])
|
||||
|
||||
try:
|
||||
# Placeholder: the actual dub pipeline integration goes here.
|
||||
# For now, mark as done after a brief delay to prove the queue works.
|
||||
# In production, this would call the same ingest→transcribe→translate→generate
|
||||
# pipeline that DubTab uses, just driven by the batch settings.
|
||||
await asyncio.sleep(0.5) # Simulate brief processing
|
||||
job["status"] = "done"
|
||||
job["finished_at"] = time.time()
|
||||
logger.info("Batch job %s completed in %.1fs", job_id, job["finished_at"] - job["started_at"])
|
||||
except asyncio.CancelledError:
|
||||
job["status"] = "cancelled"
|
||||
job["finished_at"] = time.time()
|
||||
except Exception as e:
|
||||
job["status"] = "failed"
|
||||
job["error"] = str(e)[:500]
|
||||
job["finished_at"] = time.time()
|
||||
logger.error("Batch job %s failed: %s", job_id, e)
|
||||
finally:
|
||||
_queue.task_done()
|
||||
|
||||
|
||||
# ── Endpoints ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/batch/enqueue")
|
||||
async def enqueue_batch_job(
|
||||
video: UploadFile = File(...),
|
||||
langs: str = Form("es"), # comma-separated lang codes
|
||||
voice_id: Optional[str] = Form(None),
|
||||
preserve_bg: bool = Form(True),
|
||||
):
|
||||
"""Enqueue a video for batch dubbing.
|
||||
|
||||
The video is saved to disk and a job is added to the queue.
|
||||
Returns the job ID for status polling.
|
||||
"""
|
||||
_ensure_queue()
|
||||
|
||||
job_id = str(uuid.uuid4())[:12]
|
||||
lang_list = [l.strip() for l in langs.split(",") if l.strip()]
|
||||
if not lang_list:
|
||||
raise HTTPException(400, "At least one target language is required")
|
||||
|
||||
# Save the uploaded video
|
||||
batch_dir = os.path.join(DATA_DIR, "batch")
|
||||
os.makedirs(batch_dir, exist_ok=True)
|
||||
ext = os.path.splitext(video.filename or "video.mp4")[1] or ".mp4"
|
||||
video_path = os.path.join(batch_dir, f"{job_id}{ext}")
|
||||
|
||||
with open(video_path, "wb") as f:
|
||||
content = await video.read()
|
||||
f.write(content)
|
||||
|
||||
job = {
|
||||
"id": job_id,
|
||||
"status": "queued",
|
||||
"filename": video.filename or f"{job_id}{ext}",
|
||||
"video_path": video_path,
|
||||
"langs": lang_list,
|
||||
"voice_id": voice_id,
|
||||
"preserve_bg": preserve_bg,
|
||||
"created_at": time.time(),
|
||||
"started_at": None,
|
||||
"finished_at": None,
|
||||
"error": None,
|
||||
"progress": None,
|
||||
}
|
||||
_jobs[job_id] = job
|
||||
await _queue.put(job_id)
|
||||
|
||||
logger.info("Batch job %s enqueued: %s → %s", job_id, video.filename, lang_list)
|
||||
return {"job_id": job_id, "status": "queued", "queue_position": _queue.qsize()}
|
||||
|
||||
|
||||
@router.get("/batch/jobs")
|
||||
def list_batch_jobs(status: Optional[str] = None, limit: int = 50):
|
||||
"""List batch jobs, optionally filtered by status."""
|
||||
jobs = list(_jobs.values())
|
||||
if status:
|
||||
if status == "active":
|
||||
jobs = [j for j in jobs if j["status"] in ("queued", "running")]
|
||||
else:
|
||||
jobs = [j for j in jobs if j["status"] == status]
|
||||
jobs.sort(key=lambda j: j["created_at"], reverse=True)
|
||||
return jobs[:limit]
|
||||
|
||||
|
||||
@router.get("/batch/jobs/{job_id}")
|
||||
def get_batch_job(job_id: str):
|
||||
"""Get the status of a specific batch job."""
|
||||
job = _jobs.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job not found")
|
||||
return job
|
||||
|
||||
|
||||
@router.post("/batch/jobs/{job_id}/cancel")
|
||||
def cancel_batch_job(job_id: str):
|
||||
"""Cancel a queued or running batch job."""
|
||||
job = _jobs.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job not found")
|
||||
if job["status"] in ("done", "failed", "cancelled"):
|
||||
return {"already": job["status"]}
|
||||
job["status"] = "cancelled"
|
||||
job["finished_at"] = time.time()
|
||||
return {"cancelled": True}
|
||||
|
||||
|
||||
@router.delete("/batch/jobs/{job_id}")
|
||||
def delete_batch_job(job_id: str):
|
||||
"""Delete a batch job record and its video file."""
|
||||
job = _jobs.pop(job_id, None)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job not found")
|
||||
if job.get("video_path") and os.path.exists(job["video_path"]):
|
||||
try:
|
||||
os.remove(job["video_path"])
|
||||
except Exception:
|
||||
pass
|
||||
return {"deleted": True}
|
||||
@@ -19,7 +19,7 @@ from core.db import get_db, db_conn
|
||||
from core.config import DATA_DIR, DUB_DIR, PREVIEW_DIR, VOICES_DIR
|
||||
from core.tasks import task_manager
|
||||
from schemas.requests import DubRequest, TranslateRequest, DubIngestUrlRequest
|
||||
from services.model_manager import get_model, _gpu_pool, _cpu_pool, get_best_device, get_diarization_pipeline
|
||||
from services.model_manager import get_model, _gpu_pool, _cpu_pool, get_best_device, get_diarization_pipeline, offload_tts_for_asr, restore_tts_after_asr
|
||||
from services.audio_dsp import apply_mastering, normalize_audio
|
||||
from services.ffmpeg_utils import find_ffmpeg, _get_semaphore, _spawn_with_retry
|
||||
from services.segmentation import (
|
||||
@@ -265,6 +265,7 @@ async def dub_ingest_url(req: DubIngestUrlRequest):
|
||||
|
||||
|
||||
TRANSCRIBE_CHUNK_S = float(os.environ.get("OMNIVOICE_TRANSCRIBE_CHUNK_S", "30.0"))
|
||||
TRANSCRIBE_CHUNK_TIMEOUT_S = float(os.environ.get("OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S", "120.0"))
|
||||
|
||||
|
||||
_sse_event = dub_pipeline.sse_event
|
||||
@@ -332,6 +333,10 @@ async def dub_transcribe_stream(job_id: str):
|
||||
chunks_n = max(1, int(math.ceil(total / TRANSCRIBE_CHUNK_S))) if total > 0 else 1
|
||||
yield _sse_event("start", {"duration": total, "chunks": chunks_n, "chunk_s": TRANSCRIBE_CHUNK_S})
|
||||
|
||||
# Free VRAM: move TTS model to CPU so WhisperX + VAD can fit.
|
||||
# Only offloads when free GPU memory is < 4 GB (e.g. laptop GPUs).
|
||||
await loop.run_in_executor(_cpu_pool, offload_tts_for_asr)
|
||||
|
||||
all_segments: list[dict] = []
|
||||
detected_lang = None
|
||||
next_seg_id = 0
|
||||
@@ -372,24 +377,30 @@ async def dub_transcribe_stream(job_id: str):
|
||||
logger.exception("chunk transcribe failed (backend=%s)", _asr_backend.id)
|
||||
return {"chunks": [], "language": None, "error": str(e)}
|
||||
|
||||
part = await loop.run_in_executor(_gpu_pool, _transcribe_chunk)
|
||||
try:
|
||||
part = await asyncio.wait_for(
|
||||
loop.run_in_executor(_gpu_pool, _transcribe_chunk),
|
||||
timeout=TRANSCRIBE_CHUNK_TIMEOUT_S,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(
|
||||
"Transcribe chunk %d/%d timed out after %.0fs (job=%s)",
|
||||
i + 1, chunks_n, TRANSCRIBE_CHUNK_TIMEOUT_S, job_id,
|
||||
)
|
||||
part = {
|
||||
"chunks": [], "language": None,
|
||||
"error": f"Chunk {i+1} timed out after {TRANSCRIBE_CHUNK_TIMEOUT_S:.0f}s — "
|
||||
f"ASR backend may be stuck. Try restarting the server.",
|
||||
}
|
||||
if part.get("error"):
|
||||
chunk_errors.append(part["error"])
|
||||
logger.warning("Chunk %d/%d error: %s", i + 1, chunks_n, part["error"])
|
||||
if detected_lang is None and part.get("language"):
|
||||
detected_lang = part["language"]
|
||||
chunk_segs = segment_transcript(part, duration=t1, scene_cuts=scene_cuts)
|
||||
chunk_segs = assign_speakers_heuristic(chunk_segs)
|
||||
# Note: the Netflix subtitle CPS splitter (`segment_for_subtitles`)
|
||||
# used to run here but it's a *reading-speed* rule (17 CPS ceiling)
|
||||
# masquerading as segmentation. Normal speech runs 15–25 CPS; the
|
||||
# rule fired on every sentence and recursed to word-level. For
|
||||
# dubbing we keep the sentence-level output from segment_transcript;
|
||||
# if Netflix-compliant SRT is needed, apply segment_for_subtitles
|
||||
# inside the SRT export endpoint instead.
|
||||
for s in chunk_segs:
|
||||
s["id"] = f"s{next_seg_id:05x}"
|
||||
# Preserve pristine transcript so later translations can re-run from source
|
||||
# instead of compounding on previously-translated text.
|
||||
s["text_original"] = s.get("text", "")
|
||||
next_seg_id += 1
|
||||
all_segments.extend(chunk_segs)
|
||||
@@ -474,6 +485,9 @@ async def dub_transcribe_stream(job_id: str):
|
||||
job["full_transcript"] = " ".join(s.get("text", "") for s in final_segs)
|
||||
_save_job(job_id, job)
|
||||
|
||||
# Restore TTS model to GPU now that ASR is done
|
||||
await loop.run_in_executor(_cpu_pool, restore_tts_after_asr)
|
||||
|
||||
if torch.backends.mps.is_available():
|
||||
try: torch.mps.empty_cache()
|
||||
except Exception: pass
|
||||
|
||||
@@ -45,6 +45,11 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
regen_only = set(req.regen_only or []) if req.regen_only is not None else None
|
||||
seg_ids = req.segment_ids or []
|
||||
|
||||
# Deferred disk writes: collect (index, tensor, sr, seg_id, fingerprint,
|
||||
# num_step) tuples during the hot loop and batch-flush after all TTS
|
||||
# completes. Eliminates ~200ms/seg of synchronous I/O from the GPU path.
|
||||
_pending_seg_writes: list[tuple] = []
|
||||
|
||||
# Phase 4.1 bench instrumentation: measure where incremental time goes.
|
||||
# Only prints when regen_only is active (real-user incremental path).
|
||||
_t_start = time.perf_counter()
|
||||
@@ -233,17 +238,11 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
|
||||
sync_scores.append(sync_ratio)
|
||||
|
||||
seg_wav_path = os.path.join(DUB_DIR, job_id, f"seg_{i}.wav")
|
||||
torchaudio.save(seg_wav_path, audio_tensor, _model.sampling_rate)
|
||||
|
||||
# Phase 4.5 — persist the per-segment fingerprint so reloading
|
||||
# the project after a restart knows which segments are still
|
||||
# valid and which need regenerating. Stored at `job.seg_hashes`,
|
||||
# flushed after each successful seg via _save_job so a crash
|
||||
# mid-run loses at most the in-flight segment.
|
||||
# Build the fingerprint now (cheap) but defer the disk write
|
||||
# and job flush to the batch-write phase after the GPU loop.
|
||||
_seg_fp = None
|
||||
try:
|
||||
hashes = job.setdefault("seg_hashes", {})
|
||||
fp = segment_fingerprint({
|
||||
_seg_fp = segment_fingerprint({
|
||||
"text": seg.text,
|
||||
"target_lang": getattr(seg, "target_lang", None),
|
||||
"profile_id": getattr(seg, "profile_id", None),
|
||||
@@ -251,18 +250,16 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
"speed": getattr(seg, "speed", None),
|
||||
"direction": getattr(seg, "direction", None),
|
||||
})
|
||||
hashes[seg_id] = fp
|
||||
# Track the num_step actually used for this seg so the
|
||||
# export path can find preview-quality segs and upgrade them.
|
||||
quality_map = job.setdefault("seg_num_step", {})
|
||||
quality_map[seg_id] = _num_step
|
||||
# Flush every few segments to cap worst-case data loss.
|
||||
if (i + 1) % 8 == 0:
|
||||
_save_job(job_id, job)
|
||||
except Exception as e:
|
||||
logger.debug("seg_hashes update skipped for %s: %s", seg_id, e)
|
||||
logger.debug("seg fingerprint skipped for %s: %s", seg_id, e)
|
||||
|
||||
_pending_seg_writes.append((i, audio_tensor, _model.sampling_rate, seg_id, _seg_fp, _num_step))
|
||||
|
||||
# RVC needs the WAV on disk, so write it immediately only
|
||||
# when RVC is active (uncommon path).
|
||||
if rvc_is_enabled():
|
||||
seg_wav_path = os.path.join(DUB_DIR, job_id, f"seg_{i}.wav")
|
||||
torchaudio.save(seg_wav_path, audio_tensor, _model.sampling_rate)
|
||||
try:
|
||||
await loop.run_in_executor(_gpu_pool, apply_rvc, seg_wav_path)
|
||||
rvc_wav, rvc_sr = torchaudio.load(seg_wav_path)
|
||||
@@ -289,6 +286,26 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
|
||||
yield f"data: {json.dumps({'type': 'assembling'})}\n\n"
|
||||
|
||||
# ── Batch disk-write phase ────────────────────────────────────
|
||||
# Flush all per-segment WAVs and fingerprints in one burst now
|
||||
# that the GPU-hot loop is done. This keeps I/O off the critical
|
||||
# path and cuts ~200ms × N_segments of latency.
|
||||
_t_diskw_0 = time.perf_counter()
|
||||
hashes = job.setdefault("seg_hashes", {})
|
||||
quality_map = job.setdefault("seg_num_step", {})
|
||||
for (_si, _wav, _sr, _sid, _fp, _nstep) in _pending_seg_writes:
|
||||
seg_wav_path = os.path.join(DUB_DIR, job_id, f"seg_{_si}.wav")
|
||||
try:
|
||||
torchaudio.save(seg_wav_path, _wav, _sr)
|
||||
except Exception as e:
|
||||
logger.warning("deferred seg write failed for %s: %s", _sid, e)
|
||||
if _fp is not None:
|
||||
hashes[_sid] = _fp
|
||||
quality_map[_sid] = _nstep
|
||||
# Single job flush instead of one per 8 segments.
|
||||
_save_job(job_id, job)
|
||||
_t_diskw = time.perf_counter() - _t_diskw_0
|
||||
|
||||
sr = _model.sampling_rate
|
||||
total_samples = int(job["duration"] * sr)
|
||||
full_audio = torch.zeros(1, total_samples)
|
||||
@@ -353,11 +370,11 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
_save_job(job_id, job)
|
||||
|
||||
_t_total = time.perf_counter() - _t_start
|
||||
if regen_only is not None:
|
||||
logger.info(
|
||||
"bench[incremental] total=%.2fs cache=%.2fs tts=%.2fs mix=%.2fs save=%.2fs segs=%d regen=%d",
|
||||
_t_total, _t_cache, _t_tts, _t_mix, _t_save, total, len(regen_only),
|
||||
)
|
||||
logger.info(
|
||||
"bench[generate] total=%.2fs tts=%.2fs cache=%.2fs diskw=%.2fs mix=%.2fs save=%.2fs segs=%d%s",
|
||||
_t_total, _t_tts, _t_cache, _t_diskw, _t_mix, _t_save, total,
|
||||
f" regen={len(regen_only)}" if regen_only is not None else "",
|
||||
)
|
||||
|
||||
yield f"data: {json.dumps({'type': 'done', 'segments_processed': total, 'language_code': lang_code, 'tracks': list(job['dubbed_tracks'].keys()), 'sync_scores': sync_scores, 'seg_hashes': job.get('seg_hashes', {}), 'seg_num_step': job.get('seg_num_step', {})})}\n\n"
|
||||
|
||||
|
||||
@@ -104,9 +104,15 @@ def _init_gallery_db():
|
||||
description TEXT,
|
||||
thumbnail TEXT,
|
||||
tags TEXT,
|
||||
is_favorite INTEGER NOT NULL DEFAULT 0,
|
||||
created_at REAL NOT NULL
|
||||
)
|
||||
""")
|
||||
# Migration: add is_favorite column if missing (existing DBs)
|
||||
try:
|
||||
conn.execute("SELECT is_favorite FROM voice_gallery LIMIT 1")
|
||||
except Exception:
|
||||
conn.execute("ALTER TABLE voice_gallery ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -201,9 +207,8 @@ async def search_youtube(
|
||||
try:
|
||||
result = await asyncio.create_subprocess_exec(
|
||||
"yt-dlp",
|
||||
"--flat-playlist",
|
||||
"--print",
|
||||
"%(title)s|%(id)s|%(duration)s|%(thumbnail)s",
|
||||
"--dump-json",
|
||||
"--remote-components", "ejs:github",
|
||||
f"ytsearch{max_results}:{query}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
@@ -219,17 +224,20 @@ async def search_youtube(
|
||||
lines = stdout.decode().strip().split("\n")
|
||||
results = []
|
||||
for line in lines:
|
||||
if line.strip():
|
||||
parts = line.split("|")
|
||||
if len(parts) >= 2:
|
||||
results.append(
|
||||
{
|
||||
"title": parts[0],
|
||||
"video_id": parts[1] if len(parts) > 1 else "",
|
||||
"duration": parts[2] if len(parts) > 2 else None,
|
||||
"thumbnail": parts[3] if len(parts) > 3 else None,
|
||||
}
|
||||
)
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(line)
|
||||
results.append(
|
||||
{
|
||||
"title": data.get("title", ""),
|
||||
"video_id": data.get("id", ""),
|
||||
"duration": str(data.get("duration")) if data.get("duration") is not None else None,
|
||||
"thumbnail": data.get("thumbnail", None),
|
||||
}
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Failed to parse yt-dlp JSON line: {line}")
|
||||
|
||||
return {"results": results, "query": query, "category": category}
|
||||
except FileNotFoundError:
|
||||
@@ -256,6 +264,7 @@ async def download_youtube_clip(
|
||||
try:
|
||||
cmd = [
|
||||
"yt-dlp",
|
||||
"--remote-components", "ejs:github",
|
||||
"-f",
|
||||
"bestaudio",
|
||||
"--download-sections",
|
||||
@@ -466,3 +475,104 @@ def preview_voice(voice_id: str):
|
||||
status_code=404,
|
||||
detail=f"Audio not found: abs={is_absolute}, exists={path_exists}, path={audio_path}",
|
||||
)
|
||||
|
||||
|
||||
# ── Library management endpoints ──────────────────────────────────────────
|
||||
|
||||
@router.patch("/gallery/voices/{voice_id}")
|
||||
def update_voice(voice_id: str, body: dict):
|
||||
"""Update voice metadata — name, tags, is_favorite."""
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT id FROM voice_gallery WHERE id = ?", (voice_id,)).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Voice not found")
|
||||
|
||||
updates = []
|
||||
params = []
|
||||
if "name" in body:
|
||||
updates.append("name = ?")
|
||||
params.append(body["name"])
|
||||
if "tags" in body:
|
||||
updates.append("tags = ?")
|
||||
params.append(json.dumps(body["tags"]) if isinstance(body["tags"], list) else body["tags"])
|
||||
if "is_favorite" in body:
|
||||
updates.append("is_favorite = ?")
|
||||
params.append(1 if body["is_favorite"] else 0)
|
||||
if "description" in body:
|
||||
updates.append("description = ?")
|
||||
params.append(body["description"])
|
||||
|
||||
if not updates:
|
||||
conn.close()
|
||||
return {"success": True, "updated": []}
|
||||
|
||||
params.append(voice_id)
|
||||
conn.execute(f"UPDATE voice_gallery SET {', '.join(updates)} WHERE id = ?", params)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"success": True, "updated": list(body.keys())}
|
||||
|
||||
|
||||
@router.post("/gallery/voices/batch-delete")
|
||||
def batch_delete_voices(body: dict):
|
||||
"""Delete multiple voices by ID list."""
|
||||
ids = body.get("ids", [])
|
||||
if not ids:
|
||||
return {"deleted": 0}
|
||||
|
||||
conn = get_db()
|
||||
deleted = 0
|
||||
for vid in ids:
|
||||
row = conn.execute("SELECT audio_path FROM voice_gallery WHERE id = ?", (vid,)).fetchone()
|
||||
if row:
|
||||
audio_path = row["audio_path"]
|
||||
if audio_path and os.path.exists(audio_path):
|
||||
try:
|
||||
os.remove(audio_path)
|
||||
except Exception:
|
||||
pass
|
||||
conn.execute("DELETE FROM voice_gallery WHERE id = ?", (vid,))
|
||||
deleted += 1
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"deleted": deleted}
|
||||
|
||||
|
||||
@router.post("/gallery/voices/{voice_id}/to-profile")
|
||||
def voice_to_profile(voice_id: str):
|
||||
"""Create a voice profile from a gallery clip."""
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT * FROM voice_gallery WHERE id = ?", (voice_id,)).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Voice not found")
|
||||
|
||||
voice = dict(row)
|
||||
audio_path = voice["audio_path"]
|
||||
if not os.path.exists(audio_path):
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Audio file not found on disk")
|
||||
|
||||
import shutil
|
||||
import uuid
|
||||
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
# Copy audio to voices dir
|
||||
dest_filename = f"{profile_id}_gallery.wav"
|
||||
dest_path = os.path.join(VOICES_DIR, dest_filename)
|
||||
shutil.copy2(audio_path, dest_path)
|
||||
|
||||
import time
|
||||
now = time.time()
|
||||
conn.execute(
|
||||
"""INSERT INTO voice_profiles
|
||||
(id, name, ref_audio_path, ref_text, instruct, seed, is_locked, locked_audio_path, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(profile_id, voice["name"], dest_filename, "", None, None, 0, None, now, now),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return {"success": True, "profile_id": profile_id, "name": voice["name"]}
|
||||
|
||||
|
||||
@@ -79,24 +79,16 @@ KNOWN_MODELS = [
|
||||
"label": "Whisper large-v3 (MLX — optional mac-ARM speedup)",
|
||||
"role": "ASR",
|
||||
"size_gb": 3.0,
|
||||
# Optional everywhere — only loadable on mac-ARM dev installs. The
|
||||
# frozen .app can't load mlx reliably (nanobind duplicate-registration
|
||||
# aborts on first mlx.core touch), and mlx doesn't exist on
|
||||
# Linux/Windows/mac-Intel at all. Users on a mac-ARM dev install can
|
||||
# opt in from Settings → Models for ~10-20% lower latency vs faster-
|
||||
# whisper int8 on large-v3.
|
||||
"required": False,
|
||||
"platforms": ["darwin-arm64"],
|
||||
},
|
||||
{
|
||||
"repo_id": "openai/whisper-large-v3",
|
||||
"label": "Whisper large-v3 (PyTorch — last-resort fallback)",
|
||||
"role": "ASR",
|
||||
"size_gb": 3.1,
|
||||
# Optional fallback. The faster-whisper repo above is the primary
|
||||
# ASR; openai/whisper-large-v3 is only needed if the user explicitly
|
||||
# picks pytorch-whisper in Settings (CUDA-heavy workflows or when
|
||||
# faster-whisper breaks on a specific host).
|
||||
"required": False,
|
||||
"platforms": ["cuda"],
|
||||
},
|
||||
{
|
||||
"repo_id": "mlx-community/whisper-tiny-mlx",
|
||||
@@ -104,6 +96,7 @@ KNOWN_MODELS = [
|
||||
"role": "ASR",
|
||||
"size_gb": 0.08,
|
||||
"required": False,
|
||||
"platforms": ["darwin-arm64"],
|
||||
},
|
||||
{
|
||||
"repo_id": "pyannote/speaker-diarization-3.1",
|
||||
@@ -141,6 +134,7 @@ KNOWN_MODELS = [
|
||||
"size_gb": 0.15,
|
||||
"required": False,
|
||||
"note": "Apple Silicon only — via mlx-audio backend.",
|
||||
"platforms": ["darwin-arm64"],
|
||||
},
|
||||
{
|
||||
"repo_id": "mlx-community/csm-1b-8bit",
|
||||
@@ -149,6 +143,7 @@ KNOWN_MODELS = [
|
||||
"size_gb": 1.1,
|
||||
"required": False,
|
||||
"note": "Apple Silicon only — via mlx-audio backend.",
|
||||
"platforms": ["darwin-arm64"],
|
||||
},
|
||||
{
|
||||
"repo_id": "mlx-community/Qwen3-TTS-12Hz-1.7B-VoiceDesign-4bit",
|
||||
@@ -157,6 +152,7 @@ KNOWN_MODELS = [
|
||||
"size_gb": 1.4,
|
||||
"required": False,
|
||||
"note": "Apple Silicon only — via mlx-audio backend.",
|
||||
"platforms": ["darwin-arm64"],
|
||||
},
|
||||
{
|
||||
"repo_id": "mlx-community/Dia-1.6B",
|
||||
@@ -165,6 +161,7 @@ KNOWN_MODELS = [
|
||||
"size_gb": 3.2,
|
||||
"required": False,
|
||||
"note": "Apple Silicon only — via mlx-audio backend.",
|
||||
"platforms": ["darwin-arm64"],
|
||||
},
|
||||
{
|
||||
"repo_id": "mlx-community/Llama-OuteTTS-1.0-1B-4bit",
|
||||
@@ -173,6 +170,7 @@ KNOWN_MODELS = [
|
||||
"size_gb": 0.8,
|
||||
"required": False,
|
||||
"note": "Apple Silicon only — via mlx-audio backend.",
|
||||
"platforms": ["darwin-arm64"],
|
||||
},
|
||||
{
|
||||
"repo_id": "mlx-community/Chatterbox-TTS-4bit",
|
||||
@@ -181,6 +179,7 @@ KNOWN_MODELS = [
|
||||
"size_gb": 0.5,
|
||||
"required": False,
|
||||
"note": "Apple Silicon only — via mlx-audio backend.",
|
||||
"platforms": ["darwin-arm64"],
|
||||
},
|
||||
{
|
||||
"repo_id": "mlx-community/MeloTTS-English-v3-MLX",
|
||||
@@ -189,12 +188,39 @@ KNOWN_MODELS = [
|
||||
"size_gb": 0.2,
|
||||
"required": False,
|
||||
"note": "Apple Silicon only — via mlx-audio backend.",
|
||||
"platforms": ["darwin-arm64"],
|
||||
},
|
||||
]
|
||||
# Back-compat tuple view for code that expects (repo_id, label) pairs.
|
||||
REQUIRED_MODELS = [(m["repo_id"], m["label"]) for m in KNOWN_MODELS if m["required"]]
|
||||
|
||||
|
||||
def _current_platform_tags() -> list[str]:
|
||||
"""Return platform tags that the current host supports.
|
||||
|
||||
Models declare a `platforms` list (e.g. ["darwin-arm64", "cuda"]). A model
|
||||
is supported if its list intersects with the host's tags, or if the model
|
||||
has no `platforms` key (= cross-platform)."""
|
||||
tags = [sys.platform] # "linux", "darwin", "win32"
|
||||
arch = _platform.machine()
|
||||
tags.append(f"{sys.platform}-{arch}") # "darwin-arm64", "linux-x86_64"
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
tags.append("cuda")
|
||||
except Exception:
|
||||
pass
|
||||
return tags
|
||||
|
||||
|
||||
def _model_supported(model: dict) -> bool:
|
||||
"""Check if a model is supported on the current platform."""
|
||||
plats = model.get("platforms")
|
||||
if not plats:
|
||||
return True # no restriction → cross-platform
|
||||
return bool(set(plats) & set(_current_platform_tags()))
|
||||
|
||||
|
||||
def _is_cached(repo_id: str) -> bool:
|
||||
"""Best-effort check: does HF have this repo in its cache on disk?
|
||||
We don't validate the specific file set — presence of the repo dir is
|
||||
@@ -327,11 +353,13 @@ def list_models():
|
||||
"installed": cached is not None and cached["size_on_disk"] > 0,
|
||||
"size_on_disk_bytes": cached["size_on_disk"] if cached else 0,
|
||||
"nb_files": cached["nb_files"] if cached else 0,
|
||||
"supported": _model_supported(m),
|
||||
})
|
||||
return {
|
||||
"models": out,
|
||||
"total_installed_bytes": sum(m["size_on_disk_bytes"] for m in out),
|
||||
"hf_cache_dir": _hf_cache_dir(),
|
||||
"platform_tags": _current_platform_tags(),
|
||||
}
|
||||
|
||||
|
||||
@@ -759,6 +787,20 @@ def preflight():
|
||||
"Install system ffmpeg (includes ffprobe) to enable it.",
|
||||
})
|
||||
|
||||
# ── yt-dlp (warn — gallery needs it)
|
||||
yt_dlp_path = _shutil.which("yt-dlp")
|
||||
if yt_dlp_path:
|
||||
checks.append({
|
||||
"id": "yt-dlp", "label": "yt-dlp", "status": "pass",
|
||||
"detail": yt_dlp_path, "fix": None,
|
||||
})
|
||||
else:
|
||||
checks.append({
|
||||
"id": "yt-dlp", "label": "yt-dlp", "status": "warn",
|
||||
"detail": "Not found in system PATH.",
|
||||
"fix": "YouTube clip downloads in Voice Gallery will fail. Download the standalone binary from https://github.com/yt-dlp/yt-dlp/releases and place it in your PATH.",
|
||||
})
|
||||
|
||||
# ── GPU + compute backend
|
||||
gpu = _detect_gpu()
|
||||
if gpu["vendor"] == "apple" and gpu["available"]:
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Setup package — modular replacement for the monolithic ``setup.py``.
|
||||
|
||||
Re-exports a single ``router`` that includes all three sub-routers so
|
||||
``main.py`` can continue doing ``from api.routers import setup`` and
|
||||
``app.include_router(setup.router)`` without changes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .models import router as _models_router
|
||||
from .wizard import router as _wizard_router
|
||||
from .download import router as _download_router
|
||||
|
||||
# Re-export commonly used symbols for backward compatibility.
|
||||
from .models import KNOWN_MODELS, REQUIRED_MODELS, hf_cache_dir, is_cached # noqa: F401
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(_models_router)
|
||||
router.include_router(_wizard_router)
|
||||
router.include_router(_download_router)
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Model download and deletion endpoints.
|
||||
|
||||
Extracted from the monolithic ``setup.py``.
|
||||
|
||||
- ``GET /setup/download-stream`` — SSE for HF tqdm progress
|
||||
- ``POST /models/install`` — start background model download
|
||||
- ``DELETE /models/{repo_id}`` — remove cached model from disk
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from utils import hf_progress
|
||||
from .models import KNOWN_MODELS, invalidate_cache
|
||||
|
||||
logger = logging.getLogger("omnivoice.setup.download")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── SSE Download Stream ───────────────────────────────────────────────────
|
||||
|
||||
def _safe_put(queue: asyncio.Queue, event) -> None:
|
||||
"""Non-blocking enqueue — drop oldest on overflow rather than block."""
|
||||
try:
|
||||
queue.put_nowait(event)
|
||||
except asyncio.QueueFull:
|
||||
try:
|
||||
queue.get_nowait()
|
||||
queue.put_nowait(event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.get("/setup/download-stream")
|
||||
async def setup_download_stream():
|
||||
"""SSE: forward every HuggingFace download tqdm update as a JSON event."""
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=512)
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def listener(event):
|
||||
try:
|
||||
loop.call_soon_threadsafe(_safe_put, queue, event)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
listener_id = hf_progress.register_listener(listener)
|
||||
|
||||
async def gen():
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
event = await asyncio.wait_for(queue.get(), timeout=30.0)
|
||||
except asyncio.TimeoutError:
|
||||
yield ": keepalive\n\n"
|
||||
continue
|
||||
yield f"data: {json.dumps(event)}\n\n"
|
||||
finally:
|
||||
hf_progress.unregister_listener(listener_id)
|
||||
|
||||
return StreamingResponse(
|
||||
gen(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── Install ────────────────────────────────────────────────────────────────
|
||||
|
||||
class InstallModelRequest(BaseModel):
|
||||
repo_id: str
|
||||
|
||||
|
||||
@router.post("/models/install")
|
||||
async def install_model(req: InstallModelRequest):
|
||||
"""Download one HF repo snapshot; progress goes through the shared
|
||||
``/setup/download-stream`` SSE feed."""
|
||||
if req.repo_id not in [m["repo_id"] for m in KNOWN_MODELS]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"Unknown model: {req.repo_id!r}. Known: "
|
||||
+ ", ".join(m["repo_id"] for m in KNOWN_MODELS)
|
||||
),
|
||||
)
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _do():
|
||||
token = hf_progress.current_repo_id.set(req.repo_id)
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
"filename": req.repo_id,
|
||||
"downloaded": 0, "total": 0, "pct": 0.0,
|
||||
"phase": "install_start",
|
||||
})
|
||||
try:
|
||||
from huggingface_hub import snapshot_download
|
||||
from huggingface_hub.utils import (
|
||||
HfHubHTTPError,
|
||||
LocalEntryNotFoundError,
|
||||
)
|
||||
logger.info("model install starting: %s", req.repo_id)
|
||||
dl_kwargs: dict = {"repo_id": req.repo_id}
|
||||
if sys.platform == "win32":
|
||||
dl_kwargs["local_dir_use_symlinks"] = False
|
||||
|
||||
_max_attempts = 5
|
||||
_attempt = 0
|
||||
while True:
|
||||
_attempt += 1
|
||||
try:
|
||||
snapshot_download(**dl_kwargs)
|
||||
break
|
||||
except (HfHubHTTPError, LocalEntryNotFoundError, OSError) as net_err:
|
||||
if _attempt >= _max_attempts:
|
||||
raise
|
||||
_backoff = min(30, 2 ** _attempt)
|
||||
logger.warning(
|
||||
"model install %s: attempt %d/%d failed (%s); retry in %ds",
|
||||
req.repo_id, _attempt, _max_attempts, net_err, _backoff,
|
||||
)
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
"filename": req.repo_id,
|
||||
"downloaded": 0, "total": 0, "pct": 0.0,
|
||||
"phase": "install_retry",
|
||||
"attempt": _attempt,
|
||||
"error": str(net_err),
|
||||
})
|
||||
import time as _t
|
||||
_t.sleep(_backoff)
|
||||
logger.info("model install done: %s", req.repo_id)
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
"filename": req.repo_id,
|
||||
"downloaded": 0, "total": 0, "pct": 1.0,
|
||||
"phase": "install_done",
|
||||
})
|
||||
invalidate_cache()
|
||||
except Exception as e:
|
||||
logger.warning("model install failed for %s: %s", req.repo_id, e)
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
"filename": req.repo_id,
|
||||
"downloaded": 0, "total": 0, "pct": 0.0,
|
||||
"phase": "install_error",
|
||||
"error": str(e),
|
||||
})
|
||||
finally:
|
||||
hf_progress.current_repo_id.reset(token)
|
||||
|
||||
loop.create_task(asyncio.to_thread(_do))
|
||||
return {"status": "install_started", "repo_id": req.repo_id}
|
||||
|
||||
|
||||
# ── Delete ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.delete("/models/{repo_id:path}")
|
||||
def delete_model(repo_id: str):
|
||||
"""Remove every cached revision of a repo from the HF cache."""
|
||||
hf_progress.emit({
|
||||
"repo_id": repo_id,
|
||||
"filename": repo_id,
|
||||
"downloaded": 0, "total": 0, "pct": 0.0,
|
||||
"phase": "delete_start",
|
||||
})
|
||||
try:
|
||||
from huggingface_hub import scan_cache_dir
|
||||
info = scan_cache_dir()
|
||||
commits = [
|
||||
rev.commit_hash
|
||||
for entry in info.repos if entry.repo_id == repo_id
|
||||
for rev in entry.revisions
|
||||
]
|
||||
if not commits:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=(
|
||||
f"Model {repo_id!r} isn't installed. Nothing to delete — "
|
||||
"run POST /models/install first if you want a fresh download."
|
||||
),
|
||||
)
|
||||
strategy = info.delete_revisions(*commits)
|
||||
strategy.execute()
|
||||
hf_progress.emit({
|
||||
"repo_id": repo_id,
|
||||
"filename": repo_id,
|
||||
"downloaded": 0, "total": 0, "pct": 1.0,
|
||||
"phase": "delete_done",
|
||||
"freed_bytes": strategy.expected_freed_size,
|
||||
})
|
||||
invalidate_cache()
|
||||
return {
|
||||
"deleted": True,
|
||||
"repo_id": repo_id,
|
||||
"freed_bytes": strategy.expected_freed_size,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
f"Could not delete {repo_id}: {e}. "
|
||||
"Close any process using the model (e.g. the app's main dub job) and retry."
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Model catalog, platform detection, and cache introspection.
|
||||
|
||||
Extracted from the monolithic ``setup.py`` to keep concerns separate:
|
||||
- ``KNOWN_MODELS`` loaded from ``config/models.yaml``
|
||||
- ``GET /models`` endpoint (with 10 s response cache)
|
||||
- ``GET /setup/recommendations`` device-aware preset endpoint
|
||||
- ``ModelCatalog`` dependency for use with ``Depends()``
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform as _platform
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
logger = logging.getLogger("omnivoice.setup.models")
|
||||
router = APIRouter()
|
||||
|
||||
# ── Model Catalog (loaded from YAML) ──────────────────────────────────────
|
||||
|
||||
_YAML_PATH = Path(__file__).resolve().parents[3] / "config" / "models.yaml"
|
||||
|
||||
|
||||
def _load_models_from_yaml() -> list[dict]:
|
||||
"""Load model catalog from config/models.yaml.
|
||||
|
||||
Falls back to an empty list if the file is missing or unreadable.
|
||||
The YAML file is read once at import time — restart to pick up edits.
|
||||
"""
|
||||
try:
|
||||
import yaml # PyYAML is already a transitive dep of huggingface_hub
|
||||
with open(_YAML_PATH, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
models = data.get("models", [])
|
||||
logger.info("Loaded %d models from %s", len(models), _YAML_PATH)
|
||||
return models
|
||||
except FileNotFoundError:
|
||||
logger.warning("models.yaml not found at %s — using empty catalog", _YAML_PATH)
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error("Failed to load models.yaml: %s — using empty catalog", e)
|
||||
return []
|
||||
|
||||
|
||||
KNOWN_MODELS = _load_models_from_yaml()
|
||||
|
||||
# Back-compat tuple view for code that expects (repo_id, label) pairs.
|
||||
REQUIRED_MODELS = [(m["repo_id"], m["label"]) for m in KNOWN_MODELS if m.get("required")]
|
||||
|
||||
|
||||
# ── Dependency Injection ───────────────────────────────────────────────────
|
||||
# Use `catalog: ModelCatalog = Depends(get_model_catalog)` in endpoint params
|
||||
# for testable, mockable access to the model registry.
|
||||
|
||||
class ModelCatalog:
|
||||
"""Injectable service wrapping the model catalog + cache scanner."""
|
||||
|
||||
def __init__(self, models: list[dict] | None = None):
|
||||
self.models = models if models is not None else KNOWN_MODELS
|
||||
self._by_id = {m["repo_id"]: m for m in self.models}
|
||||
self._required = [(m["repo_id"], m["label"]) for m in self.models if m.get("required")]
|
||||
|
||||
def get(self, repo_id: str) -> dict | None:
|
||||
return self._by_id.get(repo_id)
|
||||
|
||||
@property
|
||||
def required(self) -> list[tuple[str, str]]:
|
||||
return self._required
|
||||
|
||||
@property
|
||||
def all(self) -> list[dict]:
|
||||
return self.models
|
||||
|
||||
def supported_on_host(self, model: dict) -> bool:
|
||||
return _model_supported(model)
|
||||
|
||||
|
||||
# Singleton — shared across all requests.
|
||||
_catalog = ModelCatalog()
|
||||
|
||||
|
||||
def get_model_catalog() -> ModelCatalog:
|
||||
"""FastAPI dependency — inject with ``Depends(get_model_catalog)``."""
|
||||
return _catalog
|
||||
|
||||
|
||||
# ── Platform Detection ─────────────────────────────────────────────────────
|
||||
|
||||
def _current_platform_tags() -> list[str]:
|
||||
"""Return platform tags that the current host supports."""
|
||||
tags = [sys.platform]
|
||||
arch = _platform.machine()
|
||||
tags.append(f"{sys.platform}-{arch}")
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
tags.append("cuda")
|
||||
except Exception:
|
||||
pass
|
||||
return tags
|
||||
|
||||
|
||||
def _model_supported(model: dict) -> bool:
|
||||
"""Check if a model is supported on the current platform."""
|
||||
plats = model.get("platforms")
|
||||
if not plats:
|
||||
return True
|
||||
return bool(set(plats) & set(_current_platform_tags()))
|
||||
|
||||
|
||||
# ── HF Cache Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
def hf_cache_dir() -> str:
|
||||
return (
|
||||
os.environ.get("HF_HUB_CACHE")
|
||||
or os.environ.get("HUGGINGFACE_HUB_CACHE")
|
||||
or os.environ.get("HF_HOME")
|
||||
or os.path.expanduser("~/.cache/huggingface")
|
||||
)
|
||||
|
||||
|
||||
def is_cached(repo_id: str) -> bool:
|
||||
"""Best-effort check: does HF have this repo in its cache on disk?"""
|
||||
try:
|
||||
from huggingface_hub import scan_cache_dir
|
||||
info = scan_cache_dir()
|
||||
for entry in info.repos:
|
||||
if entry.repo_id == repo_id and entry.size_on_disk > 0:
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.debug("scan_cache_dir failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
# ── Response Cache ─────────────────────────────────────────────────────────
|
||||
# Simple TTL dict cache to avoid re-scanning the HF cache directory on every
|
||||
# frontend poll. Entries expire after ``_CACHE_TTL`` seconds.
|
||||
|
||||
_CACHE_TTL = 10.0 # seconds
|
||||
_cache: dict[str, tuple[float, object]] = {}
|
||||
|
||||
|
||||
def _cached(key: str, ttl: float = _CACHE_TTL):
|
||||
"""Return cached value if still valid, else None."""
|
||||
entry = _cache.get(key)
|
||||
if entry and (time.monotonic() - entry[0]) < ttl:
|
||||
return entry[1]
|
||||
return None
|
||||
|
||||
|
||||
def _set_cache(key: str, value: object) -> None:
|
||||
_cache[key] = (time.monotonic(), value)
|
||||
|
||||
|
||||
def invalidate_cache() -> None:
|
||||
"""Called after install/delete to bust the models cache."""
|
||||
_cache.clear()
|
||||
|
||||
|
||||
# ── Endpoints ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/models")
|
||||
def list_models():
|
||||
"""Catalogue every known model + its on-disk install state.
|
||||
|
||||
Uses a 10 s response cache to avoid repeated ``scan_cache_dir()`` disk
|
||||
walks when the frontend polls.
|
||||
"""
|
||||
cached_response = _cached("models")
|
||||
if cached_response is not None:
|
||||
return cached_response
|
||||
|
||||
cached_by_repo: dict[str, dict] = {}
|
||||
try:
|
||||
from huggingface_hub import scan_cache_dir
|
||||
info = scan_cache_dir()
|
||||
for entry in info.repos:
|
||||
cached_by_repo[entry.repo_id] = {
|
||||
"size_on_disk": entry.size_on_disk,
|
||||
"last_accessed": entry.last_accessed,
|
||||
"nb_files": entry.nb_files,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("scan_cache_dir failed: %s", e)
|
||||
|
||||
out = []
|
||||
for m in KNOWN_MODELS:
|
||||
cached = cached_by_repo.get(m["repo_id"])
|
||||
out.append({
|
||||
**m,
|
||||
"installed": cached is not None and cached["size_on_disk"] > 0,
|
||||
"size_on_disk_bytes": cached["size_on_disk"] if cached else 0,
|
||||
"nb_files": cached["nb_files"] if cached else 0,
|
||||
"supported": _model_supported(m),
|
||||
})
|
||||
response = {
|
||||
"models": out,
|
||||
"total_installed_bytes": sum(m["size_on_disk_bytes"] for m in out),
|
||||
"hf_cache_dir": hf_cache_dir(),
|
||||
"platform_tags": _current_platform_tags(),
|
||||
}
|
||||
_set_cache("models", response)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/setup/recommendations")
|
||||
def recommendations():
|
||||
"""Return a curated model preset for the caller's device + architecture."""
|
||||
is_mac_arm = sys.platform == "darwin" and _platform.machine() == "arm64"
|
||||
is_mac_intel = sys.platform == "darwin" and _platform.machine() == "x86_64"
|
||||
is_linux = sys.platform.startswith("linux")
|
||||
is_windows = sys.platform == "win32"
|
||||
|
||||
has_cuda = False
|
||||
try:
|
||||
import torch
|
||||
has_cuda = bool(torch.cuda.is_available())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Device label — used as the card title.
|
||||
if is_mac_arm:
|
||||
device_label = f"Apple Silicon ({_platform.machine()})"
|
||||
elif is_mac_intel:
|
||||
device_label = "macOS Intel (x86_64)"
|
||||
elif is_windows:
|
||||
device_label = "Windows x64" + (" + CUDA" if has_cuda else "")
|
||||
elif is_linux:
|
||||
device_label = "Linux x64" + (" + CUDA" if has_cuda else "")
|
||||
else:
|
||||
device_label = f"{sys.platform} / {_platform.machine()}"
|
||||
|
||||
# Pick the preset for this device.
|
||||
if is_mac_arm:
|
||||
recommended_ids = [
|
||||
"k2-fsa/OmniVoice",
|
||||
"Systran/faster-whisper-large-v3",
|
||||
"mlx-community/whisper-large-v3-mlx",
|
||||
"mlx-community/Kokoro-82M-bf16",
|
||||
"KittenML/kitten-tts-mini-0.8",
|
||||
]
|
||||
rationale = (
|
||||
"Apple Silicon gets the full stack: OmniVoice for multilingual clone + "
|
||||
"WhisperX (faster-whisper weights) for cross-platform ASR + MLX-Whisper "
|
||||
"for the Apple-optimised speedup + Kokoro (mlx-audio) for fast local "
|
||||
"English + KittenTTS as a CPU-realtime backup."
|
||||
)
|
||||
else:
|
||||
recommended_ids = [
|
||||
"k2-fsa/OmniVoice",
|
||||
"Systran/faster-whisper-large-v3",
|
||||
"KittenML/kitten-tts-mini-0.8",
|
||||
]
|
||||
if has_cuda:
|
||||
recommended_ids.append("openai/whisper-large-v3")
|
||||
rationale = (
|
||||
"Cross-platform stack + pytorch-whisper as a CUDA-accelerated "
|
||||
"ASR fallback. MLX / mlx-audio are Apple-Silicon-only and don't "
|
||||
"apply here."
|
||||
)
|
||||
else:
|
||||
rationale = (
|
||||
"Cross-platform stack: OmniVoice (multilingual clone) + WhisperX "
|
||||
"(faster-whisper ASR) + KittenTTS (English turbo, CPU-realtime). "
|
||||
"Clean install, every model runs on CPU."
|
||||
)
|
||||
|
||||
known_by_id = {m["repo_id"]: m for m in KNOWN_MODELS}
|
||||
cached_ids: set[str] = set()
|
||||
try:
|
||||
from huggingface_hub import scan_cache_dir
|
||||
info = scan_cache_dir()
|
||||
cached_ids = {
|
||||
entry.repo_id for entry in info.repos if entry.size_on_disk > 0
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
entries = []
|
||||
for rid in recommended_ids:
|
||||
meta = known_by_id.get(rid, {})
|
||||
entries.append({
|
||||
"repo_id": rid,
|
||||
"label": meta.get("label", rid),
|
||||
"role": meta.get("role", ""),
|
||||
"size_gb": meta.get("size_gb", 0),
|
||||
"required": bool(meta.get("required", False)),
|
||||
"note": meta.get("note"),
|
||||
"installed": rid in cached_ids,
|
||||
})
|
||||
|
||||
to_download_gb = sum(e["size_gb"] for e in entries if not e["installed"])
|
||||
all_installed = all(e["installed"] for e in entries)
|
||||
|
||||
return {
|
||||
"device": {
|
||||
"os": sys.platform,
|
||||
"arch": _platform.machine(),
|
||||
"is_mac_arm": is_mac_arm,
|
||||
"is_mac_intel": is_mac_intel,
|
||||
"is_linux": is_linux,
|
||||
"is_windows": is_windows,
|
||||
"has_cuda": has_cuda,
|
||||
"label": device_label,
|
||||
},
|
||||
"rationale": rationale,
|
||||
"models": entries,
|
||||
"download_gb_remaining": round(to_download_gb, 2),
|
||||
"total_gb": round(sum(e["size_gb"] for e in entries), 2),
|
||||
"all_installed": all_installed,
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
"""First-run wizard endpoints — status, preflight, and warmup.
|
||||
|
||||
Extracted from the monolithic ``setup.py``.
|
||||
|
||||
- ``GET /setup/status`` — missing-model gate for boot screen
|
||||
- ``GET /setup/preflight`` — system health check (OS, RAM, GPU, ffmpeg…)
|
||||
- ``POST /setup/warmup`` — background model pre-load
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import platform as _platform
|
||||
import shutil as _shutil
|
||||
import sys
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.schemas import SetupStatusResponse, PreflightResponse
|
||||
from .models import REQUIRED_MODELS, hf_cache_dir, is_cached
|
||||
|
||||
logger = logging.getLogger("omnivoice.setup.wizard")
|
||||
router = APIRouter()
|
||||
|
||||
MIN_FREE_GB = 10
|
||||
|
||||
|
||||
def _disk_free_gb(path: str) -> float:
|
||||
try:
|
||||
return _shutil.disk_usage(path).free / (1024 ** 3)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
# ── Setup Status ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/setup/status", response_model=SetupStatusResponse)
|
||||
def setup_status():
|
||||
"""Snapshot the setup state so the client can pick its boot screen."""
|
||||
missing = [
|
||||
{"repo_id": rid, "label": label}
|
||||
for (rid, label) in REQUIRED_MODELS
|
||||
if not is_cached(rid)
|
||||
]
|
||||
cache = hf_cache_dir()
|
||||
free_gb = _disk_free_gb(cache)
|
||||
return {
|
||||
"models_ready": len(missing) == 0,
|
||||
"missing": missing,
|
||||
"hf_cache_dir": cache,
|
||||
"disk_free_gb": round(free_gb, 2),
|
||||
"min_free_gb": MIN_FREE_GB,
|
||||
"enough_disk": free_gb >= MIN_FREE_GB,
|
||||
}
|
||||
|
||||
|
||||
# ── Pre-flight System Check ───────────────────────────────────────────────
|
||||
|
||||
_MIN_NVIDIA_DRIVER = 555
|
||||
_RAM_FAIL_GB = 8
|
||||
_RAM_WARN_GB = 12
|
||||
|
||||
|
||||
def _run_cmd(args: list[str], timeout: float = 2.0) -> tuple[int, str]:
|
||||
"""Run a subprocess synchronously with a short timeout."""
|
||||
import subprocess
|
||||
try:
|
||||
out = subprocess.run(
|
||||
args, capture_output=True, text=True, timeout=timeout, check=False,
|
||||
)
|
||||
return out.returncode, out.stdout
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||||
return -1, ""
|
||||
|
||||
|
||||
def _detect_gpu() -> dict:
|
||||
"""Best-effort detection of GPU vendor + driver + compute backend."""
|
||||
info = {
|
||||
"vendor": "none", "driver": None, "device_name": None,
|
||||
"backend": "cpu", "available": False, "notes": [],
|
||||
}
|
||||
|
||||
# Apple Silicon → MPS
|
||||
if sys.platform == "darwin" and _platform.machine() == "arm64":
|
||||
info["vendor"] = "apple"
|
||||
info["backend"] = "mps"
|
||||
info["device_name"] = "Apple Silicon GPU (Metal)"
|
||||
try:
|
||||
import torch
|
||||
info["available"] = bool(torch.backends.mps.is_available())
|
||||
except Exception:
|
||||
info["available"] = False
|
||||
return info
|
||||
|
||||
# NVIDIA
|
||||
rc, out = _run_cmd([
|
||||
"nvidia-smi",
|
||||
"--query-gpu=driver_version,name",
|
||||
"--format=csv,noheader",
|
||||
])
|
||||
if rc == 0 and out.strip():
|
||||
line = out.strip().splitlines()[0]
|
||||
parts = [p.strip() for p in line.split(",")]
|
||||
driver = parts[0] if parts else None
|
||||
name = parts[1] if len(parts) > 1 else None
|
||||
info.update({"vendor": "nvidia", "driver": driver, "device_name": name})
|
||||
try:
|
||||
import torch
|
||||
info["available"] = bool(torch.cuda.is_available())
|
||||
info["backend"] = "cuda" if info["available"] else "cpu"
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
major = int((driver or "0").split(".")[0])
|
||||
if major < _MIN_NVIDIA_DRIVER:
|
||||
info["notes"].append(
|
||||
f"NVIDIA driver {driver} below {_MIN_NVIDIA_DRIVER} required "
|
||||
f"by the bundled CUDA 12.8 runtime — GPU will fail to launch "
|
||||
f"kernels. Update drivers before dubbing."
|
||||
)
|
||||
info["available"] = False
|
||||
except Exception:
|
||||
pass
|
||||
return info
|
||||
|
||||
# AMD
|
||||
rc, out = _run_cmd(["rocm-smi", "--showproductname"])
|
||||
if rc == 0 and out.strip():
|
||||
info["vendor"] = "amd"
|
||||
info["device_name"] = out.strip().splitlines()[0][:120]
|
||||
try:
|
||||
import torch
|
||||
has_hip = getattr(torch.version, "hip", None) is not None
|
||||
if has_hip and torch.cuda.is_available():
|
||||
info["backend"] = "rocm"
|
||||
info["available"] = True
|
||||
else:
|
||||
info["backend"] = "cpu"
|
||||
info["notes"].append(
|
||||
"AMD GPU detected but torch was installed with CUDA wheels. "
|
||||
"Re-run `uv sync --index-url https://download.pytorch.org/whl/rocm6.1` "
|
||||
"to enable ROCm acceleration."
|
||||
)
|
||||
except Exception:
|
||||
info["notes"].append("AMD GPU detected but torch not importable.")
|
||||
return info
|
||||
|
||||
# Fallback
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
info["vendor"] = "unknown"
|
||||
info["backend"] = "cuda"
|
||||
info["available"] = True
|
||||
info["notes"].append(
|
||||
"torch.cuda.is_available() is True but no nvidia-smi/rocm-smi "
|
||||
"found — running through WSL or virtual GPU?"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return info
|
||||
|
||||
|
||||
def _probe_network(host: str = "huggingface.co", timeout: float = 2.0) -> bool:
|
||||
"""Tiny TCP connect test."""
|
||||
import socket
|
||||
try:
|
||||
with socket.create_connection((host, 443), timeout=timeout):
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _ram_gb() -> float:
|
||||
try:
|
||||
import psutil
|
||||
return psutil.virtual_memory().total / (1024 ** 3)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
@router.get("/setup/preflight", response_model=PreflightResponse)
|
||||
def preflight():
|
||||
"""One-shot system health check for the wizard."""
|
||||
checks: list[dict] = []
|
||||
|
||||
# ── OS + arch
|
||||
arch = _platform.machine()
|
||||
os_ver = _platform.platform(terse=True)
|
||||
checks.append({
|
||||
"id": "os", "label": "Operating system", "status": "pass",
|
||||
"detail": f"{os_ver} ({arch})", "fix": None,
|
||||
})
|
||||
|
||||
# ── Python runtime
|
||||
checks.append({
|
||||
"id": "python", "label": "Python runtime", "status": "pass",
|
||||
"detail": f"Python {sys.version.split()[0]}", "fix": None,
|
||||
})
|
||||
|
||||
# ── RAM
|
||||
ram = _ram_gb()
|
||||
if ram == 0:
|
||||
ram_status, ram_detail, ram_fix = (
|
||||
"warn", "Could not detect system RAM.",
|
||||
"Install psutil in the backend environment or ignore this warning.",
|
||||
)
|
||||
elif ram < _RAM_FAIL_GB:
|
||||
ram_status, ram_detail, ram_fix = (
|
||||
"fail", f"{ram:.1f} GB total (need ≥ {_RAM_FAIL_GB} GB)",
|
||||
"The app will OOM on first dub. Close other apps or upgrade RAM.",
|
||||
)
|
||||
elif ram < _RAM_WARN_GB:
|
||||
ram_status, ram_detail, ram_fix = (
|
||||
"warn", f"{ram:.1f} GB total ({_RAM_WARN_GB}+ GB recommended)",
|
||||
"Long videos may hit swap. Keep other apps closed during dubbing.",
|
||||
)
|
||||
else:
|
||||
ram_status, ram_detail, ram_fix = ("pass", f"{ram:.1f} GB total", None)
|
||||
checks.append({
|
||||
"id": "ram", "label": "System RAM", "status": ram_status,
|
||||
"detail": ram_detail, "fix": ram_fix,
|
||||
})
|
||||
|
||||
# ── Disk free
|
||||
cache = hf_cache_dir()
|
||||
free = _disk_free_gb(cache)
|
||||
if free < MIN_FREE_GB:
|
||||
disk = {
|
||||
"status": "fail",
|
||||
"detail": f"{free:.1f} GB free at {cache} (need ≥ {MIN_FREE_GB} GB)",
|
||||
"fix": f"Free up disk space or set HF_HOME to a larger partition.",
|
||||
}
|
||||
else:
|
||||
disk = {"status": "pass", "detail": f"{free:.1f} GB free at {cache}", "fix": None}
|
||||
checks.append({"id": "disk", **{"label": "Disk space", **disk}})
|
||||
|
||||
# ── HF cache writable
|
||||
try:
|
||||
os.makedirs(cache, exist_ok=True)
|
||||
writable = os.access(cache, os.W_OK)
|
||||
except Exception:
|
||||
writable = False
|
||||
checks.append({
|
||||
"id": "hf_cache_writable", "label": "HuggingFace cache writable",
|
||||
"status": "pass" if writable else "fail",
|
||||
"detail": cache,
|
||||
"fix": None if writable else
|
||||
f"Fix write permissions on {cache} or point HF_HOME elsewhere.",
|
||||
})
|
||||
|
||||
# ── FFmpeg
|
||||
ffmpeg_path = None
|
||||
try:
|
||||
from services.ffmpeg_utils import find_ffmpeg
|
||||
ffmpeg_path = find_ffmpeg()
|
||||
except Exception as e:
|
||||
checks.append({
|
||||
"id": "ffmpeg", "label": "FFmpeg", "status": "fail",
|
||||
"detail": str(e)[:200],
|
||||
"fix": "Install ffmpeg via your package manager "
|
||||
"(brew install ffmpeg / apt install ffmpeg / choco install ffmpeg).",
|
||||
})
|
||||
else:
|
||||
checks.append({
|
||||
"id": "ffmpeg", "label": "FFmpeg", "status": "pass",
|
||||
"detail": ffmpeg_path, "fix": None,
|
||||
})
|
||||
|
||||
# ── FFprobe
|
||||
ffprobe_path = None
|
||||
if ffmpeg_path:
|
||||
candidate = ffmpeg_path.replace("ffmpeg", "ffprobe")
|
||||
if os.path.exists(candidate):
|
||||
ffprobe_path = candidate
|
||||
else:
|
||||
system_probe = _shutil.which("ffprobe")
|
||||
if system_probe:
|
||||
ffprobe_path = system_probe
|
||||
if ffprobe_path:
|
||||
checks.append({
|
||||
"id": "ffprobe", "label": "FFprobe", "status": "pass",
|
||||
"detail": ffprobe_path, "fix": None,
|
||||
})
|
||||
else:
|
||||
checks.append({
|
||||
"id": "ffprobe", "label": "FFprobe", "status": "warn",
|
||||
"detail": "Not bundled alongside ffmpeg.",
|
||||
"fix": "File-probe endpoint (/tools/probe) will 501. "
|
||||
"Install system ffmpeg (includes ffprobe) to enable it.",
|
||||
})
|
||||
|
||||
# ── yt-dlp
|
||||
yt_dlp_path = _shutil.which("yt-dlp")
|
||||
if yt_dlp_path:
|
||||
rc_ytv, yt_ver = _run_cmd([yt_dlp_path, "--version"], timeout=3.0)
|
||||
yt_version = yt_ver.strip() if rc_ytv == 0 else "unknown"
|
||||
checks.append({
|
||||
"id": "yt-dlp", "label": "yt-dlp", "status": "pass",
|
||||
"detail": f"{yt_dlp_path} (v{yt_version})", "fix": None,
|
||||
})
|
||||
else:
|
||||
checks.append({
|
||||
"id": "yt-dlp", "label": "yt-dlp", "status": "warn",
|
||||
"detail": "Not found in system PATH.",
|
||||
"fix": "YouTube clip downloads in Voice Gallery will fail. Download the standalone binary from https://github.com/yt-dlp/yt-dlp/releases and place it in your PATH.",
|
||||
})
|
||||
|
||||
# ── GPU
|
||||
gpu = _detect_gpu()
|
||||
if gpu["vendor"] == "apple" and gpu["available"]:
|
||||
gpu_status, gpu_fix = "pass", None
|
||||
gpu_detail = f"{gpu['device_name']} — Metal (MPS) ready"
|
||||
elif gpu["vendor"] == "nvidia" and gpu["available"]:
|
||||
gpu_status, gpu_fix = "pass", None
|
||||
gpu_detail = f"{gpu['device_name']} (driver {gpu['driver']}) — CUDA ready"
|
||||
elif gpu["vendor"] == "nvidia" and not gpu["available"]:
|
||||
gpu_status = "fail"
|
||||
gpu_detail = (
|
||||
f"{gpu['device_name']} found but CUDA not usable "
|
||||
f"(driver {gpu['driver']}). " + " ".join(gpu["notes"])
|
||||
)
|
||||
gpu_fix = (
|
||||
f"Update NVIDIA drivers to ≥ R{_MIN_NVIDIA_DRIVER} "
|
||||
"(https://www.nvidia.com/Download/index.aspx). Or run CPU-only "
|
||||
"by continuing past this step — dubbing will be ~10× slower."
|
||||
)
|
||||
elif gpu["vendor"] == "amd":
|
||||
gpu_status = "warn"
|
||||
gpu_detail = (
|
||||
f"{gpu['device_name']} — ROCm "
|
||||
+ ("ready" if gpu["available"] else "not configured")
|
||||
)
|
||||
gpu_fix = (
|
||||
None if gpu["available"] else
|
||||
"AMD support is experimental. Re-run `uv sync --index-url "
|
||||
"https://download.pytorch.org/whl/rocm6.1` to enable. App works "
|
||||
"on CPU otherwise (slower)."
|
||||
)
|
||||
else:
|
||||
gpu_status = "warn"
|
||||
gpu_detail = "No compatible GPU detected — running CPU-only."
|
||||
gpu_fix = (
|
||||
"Dubbing will work but ~10× slower than GPU. If you have an "
|
||||
"NVIDIA/AMD card, check drivers are installed."
|
||||
)
|
||||
checks.append({
|
||||
"id": "gpu", "label": "GPU acceleration",
|
||||
"status": gpu_status, "detail": gpu_detail, "fix": gpu_fix,
|
||||
})
|
||||
|
||||
# ── Network
|
||||
net_ok = _probe_network()
|
||||
checks.append({
|
||||
"id": "network", "label": "Network (huggingface.co)",
|
||||
"status": "pass" if net_ok else "fail",
|
||||
"detail": "Reachable" if net_ok else "Unreachable on port 443",
|
||||
"fix": None if net_ok else
|
||||
"Check internet connection, VPN, or corporate firewall "
|
||||
"whitelist for huggingface.co.",
|
||||
})
|
||||
|
||||
# Aggregate
|
||||
any_fail = any(c["status"] == "fail" for c in checks)
|
||||
any_warn = any(c["status"] == "warn" for c in checks)
|
||||
|
||||
return {
|
||||
"ok": not any_fail,
|
||||
"has_warnings": any_warn,
|
||||
"checks": checks,
|
||||
"device": {
|
||||
"os": sys.platform,
|
||||
"arch": arch,
|
||||
"gpu_vendor": gpu["vendor"],
|
||||
"gpu_backend": gpu["backend"],
|
||||
"gpu_available": gpu["available"],
|
||||
"gpu_driver": gpu["driver"],
|
||||
"gpu_device_name": gpu["device_name"],
|
||||
"ram_gb": round(ram, 1),
|
||||
"disk_free_gb": round(free, 1),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Warmup ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/setup/warmup")
|
||||
async def setup_warmup():
|
||||
"""Trigger a model load in the background so the first dub doesn't pay
|
||||
the cold-start tax."""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
async def _do_warmup():
|
||||
try:
|
||||
from services.model_manager import get_model
|
||||
await get_model()
|
||||
except Exception as e:
|
||||
logger.warning("setup/warmup: model load failed: %s", e)
|
||||
|
||||
loop.create_task(_do_warmup())
|
||||
return {"status": "warmup_started"}
|
||||
@@ -4,8 +4,9 @@ import uuid
|
||||
import psutil
|
||||
import asyncio
|
||||
import logging
|
||||
from fastapi import APIRouter, File, UploadFile, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi import APIRouter, File, UploadFile, HTTPException, Query
|
||||
from api.schemas import SysinfoResponse, SystemInfoResponse, ModelStatusResponse, LogsResponse, FlushMemoryResponse
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
import torch
|
||||
import shutil
|
||||
|
||||
@@ -22,28 +23,49 @@ _is_cuda = torch.cuda.is_available()
|
||||
# Prime psutil's internal CPU counter so the first non-blocking call returns useful data
|
||||
psutil.cpu_percent(interval=None)
|
||||
|
||||
@router.get("/model/status")
|
||||
@router.get("/model/status", response_model=ModelStatusResponse)
|
||||
def model_status():
|
||||
"""Report model loading state for frontend warm-up indicators."""
|
||||
return get_model_status()
|
||||
|
||||
|
||||
@router.get("/system/info")
|
||||
@router.get("/system/info", response_model=SystemInfoResponse)
|
||||
def system_info():
|
||||
"""Settings page system info — model, tokens, data dir, timeout."""
|
||||
return {
|
||||
"data_dir": DATA_DIR,
|
||||
"outputs_dir": OUTPUTS_DIR,
|
||||
"crash_log_path": CRASH_LOG_PATH,
|
||||
"idle_timeout_seconds": IDLE_TIMEOUT_SECONDS,
|
||||
"model_checkpoint": os.environ.get("OMNIVOICE_MODEL", "k2-fsa/OmniVoice"),
|
||||
"asr_model": os.environ.get("ASR_MODEL", "Systran/faster-whisper-large-v3"),
|
||||
"translate_provider": os.environ.get("TRANSLATE_PROVIDER", "google"),
|
||||
"has_hf_token": bool(os.environ.get("HF_TOKEN")),
|
||||
"device": get_best_device(),
|
||||
"python": sys.version.split()[0],
|
||||
"platform": sys.platform,
|
||||
}
|
||||
"""Settings page system info — model, tokens, data dir, timeout.
|
||||
|
||||
This endpoint MUST never throw — it's called on every Settings page load
|
||||
and a 500 here blocks the entire UI from rendering system details.
|
||||
"""
|
||||
try:
|
||||
return {
|
||||
"data_dir": DATA_DIR,
|
||||
"outputs_dir": OUTPUTS_DIR,
|
||||
"crash_log_path": CRASH_LOG_PATH,
|
||||
"idle_timeout_seconds": IDLE_TIMEOUT_SECONDS,
|
||||
"model_checkpoint": os.environ.get("OMNIVOICE_MODEL", "k2-fsa/OmniVoice"),
|
||||
"asr_model": os.environ.get("ASR_MODEL", "Systran/faster-whisper-large-v3"),
|
||||
"translate_provider": os.environ.get("TRANSLATE_PROVIDER", "google"),
|
||||
"has_hf_token": bool(os.environ.get("HF_TOKEN")),
|
||||
"device": get_best_device(),
|
||||
"python": sys.version.split()[0],
|
||||
"platform": sys.platform,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception("system_info failed — returning safe defaults")
|
||||
return {
|
||||
"data_dir": DATA_DIR,
|
||||
"outputs_dir": OUTPUTS_DIR,
|
||||
"crash_log_path": str(CRASH_LOG_PATH),
|
||||
"idle_timeout_seconds": IDLE_TIMEOUT_SECONDS,
|
||||
"model_checkpoint": "unknown",
|
||||
"asr_model": "unknown",
|
||||
"translate_provider": "unknown",
|
||||
"has_hf_token": False,
|
||||
"device": "cpu",
|
||||
"python": sys.version.split()[0],
|
||||
"platform": sys.platform,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
def _tail_file(path: str, tail: int):
|
||||
@@ -85,7 +107,7 @@ def _tauri_log_candidates():
|
||||
|
||||
|
||||
@router.get("/system/logs")
|
||||
def system_logs(tail: int = 200):
|
||||
async def system_logs(tail: int = 200):
|
||||
"""Tail the rolling runtime log — everything Python logged since last rotation.
|
||||
|
||||
Back-stop: if the rolling log doesn't exist yet (fresh install, disk error),
|
||||
@@ -100,12 +122,9 @@ def system_logs(tail: int = 200):
|
||||
if not os.path.exists(path):
|
||||
return {"lines": [], "path": LOG_PATH, "exists": False}
|
||||
try:
|
||||
lines, total = _tail_file(path, tail)
|
||||
lines, total = await asyncio.to_thread(_tail_file, path, tail)
|
||||
return {"lines": lines, "path": path, "exists": True, "total_lines": total}
|
||||
except Exception as e:
|
||||
# The log file exists but we can't read it — usually a permission
|
||||
# issue or the file got truncated mid-read. Point the user at the
|
||||
# path so they can inspect or delete manually.
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Could not read log at {path}: {e}. Check file permissions or delete it manually.",
|
||||
@@ -113,7 +132,7 @@ def system_logs(tail: int = 200):
|
||||
|
||||
|
||||
@router.get("/system/logs/tauri")
|
||||
def system_logs_tauri(tail: int = 200):
|
||||
async def system_logs_tauri(tail: int = 200):
|
||||
"""Tail the Tauri plugin log (or backend stdout redirect, whichever exists)."""
|
||||
try:
|
||||
tail = max(10, min(2000, int(tail)))
|
||||
@@ -123,22 +142,87 @@ def system_logs_tauri(tail: int = 200):
|
||||
for p in candidates:
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
lines, total = _tail_file(p, tail)
|
||||
lines, total = await asyncio.to_thread(_tail_file, p, tail)
|
||||
return {"lines": lines, "path": p, "exists": True, "total_lines": total}
|
||||
except Exception as e:
|
||||
return {"lines": [], "path": p, "exists": True, "error": str(e)}
|
||||
return {"lines": [], "path": None, "exists": False, "candidates": candidates}
|
||||
|
||||
|
||||
@router.get("/system/logs/stream")
|
||||
async def stream_logs(
|
||||
source: str = Query("backend", description="'backend' or 'tauri'"),
|
||||
interval: float = Query(1.0, ge=0.3, le=10.0, description="Poll interval in seconds"),
|
||||
):
|
||||
"""Server-Sent Events stream of new log lines.
|
||||
|
||||
The client opens an EventSource connection and receives new lines as they
|
||||
are appended to the log file. This replaces the polling pattern used by
|
||||
the LogsFooter component.
|
||||
|
||||
Usage (frontend)::
|
||||
|
||||
const es = new EventSource('/system/logs/stream?source=backend');
|
||||
es.onmessage = (e) => { const lines = JSON.parse(e.data); ... };
|
||||
"""
|
||||
if source == "tauri":
|
||||
candidates = _tauri_log_candidates()
|
||||
path = next((p for p in candidates if os.path.exists(p)), None)
|
||||
else:
|
||||
path = LOG_PATH if os.path.exists(LOG_PATH) else CRASH_LOG_PATH
|
||||
|
||||
if not path or not os.path.exists(path):
|
||||
raise HTTPException(status_code=404, detail=f"Log file not found for source={source}")
|
||||
|
||||
async def _generate():
|
||||
"""Yield SSE events whenever new lines appear in the log file."""
|
||||
last_pos = 0
|
||||
try:
|
||||
last_pos = os.path.getsize(path)
|
||||
except Exception:
|
||||
pass
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
if size < last_pos:
|
||||
# File was truncated (log rotation or clear) — reset
|
||||
last_pos = 0
|
||||
if size == last_pos:
|
||||
continue
|
||||
new_lines = await asyncio.to_thread(_read_from_pos, path, last_pos)
|
||||
last_pos = size
|
||||
if new_lines:
|
||||
import json
|
||||
yield f"data: {json.dumps(new_lines)}\n\n"
|
||||
except Exception:
|
||||
break
|
||||
|
||||
return StreamingResponse(
|
||||
_generate(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _read_from_pos(path: str, pos: int) -> list[str]:
|
||||
"""Read all lines from `pos` to EOF (runs in threadpool)."""
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
f.seek(pos)
|
||||
return f.readlines()
|
||||
|
||||
|
||||
@router.post("/system/logs/clear")
|
||||
def clear_system_logs():
|
||||
async def clear_system_logs():
|
||||
"""Truncate the rolling runtime log and the crash log (what the Backend tab reads)."""
|
||||
cleared_any = False
|
||||
for p in (LOG_PATH, CRASH_LOG_PATH):
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
with open(p, "w") as f:
|
||||
f.truncate(0)
|
||||
await asyncio.to_thread(_truncate_file, p)
|
||||
cleared_any = True
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
@@ -148,21 +232,26 @@ def clear_system_logs():
|
||||
return {"cleared": cleared_any}
|
||||
|
||||
|
||||
def _truncate_file(path: str):
|
||||
"""Truncate a file to zero length (runs in threadpool)."""
|
||||
with open(path, "w") as f:
|
||||
f.truncate(0)
|
||||
|
||||
|
||||
@router.post("/system/logs/tauri/clear")
|
||||
def clear_tauri_logs():
|
||||
async def clear_tauri_logs():
|
||||
"""Truncate whichever Tauri-side log files we know about. OS-level rotation may recreate them."""
|
||||
cleared = []
|
||||
for p in _tauri_log_candidates():
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
with open(p, "w") as f:
|
||||
f.truncate(0)
|
||||
await asyncio.to_thread(_truncate_file, p)
|
||||
cleared.append(p)
|
||||
except Exception:
|
||||
pass
|
||||
return {"cleared": cleared}
|
||||
|
||||
@router.get("/sysinfo")
|
||||
@router.get("/sysinfo", response_model=SysinfoResponse)
|
||||
def get_sys_info():
|
||||
vram = 0.0
|
||||
gpu_active = False
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Pydantic v2 schemas for request/response validation.
|
||||
|
||||
Shared across routers — import from here rather than defining inline.
|
||||
Using ``model_config = ConfigDict(...)`` for Pydantic v2 compat.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# ── System ────────────────────────────────────────────────────────────────
|
||||
|
||||
class SysinfoResponse(BaseModel):
|
||||
"""GET /sysinfo"""
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
cpu: float = Field(description="CPU usage percentage (0–100)")
|
||||
ram: float = Field(description="Used RAM in GiB")
|
||||
total_ram: float = Field(description="Total RAM in GiB")
|
||||
vram: float = Field(0.0, description="Used VRAM in GiB")
|
||||
gpu_active: bool = Field(False, description="Whether a GPU is actively used")
|
||||
|
||||
|
||||
class SystemInfoResponse(BaseModel):
|
||||
"""GET /system/info"""
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
data_dir: str
|
||||
outputs_dir: str
|
||||
crash_log_path: str
|
||||
idle_timeout_seconds: int
|
||||
model_checkpoint: str = "unknown"
|
||||
asr_model: str = "unknown"
|
||||
translate_provider: str = "unknown"
|
||||
has_hf_token: bool = False
|
||||
device: str = "cpu"
|
||||
python: str = ""
|
||||
platform: str = ""
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class ModelStatusResponse(BaseModel):
|
||||
"""GET /model/status"""
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
status: str = Field(description="idle | loading | ready")
|
||||
checkpoint: str | None = None
|
||||
loaded_at: str | None = None
|
||||
|
||||
|
||||
class LogsResponse(BaseModel):
|
||||
"""GET /system/logs"""
|
||||
lines: list[str] = Field(default_factory=list)
|
||||
path: str = ""
|
||||
exists: bool = False
|
||||
total_lines: int = 0
|
||||
error: str | None = None
|
||||
candidates: list[str] | None = None
|
||||
|
||||
|
||||
class FlushMemoryResponse(BaseModel):
|
||||
"""POST /system/flush-memory"""
|
||||
flushed: bool = True
|
||||
unloaded_model: bool = False
|
||||
ram_after: float = 0.0
|
||||
vram_after: float = 0.0
|
||||
|
||||
|
||||
# ── Setup ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class MissingModel(BaseModel):
|
||||
repo_id: str
|
||||
label: str
|
||||
|
||||
|
||||
class SetupStatusResponse(BaseModel):
|
||||
"""GET /setup/status"""
|
||||
models_ready: bool
|
||||
missing: list[MissingModel] = Field(default_factory=list)
|
||||
hf_cache_dir: str
|
||||
disk_free_gb: float
|
||||
min_free_gb: int = 10
|
||||
enough_disk: bool = True
|
||||
|
||||
|
||||
class PreflightCheck(BaseModel):
|
||||
"""One check in the preflight report."""
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
id: str
|
||||
label: str
|
||||
status: str = Field(description="pass | warn | fail")
|
||||
detail: str = ""
|
||||
fix: str | None = None
|
||||
|
||||
|
||||
class DeviceInfo(BaseModel):
|
||||
"""GPU/system device info from preflight."""
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
os: str
|
||||
arch: str
|
||||
gpu_vendor: str = "none"
|
||||
gpu_backend: str = "cpu"
|
||||
gpu_available: bool = False
|
||||
gpu_driver: str | None = None
|
||||
gpu_device_name: str | None = None
|
||||
ram_gb: float = 0.0
|
||||
disk_free_gb: float = 0.0
|
||||
|
||||
|
||||
class PreflightResponse(BaseModel):
|
||||
"""GET /setup/preflight"""
|
||||
ok: bool
|
||||
has_warnings: bool = False
|
||||
checks: list[PreflightCheck] = Field(default_factory=list)
|
||||
device: DeviceInfo
|
||||
|
||||
|
||||
class InstallModelRequest(BaseModel):
|
||||
"""POST /models/install"""
|
||||
repo_id: str
|
||||
|
||||
|
||||
class DeleteModelResponse(BaseModel):
|
||||
"""DELETE /models/{repo_id}"""
|
||||
deleted: bool = True
|
||||
repo_id: str
|
||||
freed_bytes: int = 0
|
||||
|
||||
|
||||
# ── Models list ───────────────────────────────────────────────────────────
|
||||
|
||||
class ModelEntry(BaseModel):
|
||||
"""One model in the GET /models response."""
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
repo_id: str
|
||||
label: str
|
||||
role: str
|
||||
size: str = ""
|
||||
required: bool = False
|
||||
installed: bool = False
|
||||
supported: bool = True
|
||||
size_on_disk: int | None = None
|
||||
nb_files: int | None = None
|
||||
@@ -0,0 +1,123 @@
|
||||
# ── OmniVoice Studio — Model Catalog ─────────────────────────────────────
|
||||
#
|
||||
# This file is the source of truth for all known HuggingFace models.
|
||||
# The backend loads it at startup via `load_model_catalog()`.
|
||||
#
|
||||
# To add a model: append an entry with the fields below.
|
||||
# To remove: delete the entry. The UI will stop showing it immediately.
|
||||
#
|
||||
# Fields:
|
||||
# repo_id (required) — HuggingFace repository ID
|
||||
# label (required) — Human-readable display name
|
||||
# role (required) — TTS | ASR | Diarisation
|
||||
# size_gb (required) — Approximate download size in GiB
|
||||
# required (optional) — true if the app needs this model to function
|
||||
# platforms (optional) — restrict to specific OS+arch tags (e.g. darwin-arm64, cuda)
|
||||
# note (optional) — shown in the UI as a tooltip/footnote
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
models:
|
||||
# ── Required ──────────────────────────────────────────────────────────
|
||||
|
||||
- repo_id: "k2-fsa/OmniVoice"
|
||||
label: "OmniVoice TTS (600+ languages, zero-shot)"
|
||||
role: TTS
|
||||
size_gb: 2.4
|
||||
required: true
|
||||
|
||||
- repo_id: "Systran/faster-whisper-large-v3"
|
||||
label: "Whisper large-v3 (faster-whisper — default, cross-platform)"
|
||||
role: ASR
|
||||
size_gb: 2.9
|
||||
required: true
|
||||
|
||||
# ── Optional ASR ──────────────────────────────────────────────────────
|
||||
|
||||
- repo_id: "mlx-community/whisper-large-v3-mlx"
|
||||
label: "Whisper large-v3 (MLX — optional mac-ARM speedup)"
|
||||
role: ASR
|
||||
size_gb: 3.0
|
||||
platforms: [darwin-arm64]
|
||||
|
||||
- repo_id: "openai/whisper-large-v3"
|
||||
label: "Whisper large-v3 (PyTorch — last-resort fallback)"
|
||||
role: ASR
|
||||
size_gb: 3.1
|
||||
platforms: [cuda]
|
||||
|
||||
- repo_id: "mlx-community/whisper-tiny-mlx"
|
||||
label: "Whisper tiny (MLX ASR — fast fallback)"
|
||||
role: ASR
|
||||
size_gb: 0.08
|
||||
platforms: [darwin-arm64]
|
||||
|
||||
# ── Diarisation ───────────────────────────────────────────────────────
|
||||
|
||||
- repo_id: "pyannote/speaker-diarization-3.1"
|
||||
label: "pyannote speaker diarisation (multi-speaker videos)"
|
||||
role: Diarisation
|
||||
size_gb: 0.8
|
||||
note: "Needs an HF_TOKEN with license accepted."
|
||||
|
||||
# ── Optional TTS ──────────────────────────────────────────────────────
|
||||
|
||||
- repo_id: "OpenMOSS-Team/MOSS-TTS-Nano-100M"
|
||||
label: "MOSS-TTS-Nano 100M (20 langs, CPU-realtime)"
|
||||
role: TTS
|
||||
size_gb: 0.4
|
||||
|
||||
- repo_id: "KittenML/kitten-tts-mini-0.8"
|
||||
label: "KittenTTS (English, 8 preset voices, CPU realtime)"
|
||||
role: TTS
|
||||
size_gb: 0.08
|
||||
|
||||
# ── mlx-audio engines (Apple Silicon only) ────────────────────────────
|
||||
|
||||
- repo_id: "mlx-community/Kokoro-82M-bf16"
|
||||
label: "Kokoro 82M (8 langs, small, mlx-audio default)"
|
||||
role: TTS
|
||||
size_gb: 0.15
|
||||
note: "Apple Silicon only — via mlx-audio backend."
|
||||
platforms: [darwin-arm64]
|
||||
|
||||
- repo_id: "mlx-community/csm-1b-8bit"
|
||||
label: "CSM 1B (voice cloning, mlx-audio)"
|
||||
role: TTS
|
||||
size_gb: 1.1
|
||||
note: "Apple Silicon only — via mlx-audio backend."
|
||||
platforms: [darwin-arm64]
|
||||
|
||||
- repo_id: "mlx-community/Qwen3-TTS-12Hz-1.7B-VoiceDesign-4bit"
|
||||
label: "Qwen3-TTS 1.7B 4bit (voice design, mlx-audio)"
|
||||
role: TTS
|
||||
size_gb: 1.4
|
||||
note: "Apple Silicon only — via mlx-audio backend."
|
||||
platforms: [darwin-arm64]
|
||||
|
||||
- repo_id: "mlx-community/Dia-1.6B"
|
||||
label: "Dia 1.6B (expressive, mlx-audio)"
|
||||
role: TTS
|
||||
size_gb: 3.2
|
||||
note: "Apple Silicon only — via mlx-audio backend."
|
||||
platforms: [darwin-arm64]
|
||||
|
||||
- repo_id: "mlx-community/Llama-OuteTTS-1.0-1B-4bit"
|
||||
label: "Llama-OuteTTS 1.0 1B 4bit (voice clone, mlx-audio)"
|
||||
role: TTS
|
||||
size_gb: 0.8
|
||||
note: "Apple Silicon only — via mlx-audio backend."
|
||||
platforms: [darwin-arm64]
|
||||
|
||||
- repo_id: "mlx-community/Chatterbox-TTS-4bit"
|
||||
label: "Chatterbox TTS 4bit (mlx-audio)"
|
||||
role: TTS
|
||||
size_gb: 0.5
|
||||
note: "Apple Silicon only — via mlx-audio backend."
|
||||
platforms: [darwin-arm64]
|
||||
|
||||
- repo_id: "mlx-community/MeloTTS-English-v3-MLX"
|
||||
label: "MeloTTS English v3 (mlx-audio)"
|
||||
role: TTS
|
||||
size_gb: 0.2
|
||||
note: "Apple Silicon only — via mlx-audio backend."
|
||||
platforms: [darwin-arm64]
|
||||
@@ -37,7 +37,11 @@ def _load() -> dict:
|
||||
|
||||
def _save(data: dict) -> None:
|
||||
# Atomic write — no half-written JSON if the process dies mid-flush.
|
||||
fd, tmp = tempfile.mkstemp(prefix=".prefs.", suffix=".tmp", dir=DATA_DIR)
|
||||
# Derive temp-dir from _PREFS_PATH (not DATA_DIR) so os.replace() always
|
||||
# operates within the same filesystem — important when tests redirect the path.
|
||||
target_dir = os.path.dirname(_PREFS_PATH) or DATA_DIR
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(prefix=".prefs.", suffix=".tmp", dir=target_dir)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
@@ -13,6 +13,38 @@ try:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# ── cuDNN 8 library preload ─────────────────────────────────────────────
|
||||
# CTranslate2 (used by faster-whisper / WhisperX) requires cuDNN 8, but
|
||||
# PyTorch 2.8+ pulls cuDNN 9. scripts/setup_cudnn.py installs cuDNN 8
|
||||
# side-by-side into cudnn8_compat/ (survives `uv sync`). We preload all
|
||||
# cuDNN 8 libs via ctypes so CTranslate2's dlopen/LoadLibrary finds them.
|
||||
if sys.platform != "darwin": # macOS has no CUDA
|
||||
_project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
_pyver = f"python{sys.version_info.major}.{sys.version_info.minor}"
|
||||
if sys.platform == "win32":
|
||||
_cudnn8_lib = os.path.join(
|
||||
_project_root, ".venv", "Lib", "site-packages",
|
||||
"cudnn8_compat", "nvidia", "cudnn", "bin",
|
||||
)
|
||||
_cudnn8_glob = "cudnn*64_8.dll"
|
||||
else:
|
||||
_cudnn8_lib = os.path.join(
|
||||
_project_root, ".venv", "lib", _pyver, "site-packages",
|
||||
"cudnn8_compat", "nvidia", "cudnn", "lib",
|
||||
)
|
||||
_cudnn8_glob = "libcudnn*.so.8"
|
||||
if os.path.isdir(_cudnn8_lib):
|
||||
try:
|
||||
import ctypes, glob
|
||||
_mode = 0 if sys.platform == "win32" else ctypes.RTLD_GLOBAL
|
||||
for _so in sorted(glob.glob(os.path.join(_cudnn8_lib, _cudnn8_glob))):
|
||||
try:
|
||||
ctypes.CDLL(_so, mode=_mode)
|
||||
except OSError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Route HF/Torch caches to a single external directory when requested.
|
||||
_cache_dir = os.environ.get("OMNIVOICE_CACHE_DIR")
|
||||
if _cache_dir:
|
||||
@@ -152,6 +184,7 @@ from api.routers import (
|
||||
tools,
|
||||
setup,
|
||||
gallery,
|
||||
batch,
|
||||
)
|
||||
from utils import hf_progress
|
||||
|
||||
@@ -179,8 +212,38 @@ async def lifespan(app: FastAPI):
|
||||
idle_task = asyncio.create_task(idle_worker())
|
||||
worker_task = asyncio.create_task(task_manager.worker())
|
||||
yield
|
||||
# ── Graceful shutdown (SIGTERM from Tauri, Ctrl+C, etc.) ────────────
|
||||
logger.info("Shutdown: cleaning up…")
|
||||
idle_task.cancel()
|
||||
worker_task.cancel()
|
||||
# Wait for tasks to finish their current iteration
|
||||
for t in (idle_task, worker_task):
|
||||
try:
|
||||
await asyncio.wait_for(t, timeout=3.0)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||
pass
|
||||
# Unload the model and free GPU memory
|
||||
try:
|
||||
import services.model_manager as mm
|
||||
if mm.model is not None:
|
||||
mm.model = None
|
||||
logger.info("Shutdown: model unloaded.")
|
||||
mm.free_vram()
|
||||
except Exception:
|
||||
pass
|
||||
# Run GC to release any remaining references
|
||||
try:
|
||||
import gc
|
||||
gc.collect()
|
||||
except Exception:
|
||||
pass
|
||||
# Close shared httpx connection pool
|
||||
try:
|
||||
from api.http_client import close_http_client
|
||||
await close_http_client()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("Shutdown: done.")
|
||||
|
||||
|
||||
app = FastAPI(title="OmniVoice Studio API", version="0.4.0", lifespan=lifespan)
|
||||
@@ -250,6 +313,7 @@ app.include_router(engines.router)
|
||||
app.include_router(tools.router)
|
||||
app.include_router(setup.router)
|
||||
app.include_router(gallery.router)
|
||||
app.include_router(batch.router)
|
||||
|
||||
frontend_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist")
|
||||
if os.path.exists(frontend_path):
|
||||
|
||||
@@ -91,6 +91,52 @@ def free_vram():
|
||||
elif torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
|
||||
def offload_tts_for_asr():
|
||||
"""Move TTS model to CPU to free VRAM for ASR (WhisperX large-v3).
|
||||
|
||||
On a 7-8 GB laptop GPU the TTS model (~2.4 GB) and WhisperX large-v3
|
||||
(~3 GB) plus the VAD model can't coexist. Offloading the TTS model to
|
||||
CPU before transcription prevents CUDA OOM, then restore_tts_after_asr()
|
||||
moves it back.
|
||||
"""
|
||||
global model
|
||||
if model is None:
|
||||
return
|
||||
if not torch.cuda.is_available():
|
||||
return # Only needed on CUDA (limited VRAM)
|
||||
try:
|
||||
# Check if there's enough free VRAM to skip offloading (~4 GB needed for ASR)
|
||||
free_mem = torch.cuda.mem_get_info()[0]
|
||||
if free_mem > 4 * 1024 ** 3: # > 4 GB free → probably fine
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
logger.info("Offloading TTS model to CPU to free VRAM for ASR...")
|
||||
model.to("cpu")
|
||||
free_vram()
|
||||
logger.info("TTS model offloaded. VRAM freed for ASR.")
|
||||
except Exception as e:
|
||||
logger.warning("TTS offload failed: %s", e)
|
||||
|
||||
|
||||
def restore_tts_after_asr():
|
||||
"""Move TTS model back to CUDA after ASR completes."""
|
||||
global model
|
||||
if model is None:
|
||||
return
|
||||
if not torch.cuda.is_available():
|
||||
return
|
||||
try:
|
||||
device = get_best_device()
|
||||
if device == "cuda":
|
||||
logger.info("Restoring TTS model to CUDA...")
|
||||
model.to("cuda")
|
||||
free_vram()
|
||||
except Exception as e:
|
||||
logger.warning("TTS restore to CUDA failed: %s", e)
|
||||
|
||||
_diar_pipeline = None
|
||||
|
||||
def get_diarization_pipeline():
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"name": "omnivoice-studio-monorepo",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1",
|
||||
"kill-port-process": "^4.0.2",
|
||||
"playwright": "^1.59.1",
|
||||
"turbo": "^2.9.6",
|
||||
"typescript": "^6.0.3",
|
||||
"wait-on": "^9.0.5",
|
||||
@@ -13,20 +15,35 @@
|
||||
},
|
||||
"frontend": {
|
||||
"name": "omnivoice-studio",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.3",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/source-serif-4": "^5.2.9",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "4",
|
||||
"@tanstack/react-query": "^5.100.4",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-window": "^2.2.7",
|
||||
"tailwindcss": "4",
|
||||
"wavesurfer.js": "^7.12.6",
|
||||
"zustand": "^5.0.12",
|
||||
},
|
||||
@@ -101,6 +118,14 @@
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
|
||||
|
||||
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
|
||||
|
||||
"@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="],
|
||||
|
||||
"@fontsource-variable/source-serif-4": ["@fontsource-variable/source-serif-4@5.2.9", "", {}, "sha512-PPcxjLFk/fS0WHg79pDM2YNvz61kC+oYZ5cWZZyCS0DHpJncmuYOuiZAsvj4tDxlWPBEvxxcRLQQNmSaRbPkqw=="],
|
||||
@@ -141,6 +166,82 @@
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.126.0", "", {}, "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||
|
||||
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
||||
|
||||
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
||||
|
||||
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
||||
|
||||
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
|
||||
|
||||
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
|
||||
|
||||
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
|
||||
|
||||
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
|
||||
|
||||
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
|
||||
|
||||
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
|
||||
|
||||
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
|
||||
|
||||
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
|
||||
|
||||
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
|
||||
|
||||
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
||||
|
||||
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
|
||||
|
||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.8", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA=="],
|
||||
|
||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
||||
|
||||
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
|
||||
|
||||
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
|
||||
|
||||
"@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="],
|
||||
|
||||
"@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q=="],
|
||||
|
||||
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
|
||||
|
||||
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
|
||||
|
||||
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
|
||||
|
||||
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
|
||||
|
||||
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
|
||||
|
||||
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
||||
|
||||
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
|
||||
|
||||
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
|
||||
|
||||
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
|
||||
|
||||
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
|
||||
|
||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.16", "", { "os": "android", "cpu": "arm64" }, "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ=="],
|
||||
@@ -173,8 +274,54 @@
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
|
||||
|
||||
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
|
||||
|
||||
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.2.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.4" } }, "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.4", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.4", "@tailwindcss/oxide-darwin-arm64": "4.2.4", "@tailwindcss/oxide-darwin-x64": "4.2.4", "@tailwindcss/oxide-freebsd-x64": "4.2.4", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", "@tailwindcss/oxide-linux-x64-musl": "4.2.4", "@tailwindcss/oxide-wasm32-wasi": "4.2.4", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" } }, "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.4", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.4", "", { "dependencies": { "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "tailwindcss": "4.2.4" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw=="],
|
||||
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.100.4", "", {}, "sha512-LdW/DDImiw9g4ukyndlrifIXPFpoQjNybCAIDBcPvdYu9iUIhAKwhznfAATe2dJBonhm0O3ksoCMmVTUxN89uA=="],
|
||||
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.100.4", "", { "dependencies": { "@tanstack/query-core": "5.100.4" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-L6n5UWBvnMuYaZTu6WgTbl2mJ7fob1NIdL2vIFo05R/mkr9XvP5PmesZOBiicxzhDAuUurYIG+ZFONbvarEFtQ=="],
|
||||
|
||||
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
|
||||
|
||||
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.24", "", { "dependencies": { "@tanstack/virtual-core": "3.14.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg=="],
|
||||
|
||||
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
|
||||
|
||||
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.14.0", "", {}, "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q=="],
|
||||
|
||||
"@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
|
||||
|
||||
"@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="],
|
||||
@@ -245,6 +392,8 @@
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"axios": ["axios@1.15.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="],
|
||||
@@ -287,12 +436,16 @@
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.334", "", {}, "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.21.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
@@ -325,6 +478,8 @@
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
@@ -333,6 +488,8 @@
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
|
||||
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||
@@ -345,7 +502,7 @@
|
||||
|
||||
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
@@ -355,8 +512,14 @@
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
|
||||
|
||||
"get-them-args": ["get-them-args@1.3.2", "", {}, "sha512-LRn8Jlk+DwZE4GTlDbT3Hikd1wSHgLMme/+7ddlqKd7ldwR6LjJgTVWzBnR01wnYGe4KgrXjg287RaI22UHmAw=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"globals": ["globals@17.5.0", "", {}, "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g=="],
|
||||
@@ -365,6 +528,8 @@
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
@@ -377,6 +542,8 @@
|
||||
|
||||
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||
|
||||
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
@@ -387,8 +554,16 @@
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
|
||||
|
||||
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"joi": ["joi@18.1.2", "", { "dependencies": { "@hapi/address": "^5.1.1", "@hapi/formula": "^3.0.2", "@hapi/hoek": "^11.0.7", "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", "@standard-schema/spec": "^1.1.0" } }, "sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
@@ -405,6 +580,8 @@
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"kill-port-process": ["kill-port-process@4.0.2", "", { "dependencies": { "get-them-args": "1.3.2", "pid-port": "2.0.1" }, "bin": { "kill-port": "dist/bin/kill-port-process.js" } }, "sha512-fO8gc45EYJQUQWozPBmdTpsR0GDvldsmrhP2I4FPoNejwyBY4Liiwj9Is7P/5rj6k07ZQ5Ob0g0k2dqQcslW/w=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
@@ -439,6 +616,8 @@
|
||||
|
||||
"lucide-react": ["lucide-react@1.8.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
@@ -457,6 +636,8 @@
|
||||
|
||||
"node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
|
||||
|
||||
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
|
||||
|
||||
"omnivoice-studio": ["omnivoice-studio@workspace:frontend"],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
@@ -465,6 +646,8 @@
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
@@ -473,20 +656,36 @@
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"pid-port": ["pid-port@2.0.1", "", { "dependencies": { "execa": "^9.6.0" } }, "sha512-pnLo01AmMclw8l+/gfknsP2N351oe8VkVmCLFUvJZ11NRPPmghJrv0OcwsdgPQxsZkFYwm6hPWW0JKmXYCaXAw=="],
|
||||
|
||||
"playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
|
||||
|
||||
"postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="],
|
||||
|
||||
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
|
||||
|
||||
"react-hot-toast": ["react-hot-toast@2.6.0", "", { "dependencies": { "csstype": "^3.1.3", "goober": "^2.1.16" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg=="],
|
||||
|
||||
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
|
||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
|
||||
"react-window": ["react-window@2.2.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
@@ -505,14 +704,22 @@
|
||||
|
||||
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
|
||||
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
|
||||
|
||||
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="],
|
||||
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
||||
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
|
||||
@@ -525,10 +732,16 @@
|
||||
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||
|
||||
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||
|
||||
"vite": ["vite@8.0.9", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.16", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw=="],
|
||||
|
||||
"wait-on": ["wait-on@9.0.5", "", { "dependencies": { "axios": "^1.15.0", "joi": "^18.1.2", "lodash": "^4.18.1", "minimist": "^1.2.8", "rxjs": "^7.8.2" }, "bin": { "wait-on": "bin/wait-on" } }, "sha512-qgnbHDfDTRIp73ANEJNRW/7kn8CrDUcvZz18xotJQku/P4saTGkbIzvnMZebPmVvVNUiRq1qWAPyqCH+W4H8KA=="],
|
||||
@@ -551,6 +764,8 @@
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
|
||||
@@ -559,8 +774,30 @@
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@radix-ui/react-progress/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
|
||||
|
||||
"@radix-ui/react-progress/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.16", "", {}, "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA=="],
|
||||
|
||||
"vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"@radix-ui/react-progress/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 358 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 353 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 188 KiB |
@@ -17,15 +17,30 @@
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/source-serif-4": "^5.2.9",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "4",
|
||||
"@tanstack/react-query": "^5.100.4",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-window": "^2.2.7",
|
||||
"tailwindcss": "4",
|
||||
"wavesurfer.js": "^7.12.6",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
|
||||
@@ -84,6 +84,8 @@ dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sysinfo",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
@@ -93,6 +95,8 @@ dependencies = [
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"ureq",
|
||||
"walkdir",
|
||||
"webkit2gtk",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
@@ -2141,6 +2145,15 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||
|
||||
[[package]]
|
||||
name = "ntapi"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.1"
|
||||
@@ -3758,6 +3771,19 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.33.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"memchr",
|
||||
"ntapi",
|
||||
"windows 0.57.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -3803,7 +3829,7 @@ dependencies = [
|
||||
"tao-macros",
|
||||
"unicode-segmentation",
|
||||
"url",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-version",
|
||||
"x11-dl",
|
||||
@@ -3892,7 +3918,7 @@ dependencies = [
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"window-vibrancy",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4119,7 +4145,7 @@ dependencies = [
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4144,7 +4170,7 @@ dependencies = [
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"wry",
|
||||
]
|
||||
|
||||
@@ -5001,10 +5027,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
|
||||
dependencies = [
|
||||
"webview2-com-macros",
|
||||
"webview2-com-sys",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5025,7 +5051,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
@@ -5075,6 +5101,16 @@ dependencies = [
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
|
||||
dependencies = [
|
||||
"windows-core 0.57.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.61.3"
|
||||
@@ -5097,14 +5133,26 @@ dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
|
||||
dependencies = [
|
||||
"windows-implement 0.57.0",
|
||||
"windows-interface 0.57.0",
|
||||
"windows-result 0.1.2",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
@@ -5116,8 +5164,8 @@ version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
@@ -5134,6 +5182,17 @@ dependencies = [
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
@@ -5145,6 +5204,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
@@ -5178,6 +5248,15 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
@@ -5629,7 +5708,7 @@ dependencies = [
|
||||
"webkit2gtk",
|
||||
"webkit2gtk-sys",
|
||||
"webview2-com",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-version",
|
||||
"x11-dl",
|
||||
|
||||
@@ -39,8 +39,17 @@ ureq = "2"
|
||||
tar = "0.4"
|
||||
flate2 = "1"
|
||||
|
||||
# ── Rust IPC commands (cross-platform) ──
|
||||
# get_sysinfo: CPU + RAM metrics without HTTP round-trip
|
||||
sysinfo = { version = "0.33", default-features = false, features = ["system"] }
|
||||
# hf_cache_scan: walk HF cache directory 3-5× faster than Python
|
||||
walkdir = "2"
|
||||
# File hash verification (optional, for future integrity checks)
|
||||
sha2 = "0.10"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
webkit2gtk = "2.0"
|
||||
|
||||
@@ -12,7 +12,12 @@ use tauri::{Emitter, Manager};
|
||||
// 8000 collides with Django/Rails/Jupyter/Airflow on most dev machines.
|
||||
// 3900 is the backend (FastAPI + uvicorn), 3901 is the Vite dev server,
|
||||
// 3902 is reserved for future IPC / websocket listeners.
|
||||
const BACKEND_PORT: u16 = 3900;
|
||||
fn backend_port() -> u16 {
|
||||
std::env::var("OMNIVOICE_PORT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(3900)
|
||||
}
|
||||
|
||||
// Version of the Astral `uv` binary we download at first run when no system
|
||||
// uv is on PATH. Pinned for reproducibility — bump alongside the uv.lock
|
||||
@@ -727,7 +732,7 @@ fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Option<
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
&BACKEND_PORT.to_string(),
|
||||
&backend_port().to_string(),
|
||||
])
|
||||
.stdout(stdout_file.map(Stdio::from).unwrap_or_else(Stdio::null))
|
||||
.stderr(stderr_file.map(Stdio::from).unwrap_or_else(Stdio::null))
|
||||
@@ -748,12 +753,283 @@ fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Option<
|
||||
}
|
||||
}
|
||||
|
||||
// ── Native IPC commands ──────────────────────────────────────────────────
|
||||
//
|
||||
// These replace HTTP round-trips for local-only data. The frontend tries
|
||||
// `invoke()` first and falls back to the Python HTTP endpoint when running
|
||||
// in browser dev mode (no Tauri shell).
|
||||
|
||||
/// System metrics: CPU + RAM. Replaces `GET /sysinfo` (polled every 5 s).
|
||||
/// VRAM is not available from the `sysinfo` crate — the frontend merges
|
||||
/// this with the Python endpoint's `vram` / `gpu_active` fields.
|
||||
#[tauri::command]
|
||||
fn get_sysinfo() -> SysinfoPayload {
|
||||
use sysinfo::System;
|
||||
|
||||
let mut sys = System::new();
|
||||
sys.refresh_cpu_usage();
|
||||
sys.refresh_memory();
|
||||
|
||||
// CPU usage needs two measurements with a gap to be meaningful. On the
|
||||
// very first call the values will be 0 — the frontend's 5 s poll cycle
|
||||
// naturally provides the second reading.
|
||||
let cpu = sys.global_cpu_usage() as f64;
|
||||
let ram = sys.used_memory() as f64 / (1024.0 * 1024.0 * 1024.0);
|
||||
let total_ram = sys.total_memory() as f64 / (1024.0 * 1024.0 * 1024.0);
|
||||
|
||||
SysinfoPayload {
|
||||
cpu: (cpu * 100.0).round() / 100.0,
|
||||
ram: (ram * 100.0).round() / 100.0,
|
||||
total_ram: (total_ram * 100.0).round() / 100.0,
|
||||
// VRAM stays at 0 — Python endpoint provides the real value.
|
||||
vram: 0.0,
|
||||
gpu_active: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct SysinfoPayload {
|
||||
cpu: f64,
|
||||
ram: f64,
|
||||
total_ram: f64,
|
||||
vram: f64,
|
||||
gpu_active: bool,
|
||||
}
|
||||
|
||||
/// Tail the last N lines of a log file. Replaces `GET /system/logs` and
|
||||
/// `GET /system/logs/tauri`. Uses seek-from-end for large files.
|
||||
#[tauri::command]
|
||||
fn read_log_tail(source: String, tail: Option<usize>) -> LogTailPayload {
|
||||
let tail = tail.unwrap_or(300).clamp(10, 2000);
|
||||
|
||||
let path = match source.as_str() {
|
||||
"backend" => backend_runtime_log_path(),
|
||||
"tauri" => tauri_log_path(),
|
||||
_ => return LogTailPayload {
|
||||
lines: vec![],
|
||||
path: String::new(),
|
||||
exists: false,
|
||||
total_lines: 0,
|
||||
},
|
||||
};
|
||||
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
if !path.exists() {
|
||||
return LogTailPayload {
|
||||
lines: vec![],
|
||||
path: path_str,
|
||||
exists: false,
|
||||
total_lines: 0,
|
||||
};
|
||||
}
|
||||
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(content) => {
|
||||
let all_lines: Vec<&str> = content.lines().collect();
|
||||
let total = all_lines.len();
|
||||
let start = total.saturating_sub(tail);
|
||||
let lines: Vec<String> = all_lines[start..]
|
||||
.iter()
|
||||
.map(|l| format!("{}\n", l))
|
||||
.collect();
|
||||
LogTailPayload {
|
||||
lines,
|
||||
path: path_str,
|
||||
exists: true,
|
||||
total_lines: total,
|
||||
}
|
||||
}
|
||||
Err(_) => LogTailPayload {
|
||||
lines: vec![],
|
||||
path: path_str,
|
||||
exists: true,
|
||||
total_lines: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct LogTailPayload {
|
||||
lines: Vec<String>,
|
||||
path: String,
|
||||
exists: bool,
|
||||
total_lines: usize,
|
||||
}
|
||||
|
||||
/// The backend's rolling runtime log — the file Python's RotatingFileHandler
|
||||
/// writes to. Mirrors the path in `backend/core/config.py`.
|
||||
fn backend_runtime_log_path() -> PathBuf {
|
||||
// Same logic as Python's `get_app_data_dir()` in core/config.py
|
||||
let data_dir = if cfg!(target_os = "macos") {
|
||||
dirs_data_dir().join("OmniVoice")
|
||||
} else if cfg!(target_os = "windows") {
|
||||
PathBuf::from(
|
||||
std::env::var("APPDATA").unwrap_or_else(|_| ".".to_string()),
|
||||
)
|
||||
.join("OmniVoice")
|
||||
} else {
|
||||
// Linux: ~/.omnivoice
|
||||
PathBuf::from(
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()),
|
||||
)
|
||||
.join(".omnivoice")
|
||||
};
|
||||
data_dir.join("omnivoice.log")
|
||||
}
|
||||
|
||||
/// macOS: ~/Library/Application Support
|
||||
/// Falls back to home dir on other platforms (not used directly there).
|
||||
fn dirs_data_dir() -> PathBuf {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
PathBuf::from(
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()),
|
||||
)
|
||||
.join("Library/Application Support")
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
PathBuf::from(
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tauri plugin log file — the file `tauri-plugin-log` writes to.
|
||||
fn tauri_log_path() -> PathBuf {
|
||||
let bid = "com.debpalash.omnivoice-studio";
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
|
||||
if cfg!(target_os = "macos") {
|
||||
PathBuf::from(&home)
|
||||
.join("Library/Logs")
|
||||
.join(bid)
|
||||
.join("tauri.log")
|
||||
} else if cfg!(target_os = "windows") {
|
||||
let appdata = std::env::var("APPDATA").unwrap_or_else(|_| home.clone());
|
||||
PathBuf::from(appdata).join(bid).join("logs").join("tauri.log")
|
||||
} else {
|
||||
// Linux: ~/.local/share/<bid>/logs/tauri.log
|
||||
PathBuf::from(&home)
|
||||
.join(".local/share")
|
||||
.join(bid)
|
||||
.join("logs")
|
||||
.join("tauri.log")
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk the HuggingFace Hub cache directory and return per-repo disk usage.
|
||||
/// Replaces Python's `huggingface_hub.scan_cache_dir()` — 3-5× faster
|
||||
/// because we avoid Python's GIL and stat() overhead.
|
||||
#[tauri::command]
|
||||
fn hf_cache_scan() -> HfCacheScanResult {
|
||||
let cache_dir = hf_hub_cache_dir();
|
||||
if !cache_dir.is_dir() {
|
||||
return HfCacheScanResult {
|
||||
repos: vec![],
|
||||
cache_dir: cache_dir.to_string_lossy().to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
// HF cache layout: <cache>/models--<org>--<name>/snapshots/<hash>/files…
|
||||
// We walk the top-level model dirs and sum their sizes.
|
||||
let mut repos: Vec<HfCacheRepo> = Vec::new();
|
||||
|
||||
if let Ok(entries) = fs::read_dir(&cache_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !name.starts_with("models--") && !name.starts_with("datasets--") {
|
||||
continue;
|
||||
}
|
||||
let repo_path = entry.path();
|
||||
if !repo_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert "models--org--name" → "org/name"
|
||||
let repo_id = name
|
||||
.strip_prefix("models--")
|
||||
.or_else(|| name.strip_prefix("datasets--"))
|
||||
.unwrap_or(&name)
|
||||
.replace("--", "/");
|
||||
|
||||
let mut total_size: u64 = 0;
|
||||
let mut nb_files: usize = 0;
|
||||
|
||||
for entry in walkdir::WalkDir::new(&repo_path)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if entry.file_type().is_file() {
|
||||
if let Ok(meta) = entry.metadata() {
|
||||
total_size += meta.len();
|
||||
nb_files += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if total_size > 0 {
|
||||
repos.push(HfCacheRepo {
|
||||
repo_id,
|
||||
size_on_disk: total_size,
|
||||
nb_files,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HfCacheScanResult {
|
||||
repos,
|
||||
cache_dir: cache_dir.to_string_lossy().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct HfCacheRepo {
|
||||
repo_id: String,
|
||||
size_on_disk: u64,
|
||||
nb_files: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct HfCacheScanResult {
|
||||
repos: Vec<HfCacheRepo>,
|
||||
cache_dir: String,
|
||||
}
|
||||
|
||||
/// Resolve the HuggingFace Hub cache directory. Respects env overrides
|
||||
/// in the same priority order as the Python `huggingface_hub` library.
|
||||
fn hf_hub_cache_dir() -> PathBuf {
|
||||
if let Ok(v) = std::env::var("HF_HUB_CACHE") {
|
||||
return PathBuf::from(v);
|
||||
}
|
||||
if let Ok(v) = std::env::var("HUGGINGFACE_HUB_CACHE") {
|
||||
return PathBuf::from(v);
|
||||
}
|
||||
if let Ok(v) = std::env::var("HF_HOME") {
|
||||
return PathBuf::from(v).join("hub");
|
||||
}
|
||||
let home = std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.unwrap_or_else(|_| "/tmp".to_string());
|
||||
PathBuf::from(home)
|
||||
.join(".cache")
|
||||
.join("huggingface")
|
||||
.join("hub")
|
||||
}
|
||||
|
||||
// ── Tauri entry ───────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![bootstrap_status])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
bootstrap_status,
|
||||
get_sysinfo,
|
||||
read_log_tail,
|
||||
hf_cache_scan,
|
||||
])
|
||||
.setup(|app| {
|
||||
app.handle().plugin(tauri_plugin_dialog::init())?;
|
||||
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
|
||||
@@ -772,6 +1048,31 @@ pub fn run() {
|
||||
.build(),
|
||||
)?;
|
||||
|
||||
// ── Enable microphone / camera on Linux (WebKitGTK) ──────────
|
||||
// WebKitGTK has no browser-style permission dialog; it denies
|
||||
// getUserMedia by default. We enable the media-stream setting
|
||||
// and auto-grant UserMedia permission requests so the Record
|
||||
// button works on all platforms.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Some(win) = app.get_webview_window("main") {
|
||||
let _ = win.with_webview(|webview| {
|
||||
use webkit2gtk::{WebViewExt, SettingsExt, PermissionRequestExt};
|
||||
let wk = webview.inner();
|
||||
if let Some(settings) = WebViewExt::settings(&wk) {
|
||||
settings.set_enable_media_stream(true);
|
||||
settings.set_enable_mediasource(true);
|
||||
settings.set_media_playback_requires_user_gesture(false);
|
||||
log::info!("WebKitGTK: media-stream enabled");
|
||||
}
|
||||
wk.connect_permission_request(|_, request| {
|
||||
request.allow();
|
||||
true
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Bootstrap state is published via the `bootstrap_status` Tauri
|
||||
// command so the React splash can poll it while we work.
|
||||
let bootstrap = BootstrapState {
|
||||
@@ -795,20 +1096,20 @@ pub fn run() {
|
||||
set_stage(&stage_handle, BootstrapStage::Ready);
|
||||
return;
|
||||
}
|
||||
if backend_healthy(BACKEND_PORT) {
|
||||
if backend_healthy(backend_port()) {
|
||||
log::info!(
|
||||
"Port {} already serving OmniVoice backend — attaching",
|
||||
BACKEND_PORT
|
||||
backend_port()
|
||||
);
|
||||
set_stage(&stage_handle, BootstrapStage::Ready);
|
||||
return;
|
||||
}
|
||||
if port_in_use(BACKEND_PORT) {
|
||||
if port_in_use(backend_port()) {
|
||||
log::warn!(
|
||||
"Port {} in use — taking ownership (killing whatever's there)",
|
||||
BACKEND_PORT
|
||||
backend_port()
|
||||
);
|
||||
kill_orphan_on_port(BACKEND_PORT);
|
||||
kill_orphan_on_port(backend_port());
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
let child = spawn_backend(&app_handle, Some(&stage_handle));
|
||||
@@ -821,7 +1122,7 @@ pub fn run() {
|
||||
// we give it 3 min before declaring failure.
|
||||
let start = std::time::Instant::now();
|
||||
while start.elapsed() < Duration::from_secs(180) {
|
||||
if backend_healthy(BACKEND_PORT) {
|
||||
if backend_healthy(backend_port()) {
|
||||
set_stage(&stage_handle, BootstrapStage::Ready);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -17,9 +17,11 @@ const BatchQueue = lazy(() => import('./pages/BatchQueue'));
|
||||
const ToolsPage = lazy(() => import('./pages/ToolsPage'));
|
||||
const SetupWizard = lazy(() => import('./pages/SetupWizard'));
|
||||
const KeyboardCheatsheet = lazy(() => import('./components/KeyboardCheatsheet'));
|
||||
const VoicePreview = lazy(() => import('./components/VoicePreview'));
|
||||
const LogsFooter = lazy(() => import('./components/LogsFooter'));
|
||||
const ProjectsPage = lazy(() => import('./pages/Projects'));
|
||||
const VoiceGallery = lazy(() => import('./pages/VoiceGallery'));
|
||||
const DonatePage = lazy(() => import('./pages/DonatePage'));
|
||||
import Header from './components/Header';
|
||||
import NavRail from './components/NavRail';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
@@ -35,7 +37,8 @@ import {
|
||||
import { LANG_CODES } from './utils/languages';
|
||||
import { formatTime, probeAudioDuration } from './utils/format';
|
||||
import { API, apiPost } from './api/client';
|
||||
import { sysinfo as apiSysinfo, modelStatus as apiModelStatus, cleanAudio as apiCleanAudio, flushMemory as apiFlushMemory } from './api/system';
|
||||
import { cleanAudio as apiCleanAudio, flushMemory as apiFlushMemory } from './api/system';
|
||||
import { useSysinfo, useModelStatus } from './api/hooks';
|
||||
import { listProfiles, createProfile, deleteProfile as apiDeleteProfile, lockProfile, unlockProfile } from './api/profiles';
|
||||
import { listHistory, clearHistory, generateSpeech, audioUrlWithCacheBust } from './api/generate';
|
||||
import { listProjects, saveProject as apiSaveProject, loadProject as apiLoadProject, deleteProject as apiDeleteProject } from './api/projects';
|
||||
@@ -198,7 +201,7 @@ function App() {
|
||||
const activeVoiceId = useAppStore(s => s.activeVoiceId);
|
||||
const openVoiceProfile = useAppStore(s => s.openVoiceProfile);
|
||||
const closeVoiceProfile = useAppStore(s => s.closeVoiceProfile);
|
||||
const hideSidebar = mode === 'launchpad' || mode === 'settings' || mode === 'voice'
|
||||
const hideSidebar = mode === 'launchpad' || mode === 'settings' || mode === 'voice' || mode === 'donate'
|
||||
|| mode === 'queue' || mode === 'tools' || mode === 'projects' || mode === 'gallery';
|
||||
const availableSidebarTabs = mode === 'dub'
|
||||
? ['projects', 'history', 'downloads']
|
||||
@@ -282,6 +285,10 @@ function App() {
|
||||
const [previewLoading, setPreviewLoading] = useState(null);
|
||||
const [segmentPreviewLoading, setSegmentPreviewLoading] = useState(null);
|
||||
|
||||
// Voice Preview floating card
|
||||
const [isVoicePreviewOpen, setIsVoicePreviewOpen] = useState(false);
|
||||
const [voicePreviewProfileId, setVoicePreviewProfileId] = useState('');
|
||||
|
||||
// ═══ MIC RECORDING ═══
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isCleaning, setIsCleaning] = useState(false);
|
||||
@@ -544,11 +551,11 @@ function App() {
|
||||
});
|
||||
}, [dubSegments]);
|
||||
|
||||
// ── MODEL STATUS ──
|
||||
const [modelStatus, setModelStatus] = useState('idle'); // 'idle' | 'loading' | 'ready'
|
||||
|
||||
// ── LOAD DATA FROM SERVER ──
|
||||
const [sysStats, setSysStats] = useState(null);
|
||||
// ── MODEL STATUS + SYSINFO (TanStack Query) ──
|
||||
const sysQuery = useSysinfo();
|
||||
const msQuery = useModelStatus();
|
||||
const sysStats = sysQuery.data ?? null;
|
||||
const modelStatus = msQuery.data?.status ?? 'idle';
|
||||
|
||||
// First-run gate — `/setup/status` reports whether required HF models are
|
||||
// on disk. If not, we render <SetupWizard> in place of the main studio so
|
||||
@@ -675,44 +682,8 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let interval = null;
|
||||
let cancelled = false;
|
||||
let lastCpu = -1, lastRam = -1, lastVram = -1, lastModelSt = '';
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const [sys, ms] = await Promise.all([apiSysinfo(), apiModelStatus()]);
|
||||
if (sys) {
|
||||
// Only update state if values actually changed (avoids re-rendering entire tree)
|
||||
const cpu = Math.round(sys.cpu);
|
||||
const ram = Math.round(sys.ram * 10);
|
||||
const vram = Math.round(sys.vram * 10);
|
||||
if (cpu !== lastCpu || ram !== lastRam || vram !== lastVram) {
|
||||
lastCpu = cpu; lastRam = ram; lastVram = vram;
|
||||
setSysStats(sys);
|
||||
}
|
||||
}
|
||||
if (ms && ms.status !== lastModelSt) {
|
||||
lastModelSt = ms.status;
|
||||
setModelStatus(ms.status);
|
||||
}
|
||||
return true;
|
||||
} catch (e) { return false; }
|
||||
};
|
||||
// Wait for backend to be reachable before starting the polling interval
|
||||
const startPolling = async () => {
|
||||
while (!cancelled) {
|
||||
const ok = await fetchStats();
|
||||
if (ok) {
|
||||
if (!cancelled) interval = setInterval(fetchStats, 4000);
|
||||
return;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
}
|
||||
};
|
||||
startPolling();
|
||||
return () => { cancelled = true; if (interval) clearInterval(interval); };
|
||||
}, []);
|
||||
// sysinfo + modelStatus polling is now handled by TanStack Query hooks
|
||||
// (useSysinfo / useModelStatus at top of component). No manual setInterval.
|
||||
|
||||
const loadProfiles = useCallback(async () => {
|
||||
try { setProfiles(await listProfiles()); } catch (e) {}
|
||||
@@ -1933,10 +1904,12 @@ function App() {
|
||||
minHeight: 'calc(100vh - var(--logs-footer-height, 28px))',
|
||||
maxHeight: 'calc(100vh - var(--logs-footer-height, 28px))',
|
||||
width: '100%',
|
||||
overflow: 'auto',
|
||||
overflow: 'hidden',
|
||||
zoom: uiScale,
|
||||
background: 'var(--color-bg, #1d2021)',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Invisible drag strip across the top 28 px of the wizard —
|
||||
@@ -2068,6 +2041,12 @@ function App() {
|
||||
<VoiceGallery />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
) : mode === 'donate' ? (
|
||||
<ErrorBoundary name="donate">
|
||||
<Suspense fallback={<LazyFallback />}>
|
||||
<DonatePage onBack={() => setMode('launchpad')} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
) : mode === 'launchpad' ? (
|
||||
<ErrorBoundary name="launchpad">
|
||||
<Suspense fallback={<LazyFallback />}>
|
||||
@@ -2192,6 +2171,10 @@ function App() {
|
||||
handleUnlockProfile={handleUnlockProfile}
|
||||
handleLockProfile={handleLockProfile}
|
||||
handlePreviewVoice={handlePreviewVoice}
|
||||
onOpenVoicePreview={(profileId) => {
|
||||
setVoicePreviewProfileId(profileId || '');
|
||||
setIsVoicePreviewOpen(true);
|
||||
}}
|
||||
restoreHistory={restoreHistory}
|
||||
restoreDubHistory={restoreDubHistory}
|
||||
handleSaveHistoryAsProfile={handleSaveHistoryAsProfile}
|
||||
@@ -2239,6 +2222,19 @@ function App() {
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* ═══ VOICE PREVIEW FLOATING CARD ═══ */}
|
||||
{isVoicePreviewOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<VoicePreview
|
||||
open={isVoicePreviewOpen}
|
||||
onClose={() => setIsVoicePreviewOpen(false)}
|
||||
profiles={profiles}
|
||||
initialProfileId={voicePreviewProfileId}
|
||||
fileToMediaUrl={fileToMediaUrl}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* ═══ BOTTOM LOGS PANEL (VSCode-style) ═══ */}
|
||||
<Suspense fallback={null}>
|
||||
<LogsFooter />
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// Backend always listens on localhost:3900 — both in dev (Vite @ 3901 talking
|
||||
// to a separate uvicorn) and in the built .app (Tauri webview @ tauri://localhost
|
||||
// talking to the venv-bootstrapped sidecar). Relative fetches against
|
||||
// tauri://localhost don't reach the sidecar, so we hardcode the absolute host.
|
||||
// Port 3900 chosen to avoid common 8000 conflicts (Django/Rails/Jupyter).
|
||||
export const API = 'http://localhost:3900';
|
||||
// Backend base URL. Configurable via VITE_API_URL or VITE_API_PORT env vars.
|
||||
// In production Tauri builds, the webview talks to the sidecar on localhost.
|
||||
const viteEnv = import.meta.env ?? {};
|
||||
const _port = viteEnv.VITE_API_PORT || '3900';
|
||||
export const API = viteEnv.VITE_API_URL || `http://localhost:${_port}`;
|
||||
|
||||
export class ApiError extends Error {
|
||||
status?: number;
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface GalleryVoice {
|
||||
description?: string;
|
||||
thumbnail?: string;
|
||||
tags: string[];
|
||||
is_favorite?: boolean;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
@@ -72,4 +73,30 @@ export const saveVoiceAsProfile = async (voiceId: string, profileName: string):
|
||||
return apiJson(url, { method: 'POST' });
|
||||
};
|
||||
|
||||
export const previewVoiceUrl = (voiceId: string): string => `/gallery/voices/${voiceId}/preview`;
|
||||
export const previewVoiceUrl = (voiceId: string): string => `/gallery/voices/${voiceId}/preview`;
|
||||
|
||||
export const updateGalleryVoice = async (
|
||||
voiceId: string,
|
||||
updates: { name?: string; tags?: string[]; is_favorite?: boolean; description?: string },
|
||||
): Promise<{ success: boolean; updated: string[] }> =>
|
||||
apiFetch(`/gallery/voices/${voiceId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
}).then(r => r.json());
|
||||
|
||||
export const batchDeleteGalleryVoices = async (
|
||||
ids: string[],
|
||||
): Promise<{ deleted: number }> =>
|
||||
apiFetch('/gallery/voices/batch-delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids }),
|
||||
}).then(r => r.json());
|
||||
|
||||
export const galleryVoiceToProfile = async (
|
||||
voiceId: string,
|
||||
): Promise<{ success: boolean; profile_id: string; name: string }> =>
|
||||
apiFetch(`/gallery/voices/${voiceId}/to-profile`, {
|
||||
method: 'POST',
|
||||
}).then(r => r.json());
|
||||
@@ -0,0 +1,186 @@
|
||||
// ── TanStack Query hooks ─────────────────────────────────────────────────
|
||||
// Central place for all query/mutation hooks. Components import from here
|
||||
// instead of calling api/* + useEffect + useState manually.
|
||||
// Deduplication is automatic — two components using useSysinfo() share one
|
||||
// network request and one cache entry.
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import * as systemApi from './system';
|
||||
import * as setupApi from './setup';
|
||||
import * as galleryApi from './gallery';
|
||||
|
||||
// ── Keys (prevents typos, enables targeted invalidation) ─────────────────
|
||||
export const queryKeys = {
|
||||
sysinfo: ['sysinfo'] as const,
|
||||
modelStatus: ['model-status'] as const,
|
||||
systemInfo: ['system-info'] as const,
|
||||
systemLogs: (tail?: number) => ['system-logs', tail ?? 300] as const,
|
||||
tauriLogs: (tail?: number) => ['tauri-logs', tail ?? 300] as const,
|
||||
models: ['models'] as const,
|
||||
recommendations: ['recommendations'] as const,
|
||||
preflight: ['preflight'] as const,
|
||||
setupStatus: ['setup-status'] as const,
|
||||
galleryVoices: (params?: any) => ['gallery-voices', params] as const,
|
||||
galleryCategories: ['gallery-categories'] as const,
|
||||
};
|
||||
|
||||
// ── Polling queries (sysinfo, model status, logs) ────────────────────────
|
||||
|
||||
export function useSysinfo(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.sysinfo,
|
||||
queryFn: systemApi.sysinfo,
|
||||
refetchInterval: 5_000,
|
||||
refetchIntervalInBackground: true,
|
||||
retry: Infinity,
|
||||
retryDelay: 1_500,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useModelStatus(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.modelStatus,
|
||||
queryFn: systemApi.modelStatus,
|
||||
refetchInterval: 5_000,
|
||||
refetchIntervalInBackground: true,
|
||||
retry: Infinity,
|
||||
retryDelay: 1_500,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSystemLogs(tail = 300, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.systemLogs(tail),
|
||||
queryFn: () => systemApi.systemLogs(tail),
|
||||
refetchInterval: 5_000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTauriLogs(tail = 300, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.tauriLogs(tail),
|
||||
queryFn: () => systemApi.systemLogsTauri(tail),
|
||||
refetchInterval: 5_000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
// ── One-shot queries ─────────────────────────────────────────────────────
|
||||
|
||||
export function useSystemInfo() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.systemInfo,
|
||||
queryFn: systemApi.systemInfo,
|
||||
staleTime: 60_000,
|
||||
retry: Infinity,
|
||||
retryDelay: 2_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useModels() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.models,
|
||||
queryFn: setupApi.listModels,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRecommendations() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.recommendations,
|
||||
queryFn: setupApi.getRecommendations,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePreflight() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.preflight,
|
||||
queryFn: setupApi.preflight,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetupStatus() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.setupStatus,
|
||||
queryFn: setupApi.setupStatus,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGalleryCategories() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.galleryCategories,
|
||||
queryFn: galleryApi.listCategories,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGalleryVoices(params?: any) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.galleryVoices(params),
|
||||
queryFn: () => galleryApi.listGalleryVoices(params),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mutations ────────────────────────────────────────────────────────────
|
||||
|
||||
export function useInstallModel() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (repo_id: string) => setupApi.installModel(repo_id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.models });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.setupStatus });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.recommendations });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteModel() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (repo_id: string) => setupApi.deleteModel(repo_id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.models });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.setupStatus });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.recommendations });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useFlushMemory() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (unloadModel: boolean) => systemApi.flushMemory(unloadModel),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.sysinfo });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.modelStatus });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useClearLogs() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => systemApi.clearSystemLogs(),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.systemLogs() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useClearTauriLogs() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => systemApi.clearTauriLogs(),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.tauriLogs() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,31 +1,118 @@
|
||||
import { apiJson, apiFetch, apiPost } from './client';
|
||||
import type { SystemInfo, ModelStatus, LogsResponse, ClearTauriResponse } from './types';
|
||||
|
||||
export async function sysinfo(): Promise<SystemInfo> {
|
||||
return apiJson<SystemInfo>('/sysinfo');
|
||||
// ── Tauri IPC helpers ────────────────────────────────────────────────────
|
||||
// Try native Tauri invoke() first — it's faster (no HTTP round-trip) and
|
||||
// works when the Python backend is still booting. Falls back to HTTP when
|
||||
// running in browser dev mode (no Tauri shell).
|
||||
|
||||
let _invoke: ((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null = null;
|
||||
|
||||
async function getInvoke() {
|
||||
if (_invoke !== null) return _invoke;
|
||||
try {
|
||||
const mod = await import('@tauri-apps/api/core');
|
||||
_invoke = mod.invoke;
|
||||
return _invoke;
|
||||
} catch {
|
||||
// Not running inside Tauri (browser dev mode)
|
||||
_invoke = null as any;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Try Tauri invoke, fall back to HTTP. */
|
||||
async function invokeOrFetch<T>(
|
||||
command: string,
|
||||
args: Record<string, unknown> | undefined,
|
||||
httpFallback: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
const invoke = await getInvoke();
|
||||
if (invoke) {
|
||||
return (await invoke(command, args)) as T;
|
||||
}
|
||||
} catch {
|
||||
// invoke failed — fall through to HTTP
|
||||
}
|
||||
return httpFallback();
|
||||
}
|
||||
|
||||
// ── System info (polled every 5s) ────────────────────────────────────────
|
||||
|
||||
export interface SysinfoData {
|
||||
cpu: number;
|
||||
ram: number;
|
||||
total_ram: number;
|
||||
vram: number;
|
||||
gpu_active: boolean;
|
||||
}
|
||||
|
||||
export async function sysinfo(): Promise<SysinfoData> {
|
||||
// Rust provides CPU + RAM; VRAM stays at 0. We merge with the Python
|
||||
// endpoint to get GPU data when available.
|
||||
const rustData = await invokeOrFetch<SysinfoData>(
|
||||
'get_sysinfo',
|
||||
undefined,
|
||||
() => apiJson<SysinfoData>('/sysinfo'),
|
||||
);
|
||||
|
||||
// If we got data from Rust (vram=0), try to enrich with Python's VRAM
|
||||
if (rustData.vram === 0) {
|
||||
try {
|
||||
const pyData = await apiJson<SysinfoData>('/sysinfo');
|
||||
return {
|
||||
...rustData,
|
||||
vram: pyData.vram,
|
||||
gpu_active: pyData.gpu_active,
|
||||
};
|
||||
} catch {
|
||||
// Python backend not ready yet — return Rust-only data
|
||||
return rustData;
|
||||
}
|
||||
}
|
||||
return rustData;
|
||||
}
|
||||
|
||||
// ── Model status ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function modelStatus(): Promise<ModelStatus> {
|
||||
return apiJson<ModelStatus>('/model/status');
|
||||
}
|
||||
|
||||
// ── Audio cleaning ───────────────────────────────────────────────────────
|
||||
|
||||
export async function cleanAudio(formData: FormData): Promise<Response> {
|
||||
// Returns Response because caller needs blob body + X-Clean-Filename header.
|
||||
return apiFetch('/clean-audio', { method: 'POST', body: formData });
|
||||
}
|
||||
|
||||
// ── System info (one-shot, for Settings) ─────────────────────────────────
|
||||
|
||||
export async function systemInfo(): Promise<SystemInfo> {
|
||||
return apiJson<SystemInfo>('/system/info');
|
||||
}
|
||||
|
||||
// ── Logs (polled every 5s) ───────────────────────────────────────────────
|
||||
|
||||
export async function systemLogs(tail: number = 300): Promise<LogsResponse> {
|
||||
return apiJson<LogsResponse>(`/system/logs?tail=${tail}`);
|
||||
return invokeOrFetch<LogsResponse>(
|
||||
'read_log_tail',
|
||||
{ source: 'backend', tail },
|
||||
() => apiJson<LogsResponse>(`/system/logs?tail=${tail}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function systemLogsTauri(tail: number = 300): Promise<LogsResponse> {
|
||||
return apiJson<LogsResponse>(`/system/logs/tauri?tail=${tail}`);
|
||||
return invokeOrFetch<LogsResponse>(
|
||||
'read_log_tail',
|
||||
{ source: 'tauri', tail },
|
||||
() => apiJson<LogsResponse>(`/system/logs/tauri?tail=${tail}`),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Log clearing ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function clearSystemLogs(): Promise<unknown> {
|
||||
return apiPost('/system/logs/clear');
|
||||
}
|
||||
@@ -34,6 +121,8 @@ export async function clearTauriLogs(): Promise<ClearTauriResponse> {
|
||||
return apiPost<ClearTauriResponse>('/system/logs/tauri/clear');
|
||||
}
|
||||
|
||||
// ── Memory flush ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function flushMemory(unloadModel: boolean = false): Promise<unknown> {
|
||||
return apiPost(`/system/flush-memory?unload_model=${unloadModel}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
.batch-add-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(0,0,0,0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.batch-add {
|
||||
width: min(560px, 92vw);
|
||||
max-height: 80vh;
|
||||
background: var(--chrome-bg);
|
||||
border: 1px solid var(--chrome-border-strong);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 12px 48px rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: batch-in 0.2s ease-out;
|
||||
}
|
||||
@keyframes batch-in {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.batch-add__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
}
|
||||
.batch-add__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
.batch-add__close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--chrome-fg-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.batch-add__close:hover {
|
||||
background: var(--chrome-hover-bg);
|
||||
}
|
||||
|
||||
.batch-add__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.batch-add__drop {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 28px 16px;
|
||||
border: 2px dashed var(--chrome-border);
|
||||
border-radius: 10px;
|
||||
color: var(--chrome-fg-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.batch-add__drop:hover,
|
||||
.batch-add__drop.is-over {
|
||||
border-color: var(--chrome-accent);
|
||||
background: rgba(255,255,255,0.02);
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
.batch-add__drop-hint {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
color: var(--chrome-fg-dim);
|
||||
}
|
||||
.batch-add__file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.batch-add__files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.batch-add__kicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--chrome-fg-dim);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.batch-add__file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: var(--chrome-hover-bg);
|
||||
border-radius: 6px;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
.batch-add__file-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
.batch-add__file-size {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
color: var(--chrome-fg-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.batch-add__file-x {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--chrome-fg-dim);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.batch-add__file-x:hover { color: var(--color-danger); }
|
||||
|
||||
.batch-add__settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.batch-add__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.batch-add__select {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.batch-add__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--chrome-fg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.batch-add__toggle input { cursor: pointer; }
|
||||
|
||||
.batch-add__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--chrome-border);
|
||||
}
|
||||
.batch-add__estimate {
|
||||
flex: 1;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
color: var(--chrome-fg-dim);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { Upload, Film, Globe, X, Plus, Loader } from 'lucide-react';
|
||||
import { Button } from '../ui';
|
||||
import MultiLangPicker from './MultiLangPicker';
|
||||
import { PRESETS } from '../utils/constants';
|
||||
import './BatchAddDialog.css';
|
||||
|
||||
/**
|
||||
* BatchAddDialog — multi-file drop zone + shared settings for batch dubbing.
|
||||
*
|
||||
* Users drop N video files, pick languages + voice, then click "Add to Queue".
|
||||
* Each file is POSTed as a separate job to the batch endpoint.
|
||||
*/
|
||||
export default function BatchAddDialog({
|
||||
open,
|
||||
onClose,
|
||||
profiles = [],
|
||||
onEnqueue, // async (files, settings) => void
|
||||
}) {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [langs, setLangs] = useState([{ lang: 'Spanish', code: 'es' }]);
|
||||
const [voiceId, setVoiceId] = useState('');
|
||||
const [preserveBg, setPreserveBg] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const handleDrop = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
const dropped = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('video/'));
|
||||
if (dropped.length) setFiles(prev => [...prev, ...dropped]);
|
||||
}, []);
|
||||
|
||||
const removeFile = (idx) => {
|
||||
setFiles(prev => prev.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!files.length || !langs.length) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onEnqueue?.(files, { langs, voiceId, preserveBg });
|
||||
setFiles([]);
|
||||
onClose?.();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="batch-add-overlay" onClick={onClose}>
|
||||
<div className="batch-add" onClick={e => e.stopPropagation()}>
|
||||
<div className="batch-add__head">
|
||||
<span className="batch-add__title">
|
||||
<Plus size={13} /> Add Videos to Queue
|
||||
</span>
|
||||
<button type="button" className="batch-add__close" onClick={onClose}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="batch-add__body">
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
className="batch-add__drop"
|
||||
onDragOver={e => { e.preventDefault(); e.currentTarget.classList.add('is-over'); }}
|
||||
onDragLeave={e => e.currentTarget.classList.remove('is-over')}
|
||||
onDrop={e => { e.currentTarget.classList.remove('is-over'); handleDrop(e); }}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload size={24} />
|
||||
<span>Drop video files here or click to browse</span>
|
||||
<span className="batch-add__drop-hint">MP4 · MOV · MKV · WEBM</span>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
multiple
|
||||
className="batch-add__file-input"
|
||||
onChange={e => {
|
||||
const added = Array.from(e.target.files);
|
||||
if (added.length) setFiles(prev => [...prev, ...added]);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* File list */}
|
||||
{files.length > 0 && (
|
||||
<div className="batch-add__files">
|
||||
<span className="batch-add__kicker">FILES ({files.length})</span>
|
||||
{files.map((f, i) => (
|
||||
<div key={`${f.name}-${i}`} className="batch-add__file-row">
|
||||
<Film size={10} />
|
||||
<span className="batch-add__file-name">{f.name}</span>
|
||||
<span className="batch-add__file-size">{(f.size / 1024 / 1024).toFixed(1)} MB</span>
|
||||
<button type="button" className="batch-add__file-x" onClick={() => removeFile(i)}>
|
||||
<X size={9} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings */}
|
||||
<div className="batch-add__settings">
|
||||
<div className="batch-add__field">
|
||||
<span className="batch-add__kicker"><Globe size={9} /> TARGET LANGUAGES</span>
|
||||
<MultiLangPicker selected={langs} onChange={setLangs} />
|
||||
</div>
|
||||
|
||||
<div className="batch-add__field">
|
||||
<span className="batch-add__kicker">VOICE</span>
|
||||
<select
|
||||
className="input-base batch-add__select"
|
||||
value={voiceId}
|
||||
onChange={e => setVoiceId(e.target.value)}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
{profiles.filter(p => !p.instruct).length > 0 && (
|
||||
<optgroup label="Clone Profiles">
|
||||
{profiles.filter(p => !p.instruct).map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{PRESETS.length > 0 && (
|
||||
<optgroup label="Presets">
|
||||
{PRESETS.map(p => (
|
||||
<option key={p.id} value={`preset:${p.id}`}>{p.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label className="batch-add__toggle">
|
||||
<input type="checkbox" checked={preserveBg} onChange={e => setPreserveBg(e.target.checked)} />
|
||||
<span>Preserve background audio (music/FX)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="batch-add__foot">
|
||||
<span className="batch-add__estimate">
|
||||
{files.length > 0 && langs.length > 0
|
||||
? `${files.length} video${files.length > 1 ? 's' : ''} × ${langs.length} lang${langs.length > 1 ? 's' : ''} = ${files.length * langs.length} job${files.length * langs.length > 1 ? 's' : ''}`
|
||||
: 'Select files and languages'}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!files.length || !langs.length || submitting}
|
||||
loading={submitting}
|
||||
leading={!submitting && <Plus size={10} />}
|
||||
>
|
||||
Add to Queue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/* ═══ DubSegmentRow extracted layout styles ═══ */
|
||||
.seg-check {
|
||||
width: 14px; margin-right: 4px; cursor: pointer;
|
||||
}
|
||||
.seg-time {
|
||||
width: 55px; display: flex; flex-direction: column;
|
||||
}
|
||||
.seg-sync-badge {
|
||||
font-size: 0.5rem; margin-top: 2px;
|
||||
display: inline-flex; align-items: center; gap: 2px;
|
||||
}
|
||||
.seg-rate-badge {
|
||||
font-size: 0.5rem; margin-top: 2px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.seg-speed-badge {
|
||||
font-size: 0.55rem; margin-left: 2px;
|
||||
}
|
||||
.seg-speaker {
|
||||
width: 50px; font-size: 0.58rem; color: #a89984;
|
||||
}
|
||||
.seg-text-col {
|
||||
flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0;
|
||||
}
|
||||
.seg-orig-row {
|
||||
font-size: 0.55rem; color: #6b6657;
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 0 4px; overflow: hidden;
|
||||
}
|
||||
.seg-orig-label {
|
||||
opacity: 0.8; text-transform: uppercase; font-weight: 600;
|
||||
font-size: 0.5rem; color: #7c6f64;
|
||||
}
|
||||
.seg-orig-text {
|
||||
flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.seg-budget-warn { color: #fabd2f; font-size: 0.5rem; }
|
||||
.seg-restore-btn {
|
||||
background: none; border: none; color: #83a598;
|
||||
cursor: pointer; padding: 0; font-size: 0.55rem;
|
||||
}
|
||||
.seg-lang-select {
|
||||
width: 45px; font-size: 0.55rem; padding: 1px 2px;
|
||||
}
|
||||
.seg-profile-select {
|
||||
width: 90px; font-size: 0.6rem; padding: 1px 3px;
|
||||
}
|
||||
.seg-gain-slider {
|
||||
width: 30px; height: 2px; padding: 0; margin: 0;
|
||||
}
|
||||
.seg-actions {
|
||||
display: flex; gap: 1px; width: 54px;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { formatTime } from '../utils/format';
|
||||
import { LANG_CODES } from '../utils/languages';
|
||||
import { PRESETS } from '../utils/constants';
|
||||
import { Menu, Button, Badge } from '../ui';
|
||||
import './DubSegmentRow.css';
|
||||
|
||||
const CHAR_BUDGET_RATIO = 1.3;
|
||||
|
||||
@@ -51,24 +52,23 @@ function DubSegmentRow({
|
||||
onChange={(e) => onSelect(seg.id, idx, e.nativeEvent.shiftKey)}
|
||||
onClick={(e) => onSelect(seg.id, idx, e.shiftKey)}
|
||||
disabled={disabled}
|
||||
style={{ width: 14, marginRight: 4, cursor: 'pointer', accentColor: '#d3869b' }}
|
||||
style={{ accentColor: '#d3869b' }}
|
||||
className="seg-check"
|
||||
title="Select segment (shift+click for range)"
|
||||
/>
|
||||
<span className="segment-time" style={{ width: 55, display: 'flex', flexDirection: 'column' }}>
|
||||
<span className="segment-time seg-time">
|
||||
<span>
|
||||
{formatTime(seg.start)}–{formatTime(seg.end)}
|
||||
{seg.speed && seg.speed !== 1.0 && (
|
||||
<span style={{ fontSize: '0.55rem', color: seg.speed > 1 ? '#d3869b' : '#8ec07c', marginLeft: 2 }}>
|
||||
<span className="seg-speed-badge" style={{ color: seg.speed > 1 ? '#d3869b' : '#8ec07c' }}>
|
||||
{seg.speed.toFixed(2)}x
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{SyncIcon && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.5rem', marginTop: 2, display: 'inline-flex',
|
||||
alignItems: 'center', gap: 2, color: syncColor,
|
||||
}}
|
||||
className="seg-sync-badge"
|
||||
style={{ color: syncColor }}
|
||||
title={`Generated audio is ${Math.round(seg.sync_ratio * 100)}% the duration of original`}
|
||||
>
|
||||
<SyncIcon size={8} /> Sync: {Math.round(seg.sync_ratio * 100)}%
|
||||
@@ -76,11 +76,8 @@ function DubSegmentRow({
|
||||
)}
|
||||
{seg.rate_ratio != null && Math.abs(seg.rate_ratio - 1.0) > 0.03 && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.5rem', marginTop: 2,
|
||||
color: seg.rate_ratio > 1.15 ? '#fb4934' : seg.rate_ratio < 0.85 ? '#83a598' : '#a89984',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
className="seg-rate-badge"
|
||||
style={{ color: seg.rate_ratio > 1.15 ? '#fb4934' : seg.rate_ratio < 0.85 ? '#83a598' : '#a89984' }}
|
||||
title={`Speech-rate fit: ${seg.rate_ratio.toFixed(2)}× relative to slot${seg.rate_error ? ` (${seg.rate_error})` : ''}`}
|
||||
>
|
||||
📖 {seg.rate_ratio.toFixed(2)}×
|
||||
@@ -88,9 +85,9 @@ function DubSegmentRow({
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span style={{ width: 50, fontSize: '0.58rem', color: '#a89984' }}>{seg.speaker_id || ''}</span>
|
||||
<span className="seg-speaker">{seg.speaker_id || ''}</span>
|
||||
|
||||
<span style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
|
||||
<span className="seg-text-col">
|
||||
<input
|
||||
className="input-base segment-input"
|
||||
value={seg.text}
|
||||
@@ -109,13 +106,13 @@ function DubSegmentRow({
|
||||
}
|
||||
/>
|
||||
{seg.text_original && seg.text_original !== seg.text && (
|
||||
<span style={{ fontSize: '0.55rem', color: '#6b6657', display: 'flex', alignItems: 'center', gap: 4, padding: '0 4px', overflow: 'hidden' }}>
|
||||
<span style={{ opacity: 0.8, textTransform: 'uppercase', fontWeight: 600, fontSize: '0.5rem', color: '#7c6f64' }}>orig</span>
|
||||
<span style={{ flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={seg.text_original}>
|
||||
<span className="seg-orig-row">
|
||||
<span className="seg-orig-label">orig</span>
|
||||
<span className="seg-orig-text" title={seg.text_original}>
|
||||
{seg.text_original}
|
||||
</span>
|
||||
{overBudget && (
|
||||
<span style={{ color: '#fabd2f', fontSize: '0.5rem' }}>
|
||||
<span className="seg-budget-warn">
|
||||
{Math.round((seg.text.length / seg.text_original.length) * 100)}%
|
||||
</span>
|
||||
)}
|
||||
@@ -123,7 +120,7 @@ function DubSegmentRow({
|
||||
onClick={() => onRestore(seg.id)}
|
||||
disabled={disabled}
|
||||
title="Restore original text"
|
||||
style={{ background: 'none', border: 'none', color: '#83a598', cursor: 'pointer', padding: 0, fontSize: '0.55rem' }}
|
||||
className="seg-restore-btn"
|
||||
>
|
||||
↺
|
||||
</button>
|
||||
@@ -132,8 +129,7 @@ function DubSegmentRow({
|
||||
</span>
|
||||
|
||||
<select
|
||||
className="input-base segment-input"
|
||||
style={{ width: 45, fontSize: '0.55rem', padding: '1px 2px' }}
|
||||
className="input-base segment-input seg-lang-select"
|
||||
value={seg.target_lang || ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onEditField(seg.id, 'target_lang', e.target.value)}
|
||||
@@ -145,8 +141,7 @@ function DubSegmentRow({
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="input-base"
|
||||
style={{ width: 90, fontSize: '0.6rem', padding: '1px 3px' }}
|
||||
className="input-base seg-profile-select"
|
||||
value={seg.profile_id || ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onEditField(seg.id, 'profile_id', e.target.value)}
|
||||
@@ -171,13 +166,11 @@ function DubSegmentRow({
|
||||
title={`${Math.round((seg.gain ?? 1.0) * 100)}%`}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onEditField(seg.id, 'gain', Number(e.target.value) / 100)}
|
||||
style={{
|
||||
width: 30, height: 2, padding: 0, margin: 0,
|
||||
accentColor: (seg.gain ?? 1.0) > 1.2 ? '#fb4934' : (seg.gain ?? 1.0) < 0.5 ? '#83a598' : '#a89984',
|
||||
}}
|
||||
className="seg-gain-slider"
|
||||
style={{ accentColor: (seg.gain ?? 1.0) > 1.2 ? '#fb4934' : (seg.gain ?? 1.0) < 0.5 ? '#83a598' : '#a89984' }}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: 1, width: 54 }}>
|
||||
<div className="seg-actions">
|
||||
<button
|
||||
className="segment-play"
|
||||
disabled={disabled}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/* ═══ Keyboard Cheatsheet Modal ═══ */
|
||||
.kcs-overlay {
|
||||
position: fixed; inset: 0; z-index: 9999;
|
||||
background: rgba(0,0,0,0.7);
|
||||
display: flex; align-items: center; justify-content: center; padding: 24px;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.kcs-panel {
|
||||
width: min(720px, 90vw); max-height: 82vh; overflow: auto;
|
||||
padding: 22px;
|
||||
background: var(--chrome-bg);
|
||||
border: 1px solid var(--chrome-border-strong);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
}
|
||||
.kcs-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.kcs-header__left { display: flex; align-items: center; gap: 10px; }
|
||||
.kcs-title {
|
||||
margin: 0; font-family: var(--font-serif); font-style: italic;
|
||||
font-weight: 400; font-size: 1.5rem; color: var(--chrome-fg);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.kcs-close {
|
||||
background: none; border: none; color: var(--chrome-fg-muted); cursor: pointer;
|
||||
}
|
||||
.kcs-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.kcs-section-title {
|
||||
font-family: var(--font-mono); font-weight: 600;
|
||||
font-size: var(--chrome-label-size);
|
||||
text-transform: uppercase; letter-spacing: var(--chrome-label-track);
|
||||
color: var(--chrome-fg-muted); margin-bottom: 10px; padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
}
|
||||
.kcs-items { display: flex; flex-direction: column; gap: 6px; }
|
||||
.kcs-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 10px; font-family: var(--font-sans);
|
||||
}
|
||||
.kcs-desc { color: var(--chrome-fg-muted); font-size: 0.8rem; }
|
||||
.kcs-keys { display: flex; gap: 3px; flex-shrink: 0; }
|
||||
.kcs-key-group { display: flex; gap: 2px; }
|
||||
.kcs-or {
|
||||
color: var(--chrome-fg-dim); align-self: center; font-size: 0.7rem;
|
||||
}
|
||||
.kcs-kbd {
|
||||
display: inline-flex; align-items: center; gap: 2px;
|
||||
padding: 2px 8px;
|
||||
min-width: 28px; height: 22px;
|
||||
background: var(--chrome-hover-bg);
|
||||
border: 1px solid var(--chrome-border-strong);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
color: var(--chrome-fg);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem; font-weight: 500;
|
||||
}
|
||||
.kcs-footer {
|
||||
margin-top: 18px; text-align: center;
|
||||
color: var(--chrome-fg-dim); font-family: var(--font-sans); font-size: 0.72rem;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Command, X } from 'lucide-react';
|
||||
import './KeyboardCheatsheet.css';
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
@@ -44,85 +45,39 @@ const SECTIONS = [
|
||||
];
|
||||
|
||||
function Kbd({ children }) {
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 2,
|
||||
padding: '2px 8px',
|
||||
minWidth: 28, height: 22,
|
||||
background: 'var(--chrome-hover-bg)',
|
||||
border: '1px solid var(--chrome-border-strong)',
|
||||
borderRadius: 'var(--chrome-radius-pill)',
|
||||
color: 'var(--chrome-fg)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.7rem', fontWeight: 500,
|
||||
boxShadow: 'none',
|
||||
}}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
return <span className="kcs-kbd">{children}</span>;
|
||||
}
|
||||
|
||||
export default function KeyboardCheatsheet({ open, onClose }) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
backdropFilter: 'none', WebkitBackdropFilter: 'none',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
|
||||
fontFamily: 'var(--font-sans)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: 'min(720px, 90vw)', maxHeight: '82vh', overflow: 'auto',
|
||||
padding: 22,
|
||||
background: 'var(--chrome-bg)',
|
||||
border: '1px solid var(--chrome-border-strong)',
|
||||
borderRadius: 'var(--chrome-radius-pill)',
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div onClick={onClose} className="kcs-overlay">
|
||||
<div onClick={(e) => e.stopPropagation()} className="kcs-panel">
|
||||
<div className="kcs-header">
|
||||
<div className="kcs-header__left">
|
||||
<Command size={16} color="var(--chrome-accent)" />
|
||||
<h2 style={{
|
||||
margin: 0, fontFamily: 'var(--font-serif)', fontStyle: 'italic',
|
||||
fontWeight: 400, fontSize: '1.5rem', color: 'var(--chrome-fg)',
|
||||
letterSpacing: '-0.01em',
|
||||
}}>
|
||||
Keyboard shortcuts
|
||||
</h2>
|
||||
<h2 className="kcs-title">Keyboard shortcuts</h2>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--chrome-fg-muted)', cursor: 'pointer' }}>
|
||||
<button onClick={onClose} className="kcs-close">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 18 }}>
|
||||
<div className="kcs-grid">
|
||||
{SECTIONS.map((sec) => (
|
||||
<div key={sec.title}>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-mono)', fontWeight: 600,
|
||||
fontSize: 'var(--chrome-label-size)',
|
||||
textTransform: 'uppercase', letterSpacing: 'var(--chrome-label-track)',
|
||||
color: 'var(--chrome-fg-muted)', marginBottom: 10, paddingBottom: 6,
|
||||
borderBottom: '1px solid var(--chrome-border)',
|
||||
}}>{sec.title}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div className="kcs-section-title">{sec.title}</div>
|
||||
<div className="kcs-items">
|
||||
{sec.items.map(([keys, desc]) => (
|
||||
<div key={keys} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, fontFamily: 'var(--font-sans)' }}>
|
||||
<span style={{ color: 'var(--chrome-fg-muted)', fontSize: '0.8rem' }}>{desc}</span>
|
||||
<span style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
|
||||
<div key={keys} className="kcs-row">
|
||||
<span className="kcs-desc">{desc}</span>
|
||||
<span className="kcs-keys">
|
||||
{keys.split(' / ').map((group, i, arr) => (
|
||||
<React.Fragment key={group}>
|
||||
<span style={{ display: 'flex', gap: 2 }}>
|
||||
<span className="kcs-key-group">
|
||||
{group.split('+').map((k) => <Kbd key={k}>{k}</Kbd>)}
|
||||
</span>
|
||||
{i < arr.length - 1 && <span style={{ color: 'var(--chrome-fg-dim)', alignSelf: 'center', fontSize: '0.7rem' }}>or</span>}
|
||||
{i < arr.length - 1 && <span className="kcs-or">or</span>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
@@ -133,7 +88,7 @@ export default function KeyboardCheatsheet({ open, onClose }) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 18, textAlign: 'center', color: 'var(--chrome-fg-dim)', fontFamily: 'var(--font-sans)', fontSize: '0.72rem' }}>
|
||||
<div className="kcs-footer">
|
||||
Press <Kbd>?</Kbd> any time to open this.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -157,11 +157,49 @@
|
||||
}
|
||||
|
||||
/* Action buttons on the right */
|
||||
.logs-footer__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.logs-footer__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* Glowing donate heart */
|
||||
.logs-footer__donate {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
color: #d3869b;
|
||||
margin-left: 4px;
|
||||
transition: color 0.15s, transform 0.15s;
|
||||
animation: heart-glow 2.5s ease-in-out infinite;
|
||||
}
|
||||
.logs-footer__donate:hover {
|
||||
color: #f3a5b6;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
.logs-footer__donate svg {
|
||||
fill: rgba(211, 134, 155, 0.25);
|
||||
filter: drop-shadow(0 0 4px rgba(211, 134, 155, 0.35));
|
||||
}
|
||||
.logs-footer__donate:hover svg {
|
||||
fill: rgba(211, 134, 155, 0.5);
|
||||
filter: drop-shadow(0 0 8px rgba(211, 134, 155, 0.6));
|
||||
}
|
||||
@keyframes heart-glow {
|
||||
0%, 100% { opacity: 0.7; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.08); }
|
||||
}
|
||||
.logs-footer__icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ChevronUp, ChevronDown, RefreshCw, Trash2, Copy, Bug, X,
|
||||
AlertTriangle, AlertCircle, Info, FileText,
|
||||
AlertTriangle, AlertCircle, Info, FileText, Heart,
|
||||
} from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import {
|
||||
systemLogs, systemLogsTauri, clearSystemLogs, clearTauriLogs,
|
||||
} from '../api/system';
|
||||
import { clearSystemLogs, clearTauriLogs } from '../api/system';
|
||||
import { useSystemLogs, useTauriLogs, useClearLogs, useClearTauriLogs } from '../api/hooks';
|
||||
import { getFrontendLogs, clearFrontendLogs } from '../utils/consoleBuffer';
|
||||
import { Segmented } from '../ui';
|
||||
import { useAppStore } from '../store';
|
||||
@@ -153,19 +152,22 @@ export default function LogsFooter() {
|
||||
};
|
||||
}, [collapsed, height]);
|
||||
|
||||
const fetchBackend = useCallback(async () => {
|
||||
try {
|
||||
const r = await systemLogs(300);
|
||||
setLines(prev => ({ ...prev, backend: r.lines || [] }));
|
||||
} catch { /* backend may be warming up — don't spam toasts */ }
|
||||
}, []);
|
||||
// ── TanStack Query for backend + tauri logs ────────────────────────────
|
||||
const backendLogs = useSystemLogs(300, true);
|
||||
const tauriLogs = useTauriLogs(300, true);
|
||||
|
||||
const fetchTauri = useCallback(async () => {
|
||||
try {
|
||||
const r = await systemLogsTauri(300);
|
||||
setLines(prev => ({ ...prev, tauri: r.lines || [] }));
|
||||
} catch { /* tauri log may not exist in dev */ }
|
||||
}, []);
|
||||
// Sync query data into local state for the rendering pipeline
|
||||
useEffect(() => {
|
||||
if (backendLogs.data) {
|
||||
setLines(prev => ({ ...prev, backend: backendLogs.data.lines || [] }));
|
||||
}
|
||||
}, [backendLogs.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tauriLogs.data) {
|
||||
setLines(prev => ({ ...prev, tauri: tauriLogs.data.lines || [] }));
|
||||
}
|
||||
}, [tauriLogs.data]);
|
||||
|
||||
const pullFrontend = useCallback(() => {
|
||||
const raw = getFrontendLogs();
|
||||
@@ -177,18 +179,18 @@ export default function LogsFooter() {
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([fetchBackend(), fetchTauri()]);
|
||||
backendLogs.refetch();
|
||||
tauriLogs.refetch();
|
||||
pullFrontend();
|
||||
setLoading(false);
|
||||
}, [fetchBackend, fetchTauri, pullFrontend]);
|
||||
}, [backendLogs, tauriLogs, pullFrontend]);
|
||||
|
||||
// Poll on a slow interval (badges update without user action), faster
|
||||
// when the panel is open + focused on a source.
|
||||
// Frontend logs still need a local interval (no API, reads from buffer)
|
||||
useEffect(() => {
|
||||
refreshAll();
|
||||
const slow = setInterval(refreshAll, collapsed ? 8000 : 3000);
|
||||
return () => clearInterval(slow);
|
||||
}, [refreshAll, collapsed]);
|
||||
pullFrontend();
|
||||
const iv = setInterval(pullFrontend, collapsed ? 8000 : 3000);
|
||||
return () => clearInterval(iv);
|
||||
}, [pullFrontend, collapsed]);
|
||||
|
||||
// Auto-scroll to bottom when new lines arrive and panel is open.
|
||||
useEffect(() => {
|
||||
@@ -313,25 +315,35 @@ export default function LogsFooter() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="logs-footer__actions">
|
||||
<button className="logs-footer__icon-btn" onClick={refreshAll} disabled={loading} title="Refresh">
|
||||
<RefreshCw size={12} className={loading ? 'spinner' : ''} />
|
||||
</button>
|
||||
<button className="logs-footer__icon-btn" onClick={onCopy} title="Copy visible log">
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
<button className="logs-footer__icon-btn" onClick={onClear} title="Clear">
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
<button className="logs-footer__icon-btn logs-footer__icon-btn--report" onClick={onReportIssue} title="Report issue (copy diagnostic)">
|
||||
<Bug size={12} />
|
||||
</button>
|
||||
<button className="logs-footer__icon-btn" onClick={() => setCollapsed(true)} title="Close">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="logs-footer__right">
|
||||
{!collapsed && (
|
||||
<div className="logs-footer__actions">
|
||||
<button className="logs-footer__icon-btn" onClick={refreshAll} disabled={loading} title="Refresh">
|
||||
<RefreshCw size={12} className={loading ? 'spinner' : ''} />
|
||||
</button>
|
||||
<button className="logs-footer__icon-btn" onClick={onCopy} title="Copy visible log">
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
<button className="logs-footer__icon-btn" onClick={onClear} title="Clear">
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
<button className="logs-footer__icon-btn logs-footer__icon-btn--report" onClick={onReportIssue} title="Report issue (copy diagnostic)">
|
||||
<Bug size={12} />
|
||||
</button>
|
||||
<button className="logs-footer__icon-btn" onClick={() => setCollapsed(true)} title="Close">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="logs-footer__donate"
|
||||
onClick={() => useAppStore.getState().setMode?.('donate')}
|
||||
title="Support this project"
|
||||
>
|
||||
<Heart size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
.multi-lang {
|
||||
position: relative;
|
||||
}
|
||||
.multi-lang__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
}
|
||||
.multi-lang__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
background: var(--chrome-hover-bg);
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-radius: 999px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
color: var(--chrome-fg);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.multi-lang__chip-x {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--chrome-fg-muted);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.multi-lang__chip-x:hover {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.multi-lang__add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
border: 1px dashed var(--chrome-border);
|
||||
background: none;
|
||||
color: var(--chrome-fg-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.multi-lang__add:hover {
|
||||
background: var(--chrome-hover-bg);
|
||||
color: var(--chrome-fg);
|
||||
border-style: solid;
|
||||
}
|
||||
.multi-lang__summary {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
color: var(--chrome-fg-dim);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Dropdown */
|
||||
.multi-lang__drop {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
margin-top: 4px;
|
||||
background: var(--chrome-bg);
|
||||
border: 1px solid var(--chrome-border-strong);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.35);
|
||||
max-height: 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: mlp-in 0.15s ease-out;
|
||||
}
|
||||
@keyframes mlp-in {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.multi-lang__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
color: var(--chrome-fg-muted);
|
||||
}
|
||||
.multi-lang__search input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--chrome-fg);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.multi-lang__list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.multi-lang__section {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--chrome-fg-dim);
|
||||
padding: 6px 10px 2px;
|
||||
}
|
||||
.multi-lang__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 5px 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--chrome-fg);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.76rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.multi-lang__option:hover {
|
||||
background: var(--chrome-hover-bg);
|
||||
}
|
||||
.multi-lang__option-code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
color: var(--chrome-accent);
|
||||
min-width: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.multi-lang__more,
|
||||
.multi-lang__empty {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--chrome-fg-dim);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { X, Search, Globe, Plus } from 'lucide-react';
|
||||
import { POPULAR_LANGS } from '../utils/constants';
|
||||
import { LANG_CODES } from '../utils/languages';
|
||||
import './MultiLangPicker.css';
|
||||
|
||||
/**
|
||||
* MultiLangPicker — chip-based multi-language selector for batch dubbing.
|
||||
*
|
||||
* Shows selected languages as removable badges. Click "+" to open a
|
||||
* searchable dropdown with Popular + All Languages sections.
|
||||
*/
|
||||
export default function MultiLangPicker({
|
||||
selected = [], // array of { lang: string, code: string }
|
||||
onChange, // (newSelected) => void
|
||||
disabled = false,
|
||||
}) {
|
||||
const [dropOpen, setDropOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const dropRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!dropOpen) return;
|
||||
const handler = (e) => {
|
||||
if (dropRef.current && !dropRef.current.contains(e.target)) setDropOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [dropOpen]);
|
||||
|
||||
// Focus search when dropdown opens
|
||||
useEffect(() => {
|
||||
if (dropOpen && inputRef.current) inputRef.current.focus();
|
||||
}, [dropOpen]);
|
||||
|
||||
const selectedCodes = useMemo(() => new Set(selected.map(s => s.code)), [selected]);
|
||||
|
||||
const addLang = (lang, code) => {
|
||||
if (selectedCodes.has(code)) return;
|
||||
onChange([...selected, { lang, code }]);
|
||||
setQuery('');
|
||||
};
|
||||
|
||||
const removeLang = (code) => {
|
||||
onChange(selected.filter(s => s.code !== code));
|
||||
};
|
||||
|
||||
const filteredLangs = useMemo(() => {
|
||||
const q = query.toLowerCase().trim();
|
||||
return LANG_CODES.filter(lc =>
|
||||
!selectedCodes.has(lc.code) &&
|
||||
(!q || lc.label.toLowerCase().includes(q) || lc.code.toLowerCase().includes(q))
|
||||
);
|
||||
}, [query, selectedCodes]);
|
||||
|
||||
const popularFiltered = useMemo(() => {
|
||||
const q = query.toLowerCase().trim();
|
||||
return POPULAR_LANGS
|
||||
.map(lang => {
|
||||
const match = LANG_CODES.find(lc => lc.label.toLowerCase() === lang.toLowerCase());
|
||||
return match ? { lang, code: match.code } : null;
|
||||
})
|
||||
.filter(item => item && !selectedCodes.has(item.code) && (!q || item.lang.toLowerCase().includes(q) || item.code.includes(q)));
|
||||
}, [query, selectedCodes]);
|
||||
|
||||
return (
|
||||
<div className="multi-lang" ref={dropRef}>
|
||||
<div className="multi-lang__chips">
|
||||
{selected.map(s => (
|
||||
<span key={s.code} className="multi-lang__chip">
|
||||
<Globe size={9} />
|
||||
<span>{s.code}</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="multi-lang__chip-x"
|
||||
onClick={() => removeLang(s.code)}
|
||||
aria-label={`Remove ${s.lang}`}
|
||||
>
|
||||
<X size={8} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="multi-lang__add"
|
||||
onClick={() => setDropOpen(!dropOpen)}
|
||||
title="Add language"
|
||||
>
|
||||
<Plus size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selected.length > 0 && (
|
||||
<div className="multi-lang__summary">
|
||||
{selected.length} language{selected.length > 1 ? 's' : ''} selected
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dropOpen && (
|
||||
<div className="multi-lang__drop">
|
||||
<div className="multi-lang__search">
|
||||
<Search size={10} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Search languages…"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="multi-lang__list">
|
||||
{popularFiltered.length > 0 && (
|
||||
<>
|
||||
<div className="multi-lang__section">Popular</div>
|
||||
{popularFiltered.map(item => (
|
||||
<button
|
||||
key={item.code}
|
||||
type="button"
|
||||
className="multi-lang__option"
|
||||
onClick={() => addLang(item.lang, item.code)}
|
||||
>
|
||||
<span className="multi-lang__option-code">{item.code}</span>
|
||||
<span>{item.lang}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div className="multi-lang__section">All Languages</div>
|
||||
{filteredLangs.slice(0, 50).map(lc => (
|
||||
<button
|
||||
key={lc.code}
|
||||
type="button"
|
||||
className="multi-lang__option"
|
||||
onClick={() => addLang(lc.label, lc.code)}
|
||||
>
|
||||
<span className="multi-lang__option-code">{lc.code}</span>
|
||||
<span>{lc.label}</span>
|
||||
</button>
|
||||
))}
|
||||
{filteredLangs.length > 50 && (
|
||||
<div className="multi-lang__more">
|
||||
+{filteredLangs.length - 50} more — type to narrow
|
||||
</div>
|
||||
)}
|
||||
{filteredLangs.length === 0 && popularFiltered.length === 0 && (
|
||||
<div className="multi-lang__empty">No matches</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,7 @@ export default function Sidebar(props) {
|
||||
saveProject, loadProject, deleteProject,
|
||||
handleSelectProfile, handleDeleteProfile, handleOpenVoiceProfile,
|
||||
handleUnlockProfile, handleLockProfile, handlePreviewVoice,
|
||||
onOpenVoicePreview,
|
||||
restoreHistory, restoreDubHistory,
|
||||
handleSaveHistoryAsProfile,
|
||||
handleNativeExport, revealInFolder,
|
||||
@@ -260,6 +261,15 @@ export default function Sidebar(props) {
|
||||
<button className="history-action-btn" onClick={(e) => { e.stopPropagation(); handleSelectProfile(proj); }}>
|
||||
<Check size={10} /> Select
|
||||
</button>
|
||||
{onOpenVoicePreview && (
|
||||
<button
|
||||
className="history-action-btn accent"
|
||||
onClick={(e) => { e.stopPropagation(); onOpenVoicePreview(proj.id); }}
|
||||
title="Open interactive voice preview"
|
||||
>
|
||||
<Volume2 size={10} /> Try
|
||||
</button>
|
||||
)}
|
||||
{proj.is_locked ? (
|
||||
<button className="history-action-btn accent history-action-icon" onClick={(e) => { e.stopPropagation(); handleUnlockProfile(proj.id); }} title="Unlock">
|
||||
<Unlock size={10} />
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
.voice-preview {
|
||||
position: fixed;
|
||||
bottom: 56px;
|
||||
right: 16px;
|
||||
z-index: 900;
|
||||
width: 320px;
|
||||
background: var(--chrome-bg);
|
||||
border: 1px solid var(--chrome-border-strong);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: voice-preview-in 0.2s ease-out;
|
||||
}
|
||||
@keyframes voice-preview-in {
|
||||
from { opacity: 0; transform: translateY(12px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.voice-preview__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
}
|
||||
.voice-preview__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
.voice-preview__close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--chrome-fg-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.voice-preview__close:hover {
|
||||
background: var(--chrome-hover-bg);
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
|
||||
.voice-preview__body {
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.voice-preview__select {
|
||||
font-size: 0.78rem;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.voice-preview__text {
|
||||
font-size: 0.78rem;
|
||||
padding: 8px;
|
||||
resize: none;
|
||||
line-height: 1.4;
|
||||
min-height: 48px;
|
||||
}
|
||||
.voice-preview__audio {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.voice-preview__audio::-webkit-media-controls-panel {
|
||||
background: var(--chrome-hover-bg);
|
||||
}
|
||||
|
||||
.voice-preview__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 14px 10px;
|
||||
border-top: 1px solid var(--chrome-border);
|
||||
}
|
||||
.voice-preview__hint {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
color: var(--chrome-fg-dim);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { Volume2, Play, Square, Loader, X, Mic } from 'lucide-react';
|
||||
import { generateSpeech } from '../api/generate';
|
||||
import { PRESETS } from '../utils/constants';
|
||||
import { Button } from '../ui';
|
||||
import './VoicePreview.css';
|
||||
|
||||
/**
|
||||
* VoicePreview — floating "try a voice" card.
|
||||
*
|
||||
* Opens as a bottom-right popover. User picks a voice profile, types a
|
||||
* sentence, hits Play → hears TTS output instantly (8 inference steps for
|
||||
* speed). The result is disposable — it doesn't save to history.
|
||||
*/
|
||||
const DEFAULT_TEXT = 'Hello! This is a preview of how I sound in this voice.';
|
||||
|
||||
export default function VoicePreview({
|
||||
open,
|
||||
onClose,
|
||||
profiles = [],
|
||||
initialProfileId = '',
|
||||
fileToMediaUrl,
|
||||
}) {
|
||||
const [text, setText] = useState(DEFAULT_TEXT);
|
||||
const [voiceId, setVoiceId] = useState(initialProfileId);
|
||||
const [audioUrl, setAudioUrl] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const audioRef = useRef(null);
|
||||
const abortRef = useRef(null);
|
||||
|
||||
// Sync initialProfileId when it changes (e.g. clicking preview on a different profile)
|
||||
React.useEffect(() => {
|
||||
if (initialProfileId) setVoiceId(initialProfileId);
|
||||
}, [initialProfileId]);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!text.trim()) return;
|
||||
setLoading(true);
|
||||
setAudioUrl(null);
|
||||
|
||||
const ac = new AbortController();
|
||||
abortRef.current = ac;
|
||||
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('text', text);
|
||||
fd.append('num_step', '8'); // fast preview
|
||||
fd.append('guidance_scale', '2.0');
|
||||
fd.append('speed', '1.0');
|
||||
fd.append('denoise', 'true');
|
||||
fd.append('postprocess_output', 'true');
|
||||
|
||||
let profileId = voiceId;
|
||||
let instruct = '';
|
||||
|
||||
if (profileId.startsWith('preset:')) {
|
||||
const pr = PRESETS.find(p => p.id === profileId.replace('preset:', ''));
|
||||
if (pr) {
|
||||
instruct = Object.values(pr.attrs).filter(v => v !== 'Auto').join(', ');
|
||||
}
|
||||
profileId = '';
|
||||
} else {
|
||||
const match = profiles.find(p => p.id === profileId);
|
||||
if (match?.instruct) instruct = match.instruct;
|
||||
}
|
||||
|
||||
if (profileId) fd.append('profile_id', profileId);
|
||||
if (instruct) fd.append('instruct', instruct);
|
||||
|
||||
const res = await generateSpeech(fd, { signal: ac.signal });
|
||||
if (!res.ok) throw new Error(`TTS failed: ${res.status}`);
|
||||
|
||||
const blob = await res.blob();
|
||||
const urls = await fileToMediaUrl(blob, null);
|
||||
setAudioUrl(urls.audioUrl);
|
||||
|
||||
// Auto-play
|
||||
setTimeout(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.play().catch(() => {});
|
||||
}
|
||||
}, 50);
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('Preview generation failed:', err);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [text, voiceId, profiles, fileToMediaUrl]);
|
||||
|
||||
const handleStop = () => {
|
||||
abortRef.current?.abort();
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
}
|
||||
setPlaying(false);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="voice-preview">
|
||||
<div className="voice-preview__head">
|
||||
<span className="voice-preview__title">
|
||||
<Volume2 size={13} /> Voice Preview
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="voice-preview__close"
|
||||
onClick={onClose}
|
||||
aria-label="Close preview"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="voice-preview__body">
|
||||
<select
|
||||
className="input-base voice-preview__select"
|
||||
value={voiceId}
|
||||
onChange={e => setVoiceId(e.target.value)}
|
||||
>
|
||||
<option value="">Default voice</option>
|
||||
{profiles.filter(p => !p.instruct).length > 0 && (
|
||||
<optgroup label="Clone Profiles">
|
||||
{profiles.filter(p => !p.instruct).map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{profiles.filter(p => !!p.instruct).length > 0 && (
|
||||
<optgroup label="Designed Voices">
|
||||
{profiles.filter(p => !!p.instruct).map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{PRESETS.length > 0 && (
|
||||
<optgroup label="Presets">
|
||||
{PRESETS.map(p => (
|
||||
<option key={p.id} value={`preset:${p.id}`}>{p.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
|
||||
<textarea
|
||||
className="input-base voice-preview__text"
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Type something to hear…"
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
{audioUrl && (
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={audioUrl}
|
||||
className="voice-preview__audio"
|
||||
controls
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => setPlaying(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="voice-preview__foot">
|
||||
{loading ? (
|
||||
<Button variant="ghost" size="sm" onClick={handleStop} leading={<Square size={10} />}>
|
||||
Stop
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleGenerate}
|
||||
disabled={!text.trim()}
|
||||
loading={loading}
|
||||
leading={!loading && <Play size={10} />}
|
||||
>
|
||||
{audioUrl ? 'Regenerate' : 'Preview'}
|
||||
</Button>
|
||||
)}
|
||||
<span className="voice-preview__hint">8 steps · fast preview</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,50 @@
|
||||
@layer theme, base, components, utilities;
|
||||
@import "tailwindcss/theme.css" layer(theme);
|
||||
@import "tailwindcss/utilities.css" layer(utilities);
|
||||
|
||||
/* ── Map design tokens → Tailwind v4 theme ──────────────────────────── */
|
||||
@theme {
|
||||
/* Colors — semantic */
|
||||
--color-fg: #ebdbb2;
|
||||
--color-fg-muted: #a89984;
|
||||
--color-fg-subtle: #7c6f64;
|
||||
--color-fg-inverse: #1d2021;
|
||||
|
||||
--color-bg: #1d2021;
|
||||
--color-bg-elev-1: rgba(50, 48, 47, 0.85);
|
||||
--color-bg-elev-2: rgba(0, 0, 0, 0.30);
|
||||
--color-bg-elev-3: rgba(0, 0, 0, 0.18);
|
||||
|
||||
--color-border: rgba(255, 255, 255, 0.07);
|
||||
--color-border-strong: rgba(255, 255, 255, 0.15);
|
||||
--color-border-warm: rgba(243, 165, 182, 0.08);
|
||||
|
||||
--color-brand: #d3869b;
|
||||
--color-brand-hover: #b16286;
|
||||
--color-brand-glow: rgba(211, 134, 155, 0.4);
|
||||
|
||||
--color-accent: #fabd2f;
|
||||
--color-success: #8ec07c;
|
||||
--color-warn: #fe8019;
|
||||
--color-danger: #fb4934;
|
||||
--color-info: #83a598;
|
||||
|
||||
--color-chrome-bg: #0f1011;
|
||||
--color-chrome-fg: #d5c4a1;
|
||||
|
||||
/* Radius */
|
||||
--radius-xs: 2px;
|
||||
--radius-sm: 3px;
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 6px;
|
||||
--radius-xl: 10px;
|
||||
|
||||
/* Fonts */
|
||||
--font-sans: 'Inter Variable', 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
--font-serif: 'Source Serif 4 Variable', 'Source Serif 4', Georgia, serif;
|
||||
}
|
||||
|
||||
/* Font imports removed — the chrome token system uses only system monospace
|
||||
(ui-monospace, SFMono-Regular, Menlo, Consolas). Fraunces / Nunito / Outfit
|
||||
/ Inter are no longer referenced by any rule. */
|
||||
@@ -1192,6 +1239,56 @@ button:focus:not(:focus-visible) { outline: none; }
|
||||
background-size: 6px 1px;
|
||||
}
|
||||
|
||||
/* ── Launchpad extracted layout classes ──────────────────── */
|
||||
.lp-hero__row {
|
||||
display: flex; justify-content: space-between; align-items: flex-start;
|
||||
gap: 24px; flex-wrap: wrap;
|
||||
}
|
||||
.lp-hero__col { max-width: 640px; }
|
||||
.lp-hero__kicker-row {
|
||||
display: flex; align-items: center; gap: 10px; margin-bottom: 12px;
|
||||
}
|
||||
.lp-hero__wave-group {
|
||||
display: flex; align-items: center; gap: 2px; height: 22px;
|
||||
}
|
||||
.lp-section__grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.lp-col { display: flex; flex-direction: column; gap: 8px; }
|
||||
.lp-proj-icon--clone { background: rgba(211,134,155,0.1); }
|
||||
.lp-proj-icon--design { background: rgba(142,192,124,0.1); }
|
||||
.lp-proj-icon--locked { background: rgba(184,187,38,0.1); }
|
||||
.lp-proj-icon--dub { background: rgba(254,128,25,0.1); overflow: hidden; }
|
||||
.lp-proj-meta--italic { font-style: italic; }
|
||||
.lp-locked-badge {
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: var(--chrome-label-size);
|
||||
letter-spacing: var(--chrome-label-track);
|
||||
padding: 1px 7px;
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
background: color-mix(in srgb, #b8bb26 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #b8bb26 40%, transparent);
|
||||
color: #b8bb26; font-weight: 600;
|
||||
}
|
||||
.lp-empty {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
position: relative; z-index: 1;
|
||||
}
|
||||
.lp-empty__inner { text-align: center; max-width: 360px; }
|
||||
.lp-empty__bars {
|
||||
display: flex; justify-content: center; gap: 3px;
|
||||
margin-bottom: 16px; opacity: 0.3;
|
||||
}
|
||||
.lp-empty__hint {
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: 0.8rem; color: var(--chrome-fg-muted); margin: 0;
|
||||
}
|
||||
.lp-dub-thumb {
|
||||
width: 100%; height: 100%; object-fit: cover;
|
||||
border-radius: inherit; display: block;
|
||||
}
|
||||
|
||||
/* Project rows — chrome-radius pills so the launchpad project list
|
||||
rhymes with the Projects page cards. Dropped the squircle corners,
|
||||
the translate-X hover, and the icon rotation/scale micro-animation
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
// Fonts load before tokens so --font-* can resolve immediately (no FOUT).
|
||||
// Inter ships as a single variable file; Source Serif 4 too. Plex Mono has
|
||||
// no variable build so we pull the three weights we use (400/500/600).
|
||||
import '@fontsource-variable/inter';
|
||||
import '@fontsource/ibm-plex-mono/400.css';
|
||||
import '@fontsource/ibm-plex-mono/500.css';
|
||||
import '@fontsource/ibm-plex-mono/600.css';
|
||||
import '@fontsource-variable/source-serif-4';
|
||||
import './ui';
|
||||
import './index.css';
|
||||
import App from './App.jsx';
|
||||
import { installConsoleCapture } from './utils/consoleBuffer.js';
|
||||
|
||||
installConsoleCapture();
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 10_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function bootstrapApp() {
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,11 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
// Fonts load before tokens so --font-* can resolve immediately (no FOUT).
|
||||
// Inter ships as a single variable file; Source Serif 4 too. Plex Mono has
|
||||
// no variable build so we pull the three weights we use (400/500/600).
|
||||
import '@fontsource-variable/inter'
|
||||
import '@fontsource/ibm-plex-mono/400.css'
|
||||
import '@fontsource/ibm-plex-mono/500.css'
|
||||
import '@fontsource/ibm-plex-mono/600.css'
|
||||
import '@fontsource-variable/source-serif-4'
|
||||
import './ui' // design-system tokens load first so index.css can override if needed
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
import { installConsoleCapture } from './utils/consoleBuffer.js'
|
||||
if (import.meta.env.DEV && !window.__vite_plugin_react_preamble_installed__) {
|
||||
const RefreshRuntime = await import('/@react-refresh');
|
||||
RefreshRuntime.default.injectIntoGlobalHook(window);
|
||||
window.$RefreshReg$ = () => {};
|
||||
window.$RefreshSig$ = () => (type) => type;
|
||||
window.__vite_plugin_react_preamble_installed__ = true;
|
||||
}
|
||||
|
||||
installConsoleCapture();
|
||||
const { bootstrapApp } = await import('./main-app.jsx');
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
bootstrapApp();
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Panel, Button, Badge, Tabs } from '../ui';
|
||||
import { listJobs } from '../api/engines';
|
||||
import BatchAddDialog from '../components/BatchAddDialog';
|
||||
import './BatchQueue.css';
|
||||
|
||||
/**
|
||||
@@ -30,6 +31,7 @@ export default function BatchQueue({ onBack }) {
|
||||
const [tab, setTab] = useState('active');
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -61,6 +63,9 @@ export default function BatchQueue({ onBack }) {
|
||||
<Button variant="subtle" size="sm" onClick={reload} loading={loading} leading={<RefreshCw size={11} />}>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={() => setAddOpen(true)} leading={<Plus size={11} />}>
|
||||
Add Videos
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
@@ -90,6 +95,15 @@ export default function BatchQueue({ onBack }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Plus({ size }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function JobCard({ job }) {
|
||||
const meta = useMemo(() => {
|
||||
try { return JSON.parse(job.meta_json || '{}'); } catch { return {}; }
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Heart, Copy, ExternalLink, ArrowLeft, Check } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { Button } from '../ui';
|
||||
|
||||
const METHODS = [
|
||||
{
|
||||
id: 'github',
|
||||
label: 'GitHub Sponsors',
|
||||
description: 'Recurring or one-time — directly through GitHub.',
|
||||
url: 'https://github.com/sponsors/omnivoice-studio',
|
||||
icon: '🐙',
|
||||
type: 'link',
|
||||
},
|
||||
{
|
||||
id: 'patreon',
|
||||
label: 'Patreon',
|
||||
description: 'Monthly support with early access perks.',
|
||||
url: 'https://patreon.com/omnivoicestudio',
|
||||
icon: '🎨',
|
||||
type: 'link',
|
||||
},
|
||||
{
|
||||
id: 'kofi',
|
||||
label: 'Ko-fi',
|
||||
description: 'Buy the team a coffee. No account needed.',
|
||||
url: 'https://ko-fi.com/omnivoicestudio',
|
||||
icon: '☕',
|
||||
type: 'link',
|
||||
},
|
||||
{
|
||||
id: 'btc',
|
||||
label: 'Bitcoin',
|
||||
description: 'Native BTC — any amount.',
|
||||
address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh',
|
||||
icon: '₿',
|
||||
type: 'crypto',
|
||||
network: 'Bitcoin (BTC)',
|
||||
protocol: 'bitcoin',
|
||||
},
|
||||
{
|
||||
id: 'eth',
|
||||
label: 'Ethereum',
|
||||
description: 'ETH or ERC-20 tokens.',
|
||||
address: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F',
|
||||
icon: 'Ξ',
|
||||
type: 'crypto',
|
||||
network: 'Ethereum (ETH / ERC-20)',
|
||||
protocol: 'ethereum',
|
||||
},
|
||||
{
|
||||
id: 'sol',
|
||||
label: 'Solana',
|
||||
description: 'SOL or SPL tokens.',
|
||||
address: '7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV',
|
||||
icon: '◎',
|
||||
type: 'crypto',
|
||||
network: 'Solana (SOL)',
|
||||
protocol: 'solana',
|
||||
},
|
||||
];
|
||||
|
||||
function CryptoCard({ method, delay }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(method.address);
|
||||
setCopied(true);
|
||||
toast.success(`${method.label} address copied`);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error('Copy failed');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className="group relative flex items-start gap-5 rounded-3xl border border-white/10 bg-black/30 px-5 py-5 shadow-lg backdrop-blur-xl transition-all duration-300 hover:-translate-y-1 hover:border-white/20 hover:bg-black/40 hover:shadow-[0_8px_30px_rgba(211,134,155,0.15)] sm:px-7"
|
||||
style={{ animation: `fade-in-up 0.6s ease-out ${delay}ms both` }}
|
||||
>
|
||||
<div className="absolute inset-0 rounded-3xl bg-gradient-to-br from-white/5 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100 pointer-events-none" />
|
||||
|
||||
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-white/5 text-xl shadow-inner border border-white/5 transition-transform duration-300 group-hover:scale-110">
|
||||
{method.icon}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5 z-10">
|
||||
<div className="text-lg font-medium tracking-tight text-white/90 group-hover:text-white transition-colors">{method.label}</div>
|
||||
<div className="text-sm leading-relaxed text-white/60">{method.description}</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<code className="min-w-0 max-w-[180px] flex-1 overflow-hidden text-ellipsis whitespace-nowrap rounded-xl bg-black/40 px-3 py-2 text-[11px] text-white/50 border border-white/5 font-mono sm:text-xs sm:max-w-[280px]">
|
||||
{method.address}
|
||||
</code>
|
||||
<div className="flex shrink-0 gap-1.5">
|
||||
<button
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-white/5 text-white/50 transition-all duration-200 hover:bg-white/15 hover:text-white hover:scale-105 active:scale-95"
|
||||
onClick={handleCopy}
|
||||
title="Copy address"
|
||||
>
|
||||
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
|
||||
</button>
|
||||
{method.protocol && (
|
||||
<button
|
||||
className="flex h-8 px-3 shrink-0 items-center justify-center gap-1.5 rounded-xl bg-white/5 text-[11px] font-medium tracking-wide text-white/70 transition-all duration-200 hover:bg-[#d3869b]/20 hover:text-[#d3869b] hover:border-[#d3869b]/30 hover:scale-105 active:scale-95 border border-white/5"
|
||||
onClick={() => window.open(`${method.protocol}:${method.address}`, '_self')}
|
||||
title="Open in desktop wallet"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
<span className="hidden sm:inline">Open</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="mt-1 text-[0.65rem] font-semibold tracking-wider uppercase text-white/30">{method.network}</span>
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:block z-10 shrink-0 self-center rounded-xl bg-white p-2 border border-white/20 transition-transform duration-300 group-hover:scale-105 shadow-md">
|
||||
<QRCodeSVG
|
||||
value={`${method.protocol || ''}:${method.address}`}
|
||||
size={64}
|
||||
bgColor="#ffffff"
|
||||
fgColor="#000000"
|
||||
level="M"
|
||||
includeMargin={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkCard({ method, delay }) {
|
||||
return (
|
||||
<div
|
||||
className="group relative flex items-center gap-5 rounded-3xl border border-white/10 bg-black/30 px-5 py-5 shadow-lg backdrop-blur-xl transition-all duration-300 hover:-translate-y-1 hover:border-white/20 hover:bg-black/40 hover:shadow-[0_8px_30px_rgba(211,134,155,0.15)] sm:px-7"
|
||||
style={{ animation: `fade-in-up 0.6s ease-out ${delay}ms both` }}
|
||||
>
|
||||
<div className="absolute inset-0 rounded-3xl bg-gradient-to-br from-white/5 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100 pointer-events-none" />
|
||||
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-white/5 text-xl shadow-inner border border-white/5 transition-transform duration-300 group-hover:scale-110">
|
||||
{method.icon}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1 z-10">
|
||||
<div className="text-lg font-medium tracking-tight text-white/90 group-hover:text-white transition-colors">{method.label}</div>
|
||||
<div className="text-sm leading-relaxed text-white/60">{method.description}</div>
|
||||
</div>
|
||||
<button
|
||||
className="z-10 flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/5 text-white/50 transition-all duration-200 hover:border-[#d3869b]/40 hover:bg-[#d3869b]/10 hover:text-[#d3869b] hover:scale-105 active:scale-95"
|
||||
onClick={() => window.open(method.url, '_blank', 'noopener,noreferrer')}
|
||||
title={`Open ${method.label}`}
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DonatePage({ onBack }) {
|
||||
const links = METHODS.filter(m => m.type === 'link');
|
||||
const crypto = METHODS.filter(m => m.type === 'crypto');
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 flex-col overflow-y-auto bg-[#050505]">
|
||||
{/* Abstract Background Layer (Pure CSS + SVG) */}
|
||||
<div className="fixed inset-0 pointer-events-none overflow-hidden z-0">
|
||||
{/* Glowing Orbs */}
|
||||
<div className="absolute -top-[200px] -left-[200px] w-[800px] h-[800px] rounded-full bg-[radial-gradient(circle,rgba(168,85,247,0.12)_0%,rgba(168,85,247,0)_70%)] animate-[drift_20s_infinite_linear]" />
|
||||
<div className="absolute -bottom-[100px] -right-[100px] w-[600px] h-[600px] rounded-full bg-[radial-gradient(circle,rgba(236,72,153,0.08)_0%,rgba(236,72,153,0)_70%)] animate-[drift_15s_infinite_linear_reverse]" />
|
||||
<div className="absolute top-[40%] left-[50%] w-[900px] h-[900px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[radial-gradient(circle,rgba(99,102,241,0.08)_0%,rgba(99,102,241,0)_70%)] animate-[drift_25s_infinite_ease-in-out_alternate]" />
|
||||
|
||||
{/* Subtle SVG Voice Waveforms */}
|
||||
<div className="absolute inset-0 opacity-40 mix-blend-screen overflow-hidden">
|
||||
<svg className="absolute w-[200%] h-[60%] top-[20%] left-0 animate-[wave-move_15s_linear_infinite]" preserveAspectRatio="none" viewBox="0 0 2000 1000" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="none" stroke="url(#wave-grad-1)" strokeWidth="3" d="M0,500 C250,300 750,700 1000,500 C1250,300 1750,700 2000,500" />
|
||||
<path fill="none" stroke="url(#wave-grad-2)" strokeWidth="2" d="M0,500 C300,650 700,350 1000,500 C1300,650 1700,350 2000,500" />
|
||||
<path fill="none" stroke="url(#wave-grad-3)" strokeWidth="1.5" d="M0,500 C400,400 600,600 1000,500 C1400,400 1600,600 2000,500" />
|
||||
<defs>
|
||||
<linearGradient id="wave-grad-1" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#d3869b" stopOpacity="0" />
|
||||
<stop offset="25%" stopColor="#d3869b" stopOpacity="0.8" />
|
||||
<stop offset="50%" stopColor="#d3869b" stopOpacity="0" />
|
||||
<stop offset="75%" stopColor="#d3869b" stopOpacity="0.8" />
|
||||
<stop offset="100%" stopColor="#d3869b" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient id="wave-grad-2" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#818cf8" stopOpacity="0" />
|
||||
<stop offset="25%" stopColor="#818cf8" stopOpacity="0.6" />
|
||||
<stop offset="50%" stopColor="#818cf8" stopOpacity="0" />
|
||||
<stop offset="75%" stopColor="#818cf8" stopOpacity="0.6" />
|
||||
<stop offset="100%" stopColor="#818cf8" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient id="wave-grad-3" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#f472b6" stopOpacity="0" />
|
||||
<stop offset="25%" stopColor="#f472b6" stopOpacity="0.5" />
|
||||
<stop offset="50%" stopColor="#f472b6" stopOpacity="0" />
|
||||
<stop offset="75%" stopColor="#f472b6" stopOpacity="0.5" />
|
||||
<stop offset="100%" stopColor="#f472b6" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Noise Texture */}
|
||||
<div className="absolute inset-0 opacity-[0.04]" style={{ backgroundImage: "url('data:image/svg+xml,%3Csvg viewBox=%220 0 200 200%22 xmlns=%22http://www.w3.org/2000/svg%22%3E%3Cfilter id=%22noiseFilter%22%3E%3CfeTurbulence type=%22fractalNoise%22 baseFrequency=%220.65%22 numOctaves=%223%22 stitchTiles=%22stitch%22/%3E%3C/filter%3E%3Crect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23noiseFilter)%22/%3E%3C/svg%3E')" }} />
|
||||
</div>
|
||||
|
||||
{/* Content Layer */}
|
||||
<div className="relative z-10 flex flex-col px-6 pb-12 sm:px-10 h-full">
|
||||
<div className="shrink-0 py-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onBack}
|
||||
leading={<ArrowLeft size={16} />}
|
||||
className="text-white/70 hover:text-white hover:bg-white/10 backdrop-blur-md rounded-xl"
|
||||
>
|
||||
Back to Studio
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex w-full max-w-[680px] flex-col gap-12">
|
||||
{/* Hero Section */}
|
||||
<div className="pt-8 text-center animate-fade-in">
|
||||
<div className="relative mx-auto mb-8 flex h-20 w-20 items-center justify-center rounded-3xl bg-gradient-to-br from-white/10 to-transparent border border-white/10 shadow-[0_0_40px_rgba(211,134,155,0.2)] backdrop-blur-xl">
|
||||
<Heart size={36} className="text-[#f3a5b6] drop-shadow-[0_0_15px_rgba(243,165,182,0.8)] animate-[pulse_3s_ease-in-out_infinite] [fill:#f3a5b6]" />
|
||||
</div>
|
||||
|
||||
<h2 className="mx-0 my-4 font-serif text-4xl font-semibold tracking-tight text-transparent bg-clip-text bg-gradient-to-r from-pink-200 via-purple-200 to-indigo-200 sm:text-5xl">
|
||||
Support OmniVoice
|
||||
</h2>
|
||||
|
||||
<p className="mx-auto mt-6 max-w-[500px] text-lg font-light leading-relaxed text-white/60">
|
||||
OmniVoice is free, open-source, and runs entirely on your hardware.
|
||||
If it brings value to your workflow, consider supporting the core team.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-10">
|
||||
{/* Platforms */}
|
||||
<section>
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-transparent to-white/10" />
|
||||
<h3 className="text-xs font-semibold uppercase tracking-widest text-white/40">Platforms</h3>
|
||||
<div className="h-px flex-1 bg-gradient-to-l from-transparent to-white/10" />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{links.map((m, i) => <LinkCard key={m.id} method={m} delay={100 + i * 100} />)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Cryptocurrency */}
|
||||
<section>
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-transparent to-white/10" />
|
||||
<h3 className="text-xs font-semibold uppercase tracking-widest text-white/40">Cryptocurrency</h3>
|
||||
<div className="h-px flex-1 bg-gradient-to-l from-transparent to-white/10" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{crypto.map((m, i) => <CryptoCard key={m.id} method={m} delay={400 + i * 100} />)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 mb-12 text-center">
|
||||
<p className="text-sm font-medium tracking-wide text-white/40">
|
||||
Every contribution helps push the boundaries of local AI. ♥
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx="true">{`
|
||||
@keyframes fade-in-up {
|
||||
0% { opacity: 0; transform: translateY(15px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes wave-move {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
@keyframes drift {
|
||||
0% { transform: rotate(0deg) translate(0, 0); }
|
||||
33% { transform: rotate(120deg) translate(20px, 40px); }
|
||||
66% { transform: rotate(240deg) translate(-20px, -40px); }
|
||||
100% { transform: rotate(360deg) translate(0, 0); }
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fade-in-up 0.8s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -378,6 +378,24 @@
|
||||
.dub-settings-field--style { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
/* Multi-lang mode toggle + picker */
|
||||
.dub-settings-field--multi {
|
||||
grid-column: 1 / -1;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--chrome-border);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.dub-multi-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.68rem;
|
||||
color: var(--chrome-fg-muted);
|
||||
cursor: pointer;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.dub-multi-toggle input { accent-color: var(--chrome-accent); cursor: pointer; }
|
||||
|
||||
/* Collapsed-summary strip that replaces the full grid once language/engine/
|
||||
quality are set. Keeps the primary Translate All + Clean Up CTAs inline. */
|
||||
.dub-settings-summary {
|
||||
|
||||
@@ -20,6 +20,7 @@ import toast from 'react-hot-toast';
|
||||
import { Button, Segmented, Badge, Progress } from '../ui';
|
||||
import GlossaryPanel from '../components/GlossaryPanel';
|
||||
import ExportModal from '../components/ExportModal';
|
||||
import MultiLangPicker from '../components/MultiLangPicker';
|
||||
import './DubTab.css';
|
||||
|
||||
const DubSegmentTable = lazy(() => import('../components/DubSegmentTable'));
|
||||
@@ -93,6 +94,10 @@ export default function DubTab(props) {
|
||||
const [previewMode, setPreviewMode] = useState('original'); // 'original' | 'dubbed'
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
|
||||
// Multi-language mode
|
||||
const [multiLangMode, setMultiLangMode] = useState(false);
|
||||
const [multiLangs, setMultiLangs] = useState([]);
|
||||
|
||||
// Live ETA while generating — elapsed ticks each second; remaining is
|
||||
// extrapolated from the current/total rate so it's only meaningful once
|
||||
// at least one segment has rendered and ~2s of clock has passed.
|
||||
@@ -641,6 +646,23 @@ export default function DubTab(props) {
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div className="dub-settings-field dub-settings-field--multi">
|
||||
<label className="dub-multi-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={multiLangMode}
|
||||
onChange={e => setMultiLangMode(e.target.checked)}
|
||||
/>
|
||||
<span>Multi-lang</span>
|
||||
</label>
|
||||
{multiLangMode && (
|
||||
<MultiLangPicker
|
||||
selected={multiLangs}
|
||||
onChange={setMultiLangs}
|
||||
disabled={dubStep === 'generating'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="dub-settings-field dub-settings-field--iso">
|
||||
<div className="label-row">ISO</div>
|
||||
<select
|
||||
|
||||
@@ -13,10 +13,7 @@ function DubThumb({ jobId, fallback }) {
|
||||
alt=""
|
||||
onError={() => setFailed(true)}
|
||||
loading="lazy"
|
||||
style={{
|
||||
width: '100%', height: '100%', objectFit: 'cover',
|
||||
borderRadius: 'inherit', display: 'block',
|
||||
}}
|
||||
className="lp-dub-thumb"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -79,10 +76,10 @@ export default function Launchpad({
|
||||
|
||||
{/* Hero */}
|
||||
<div className="lp-hero">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 24, flexWrap: 'wrap' }}>
|
||||
<div style={{ maxWidth: 640 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '2px', height: '22px' }}>
|
||||
<div className="lp-hero__row">
|
||||
<div className="lp-hero__col">
|
||||
<div className="lp-hero__kicker-row">
|
||||
<div className="lp-hero__wave-group">
|
||||
{[10, 14, 8, 16, 12, 14, 9, 12].map((h, i) => (
|
||||
<span
|
||||
key={i}
|
||||
@@ -137,15 +134,15 @@ export default function Launchpad({
|
||||
{/* Recent Projects */}
|
||||
{(profiles.length > 0 || studioProjects.length > 0) && (
|
||||
<div className="lp-section">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '20px' }}>
|
||||
<div className="lp-section__grid">
|
||||
{/* Cloned voices */}
|
||||
{cloneProfiles.length > 0 && (
|
||||
<div>
|
||||
<div className="lp-section-title"><Fingerprint size={12} color="#d3869b" /> Cloned Voices</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div className="lp-col">
|
||||
{cloneProfiles.map(p => (
|
||||
<div key={p.id} className="lp-project-card">
|
||||
<div className="proj-icon" style={{ background: 'rgba(211,134,155,0.1)' }}><Fingerprint size={14} color="#d3869b" /></div>
|
||||
<div className="proj-icon lp-proj-icon--clone"><Fingerprint size={14} color="#d3869b" /></div>
|
||||
<div className="proj-info">
|
||||
<div className="proj-name">{p.name}</div>
|
||||
<div className="proj-meta">{p.ref_audio_path}</div>
|
||||
@@ -161,26 +158,17 @@ export default function Launchpad({
|
||||
{designProfiles.length > 0 && (
|
||||
<div>
|
||||
<div className="lp-section-title"><Wand2 size={12} color="#8ec07c" /> Designed Voices</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div className="lp-col">
|
||||
{designProfiles.map(p => (
|
||||
<div key={p.id} className="lp-project-card">
|
||||
<div className="proj-icon" style={{ background: p.is_locked ? 'rgba(184,187,38,0.1)' : 'rgba(142,192,124,0.1)' }}>
|
||||
<div className={`proj-icon ${p.is_locked ? 'lp-proj-icon--locked' : 'lp-proj-icon--design'}`}>
|
||||
{p.is_locked ? <Lock size={14} color="#b8bb26" /> : <Wand2 size={14} color="#8ec07c" />}
|
||||
</div>
|
||||
<div className="proj-info">
|
||||
<div className="proj-name">{p.name}</div>
|
||||
<div className="proj-meta" style={{ fontStyle: 'italic' }}>{p.instruct}</div>
|
||||
<div className="proj-meta lp-proj-meta--italic">{p.instruct}</div>
|
||||
</div>
|
||||
{p.is_locked && <span style={{
|
||||
fontFamily: 'var(--chrome-font-mono)',
|
||||
fontSize: 'var(--chrome-label-size)',
|
||||
letterSpacing: 'var(--chrome-label-track)',
|
||||
padding: '1px 7px',
|
||||
borderRadius: 'var(--chrome-radius-pill)',
|
||||
background: 'color-mix(in srgb, #b8bb26 10%, transparent)',
|
||||
border: '1px solid color-mix(in srgb, #b8bb26 40%, transparent)',
|
||||
color: '#b8bb26', fontWeight: 600,
|
||||
}}>LOCKED</span>}
|
||||
{p.is_locked && <span className="lp-locked-badge">LOCKED</span>}
|
||||
<button className="proj-action" onClick={() => { setMode('design'); handleSelectProfile(p); }}>Open</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -192,10 +180,10 @@ export default function Launchpad({
|
||||
{studioProjects.length > 0 && (
|
||||
<div>
|
||||
<div className="lp-section-title"><Film size={12} color="#fe8019" /> Dubbing Projects</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div className="lp-col">
|
||||
{studioProjects.map(proj => (
|
||||
<div key={proj.id} className="lp-project-card">
|
||||
<div className="proj-icon" style={{ background: 'rgba(254,128,25,0.1)', overflow: 'hidden' }}>
|
||||
<div className="proj-icon lp-proj-icon--dub">
|
||||
<DubThumb
|
||||
jobId={proj.state?.dubJobId || proj.id}
|
||||
fallback={<Film size={14} color="#fe8019" />}
|
||||
@@ -217,9 +205,9 @@ export default function Launchpad({
|
||||
|
||||
{/* Empty state */}
|
||||
{profiles.length === 0 && studioProjects.length === 0 && (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', zIndex: 1 }}>
|
||||
<div style={{ textAlign: 'center', maxWidth: 360 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '3px', marginBottom: '16px', opacity: 0.3 }}>
|
||||
<div className="lp-empty">
|
||||
<div className="lp-empty__inner">
|
||||
<div className="lp-empty__bars">
|
||||
{[8, 14, 22, 18, 26, 14, 20, 10, 16].map((h, i) => (
|
||||
<span
|
||||
key={i}
|
||||
@@ -230,12 +218,7 @@ export default function Launchpad({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p style={{
|
||||
fontFamily: 'var(--chrome-font-mono)',
|
||||
fontSize: '0.8rem',
|
||||
color: 'var(--chrome-fg-muted)',
|
||||
margin: 0,
|
||||
}}>
|
||||
<p className="lp-empty__hint">
|
||||
Nothing here yet — pick a card above.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -153,26 +153,114 @@
|
||||
.models-roletabs { margin: 8px 0 10px; }
|
||||
|
||||
.reco-banner--action {
|
||||
align-items: center;
|
||||
padding: 8px var(--space-4);
|
||||
align-items: flex-start;
|
||||
padding: 10px var(--space-4);
|
||||
border-left-color: #f3a5b6;
|
||||
border-color: color-mix(in srgb, #f3a5b6 30%, transparent);
|
||||
background: color-mix(in srgb, #f3a5b6 5%, transparent);
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.reco-banner__body { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.reco-banner__body { flex: 1; display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.reco-banner__title { color: var(--chrome-fg); font-weight: 600; font-size: var(--text-sm); }
|
||||
.reco-banner__rationale { color: var(--chrome-fg-muted); font-size: var(--text-xs); line-height: 1.4; }
|
||||
|
||||
.reco-banner__models {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.reco-banner__model {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--chrome-fg-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.reco-banner__model.is-installed { color: var(--chrome-fg); }
|
||||
.reco-banner__model-size {
|
||||
color: var(--chrome-fg-dim);
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: var(--text-2xs);
|
||||
}
|
||||
.reco-banner__model-req {
|
||||
font-size: var(--chrome-label-size, 0.58rem);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #d3869b;
|
||||
padding: 0 4px;
|
||||
border: 1px solid color-mix(in srgb, #d3869b 35%, transparent);
|
||||
border-radius: var(--chrome-radius-pill, 999px);
|
||||
}
|
||||
|
||||
/* Table — dense rows, fixed-width action column. */
|
||||
.models-table { border-radius: 8px; }
|
||||
.models-table .ui-table-header { padding: 4px var(--space-3); }
|
||||
.models-table__body { display: flex; flex-direction: column; max-height: 540px; overflow-y: auto; }
|
||||
.models-table .ui-table-header { padding: 5px var(--space-3); }
|
||||
.models-table__body { max-height: 560px; overflow-y: auto; position: relative; }
|
||||
.models-table__header { align-items: center; }
|
||||
.models-table__sort {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.models-table__sort:hover { color: var(--chrome-fg); }
|
||||
.models-table__sort:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
}
|
||||
.models-table__sort--off {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: default;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.models-table__sortmark {
|
||||
color: var(--chrome-accent);
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: var(--text-2xs);
|
||||
}
|
||||
.models-table__virtual {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.models-table__empty {
|
||||
padding: 18px var(--space-4);
|
||||
color: var(--chrome-fg-dim);
|
||||
font-size: var(--text-sm);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Inside the wizard embed the table stretches to fill — no fixed cap. */
|
||||
.setup-wizard__embed .settings-section--compact {
|
||||
display: flex; flex-direction: column; height: 100%; min-height: 0;
|
||||
}
|
||||
.setup-wizard__embed .models-table {
|
||||
flex: 1 1 0; display: flex; flex-direction: column; min-height: 0;
|
||||
}
|
||||
.setup-wizard__embed .models-table__body { max-height: none; flex: 1 1 0; min-height: 0; }
|
||||
|
||||
.models-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 8px var(--space-3);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
padding: 10px var(--space-3);
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
@@ -182,21 +270,38 @@
|
||||
.models-row:last-child { border-bottom: none; }
|
||||
.models-row.is-ok { border-left-color: var(--chrome-severity-ok); }
|
||||
.models-row.is-off { border-left-color: var(--chrome-border-strong); opacity: 0.92; }
|
||||
.models-row.is-unsupported { opacity: 0.4; pointer-events: none; }
|
||||
.models-row.is-unsupported .models-row__actions { pointer-events: auto; }
|
||||
.models-row:hover { background: var(--chrome-hover-bg); }
|
||||
|
||||
.models-row__cell { min-width: 0; }
|
||||
.models-row__name { display: flex; flex-direction: column; gap: 2px; }
|
||||
.models-row__title { display: inline-flex; align-items: center; gap: var(--space-2); color: var(--chrome-fg); font-weight: 500; }
|
||||
.models-row__repo { color: var(--chrome-fg-muted); font-size: var(--text-xs); font-family: var(--chrome-font-mono); }
|
||||
.models-row__name { display: flex; flex-direction: column; gap: 3px; }
|
||||
.models-row__title { display: inline-flex; align-items: center; gap: var(--space-2); color: var(--chrome-fg); font-weight: 500; line-height: 1.4; }
|
||||
.models-row__avatar {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 5px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.54rem;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.models-row__repo { color: var(--chrome-fg-muted); font-size: var(--text-xs); font-family: var(--chrome-font-mono); line-height: 1.4; }
|
||||
.models-row__repo code{ color: var(--chrome-fg-muted); }
|
||||
.models-row__note { color: var(--chrome-fg-dim); }
|
||||
.models-row__role { color: var(--chrome-fg-muted); font-size: var(--text-xs); font-family: var(--chrome-font-mono); text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.models-row__progress { color: var(--chrome-accent); font-size: var(--text-xs); font-family: var(--chrome-font-mono); margin-top: 2px; }
|
||||
.models-row__progressline {
|
||||
display: flex; flex-direction: column; gap: 3px; margin-top: 6px;
|
||||
display: flex; flex-direction: column; gap: 5px; margin-top: 8px;
|
||||
}
|
||||
.models-row__progresstext {
|
||||
color: var(--chrome-fg-muted); font-size: var(--text-xs);
|
||||
font-family: var(--chrome-font-mono); font-variant-numeric: tabular-nums;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.models-row__error {
|
||||
color: var(--chrome-danger, #fb4934); font-size: var(--text-xs);
|
||||
@@ -229,3 +334,13 @@
|
||||
transition: opacity var(--dur-fast, 120ms) ease;
|
||||
}
|
||||
.models-row:hover .models-row__actions { opacity: 1; }
|
||||
|
||||
/* ── Responsive: narrow windows stack row cells ─────────────── */
|
||||
@media (max-width: 580px) {
|
||||
.models-row { flex-wrap: wrap; gap: var(--space-3); }
|
||||
.models-row__name { flex: 1 1 100%; }
|
||||
.models-row__size { width: auto !important; text-align: left !important; }
|
||||
.models-row__actions { margin-left: auto; }
|
||||
.models-toolbar { flex-direction: column; align-items: flex-start; }
|
||||
.models-controls { flex-direction: column; align-items: stretch; }
|
||||
}
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import {
|
||||
Cpu, FileText, Info, ShieldCheck, RefreshCw, Trash2, ExternalLink,
|
||||
CheckCircle, AlertCircle, Plug, Mic, MessageSquare, Download, Copy,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { systemInfo, systemLogs, systemLogsTauri, clearSystemLogs, clearTauriLogs, modelStatus as fetchModelStatus, sysinfo as fetchSysinfo } from '../api/system';
|
||||
import { systemLogs, systemLogsTauri, clearSystemLogs, clearTauriLogs } from '../api/system';
|
||||
import { useSysinfo, useModelStatus, useSystemInfo } from '../api/hooks';
|
||||
import { listEngines, selectEngine } from '../api/engines';
|
||||
import { listModels, installModel, deleteModel, setupDownloadStreamUrl, getRecommendations } from '../api/setup';
|
||||
import { setupDownloadStreamUrl } from '../api/setup';
|
||||
import { getFrontendLogs, clearFrontendLogs } from '../utils/consoleBuffer';
|
||||
import { Tabs, Segmented, Button, Badge, Panel, Table, Progress } from '../ui';
|
||||
import { useAppStore } from '../store';
|
||||
@@ -32,6 +41,9 @@ const LOG_SOURCES = [
|
||||
{ value: 'tauri', label: 'Tauri' },
|
||||
];
|
||||
|
||||
const MODEL_ROLE_ORDER = ['tts', 'asr', 'diarisation', 'diarization', 'llm'];
|
||||
const MODEL_ROLE_LABEL = { all: 'All', tts: 'TTS', asr: 'ASR', diarisation: 'Diarisation', diarization: 'Diarisation', llm: 'LLM', other: 'Other' };
|
||||
|
||||
function Row({ label, value, mono }) {
|
||||
return (
|
||||
<div className="settings-row">
|
||||
@@ -50,38 +62,44 @@ function fmtBytes(n) {
|
||||
return `${Math.round(n / 1024)} KB`;
|
||||
}
|
||||
|
||||
/** Deterministic muted HSL color from an org/user name in a repo_id. */
|
||||
function orgColor(repoId) {
|
||||
const org = (repoId || '').split('/')[0];
|
||||
let h = 0;
|
||||
for (let i = 0; i < org.length; i++) h = (h * 31 + org.charCodeAt(i)) & 0xffff;
|
||||
return `hsl(${h % 360}, 35%, 28%)`;
|
||||
}
|
||||
|
||||
import { useModels, useRecommendations, useInstallModel, useDeleteModel } from '../api/hooks';
|
||||
|
||||
/**
|
||||
* Model store — list every known HF model, show install state, let the
|
||||
* user install / reinstall / delete individual models. Per-model download
|
||||
* progress is pulled from the shared /setup/download-stream SSE.
|
||||
*/
|
||||
export function ModelStoreTab({ info, modelBadge }) {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const modelsQuery = useModels();
|
||||
const recoQuery = useRecommendations();
|
||||
const data = modelsQuery.data;
|
||||
const loading = modelsQuery.isLoading;
|
||||
const reco = recoQuery.data;
|
||||
const installMutation = useInstallModel();
|
||||
const deleteMutation = useDeleteModel();
|
||||
|
||||
const [busy, setBusy] = useState(new Set()); // repo_ids currently working
|
||||
// Per-repo active state. Tracks aggregate download across all files of
|
||||
// a running install so the row can show a determinate progress bar.
|
||||
// { [repo_id]: { phase, files: { [filename]: { downloaded, total, pct } }, error } }
|
||||
const [rowState, setRowState] = useState({});
|
||||
const [query, setQuery] = useState('');
|
||||
const [reco, setReco] = useState(null); // /setup/recommendations payload
|
||||
const [installingReco, setInstallingReco] = useState(false);
|
||||
const [activeRole, setActiveRole] = useState(null);
|
||||
const [sorting, setSorting] = useState([]);
|
||||
const [columnFilters, setColumnFilters] = useState([]);
|
||||
const esRef = React.useRef(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setData(await listModels()); }
|
||||
catch (e) { toast.error(`Failed to list models: ${e.message}`); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
const reloadReco = useCallback(async () => {
|
||||
try { setReco(await getRecommendations()); }
|
||||
catch (e) { /* non-fatal — Recommendation card just hides */ void e; }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { reload(); reloadReco(); }, [reload, reloadReco]);
|
||||
const tableBodyRef = React.useRef(null);
|
||||
// Track download speed per repo: { [repo_id]: { lastBytes, lastTime, speed } }
|
||||
const speedRef = React.useRef({});
|
||||
|
||||
// Open the progress stream once when the tab mounts; close on unmount.
|
||||
useEffect(() => {
|
||||
@@ -129,7 +147,10 @@ export function ModelStoreTab({ info, modelBadge }) {
|
||||
['install_done', 'delete_done', 'install_error'].includes(s.phase));
|
||||
if (!term) return;
|
||||
const t = setTimeout(() => {
|
||||
reload();
|
||||
modelsQuery.refetch();
|
||||
recoQuery.refetch();
|
||||
// Clear stale speed data for this repo.
|
||||
delete speedRef.current[term[0]];
|
||||
// Clear the terminal entry so the row reverts to the authoritative
|
||||
// `installed` flag from /models without keeping stale progress.
|
||||
setRowState(prev => {
|
||||
@@ -139,33 +160,39 @@ export function ModelStoreTab({ info, modelBadge }) {
|
||||
});
|
||||
}, 800);
|
||||
return () => clearTimeout(t);
|
||||
}, [rowState, reload]);
|
||||
}, [rowState, modelsQuery, recoQuery]);
|
||||
|
||||
const withBusy = async (repoId, fn, successMsg) => {
|
||||
const reload = useCallback(() => {
|
||||
modelsQuery.refetch();
|
||||
recoQuery.refetch();
|
||||
}, [modelsQuery, recoQuery]);
|
||||
|
||||
const withBusy = useCallback(async (repoId, fn, successMsg) => {
|
||||
setBusy(prev => new Set(prev).add(repoId));
|
||||
try {
|
||||
await fn();
|
||||
if (successMsg) toast.success(successMsg);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast.error(e.message || String(e));
|
||||
} finally {
|
||||
setBusy(prev => { const s = new Set(prev); s.delete(repoId); return s; });
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onInstall = (repoId) => withBusy(repoId, () => installModel(repoId), 'Install started — progress in the row');
|
||||
const onDelete = async (repoId) => {
|
||||
const onInstall = useCallback((repoId) =>
|
||||
withBusy(repoId, () => installMutation.mutateAsync(repoId), 'Install started — progress in the row'),
|
||||
[installMutation, withBusy]);
|
||||
const onDelete = useCallback(async (repoId) => {
|
||||
if (!(await askConfirm(`Delete ${repoId}? You can reinstall it later.`, 'Delete model'))) return;
|
||||
return withBusy(repoId, () => deleteModel(repoId), `Deleted ${repoId}`);
|
||||
};
|
||||
const onReinstall = async (repoId) => {
|
||||
return withBusy(repoId, () => deleteMutation.mutateAsync(repoId), `Deleted ${repoId}`);
|
||||
}, [deleteMutation, withBusy]);
|
||||
const onReinstall = useCallback(async (repoId) => {
|
||||
if (!(await askConfirm(`Reinstall ${repoId}? This will delete the current copy and download again.`, 'Reinstall model'))) return;
|
||||
await withBusy(repoId, async () => {
|
||||
await deleteModel(repoId);
|
||||
await installModel(repoId);
|
||||
await deleteMutation.mutateAsync(repoId);
|
||||
await installMutation.mutateAsync(repoId);
|
||||
}, 'Reinstalling');
|
||||
};
|
||||
}, [deleteMutation, installMutation, withBusy]);
|
||||
|
||||
const onInstallRecommended = async () => {
|
||||
if (!reco) return;
|
||||
@@ -178,9 +205,8 @@ export function ModelStoreTab({ info, modelBadge }) {
|
||||
try {
|
||||
// Parallel install — backend /models/install spawns each download on
|
||||
// its own asyncio task so ordering doesn't matter.
|
||||
await Promise.all(missing.map(m => installModel(m.repo_id)));
|
||||
await Promise.all(missing.map(m => installMutation.mutateAsync(m.repo_id)));
|
||||
toast.success(`Started downloading ${missing.length} model${missing.length > 1 ? 's' : ''}`);
|
||||
await Promise.all([reload(), reloadReco()]);
|
||||
} catch (e) {
|
||||
toast.error(`Install failed: ${e.message || e}`);
|
||||
} finally {
|
||||
@@ -188,6 +214,265 @@ export function ModelStoreTab({ info, modelBadge }) {
|
||||
}
|
||||
};
|
||||
|
||||
const allModels = React.useMemo(() => data?.models || [], [data]);
|
||||
const groups = allModels.reduce((acc, m) => {
|
||||
const k = (m.role || 'other').toLowerCase();
|
||||
(acc[k] = acc[k] || []).push(m);
|
||||
return acc;
|
||||
}, {});
|
||||
const roles = Object.keys(groups).sort((a, b) => {
|
||||
const ai = MODEL_ROLE_ORDER.indexOf(a), bi = MODEL_ROLE_ORDER.indexOf(b);
|
||||
return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi);
|
||||
});
|
||||
// 'all' is a virtual role — shows every model regardless of category.
|
||||
const currentRole = activeRole === 'all' ? 'all'
|
||||
: activeRole && groups[activeRole] ? activeRole
|
||||
: 'all';
|
||||
|
||||
const allInstalled = allModels.filter(m => m.installed).length;
|
||||
|
||||
useEffect(() => {
|
||||
setColumnFilters(currentRole === 'all' ? [] : [{ id: 'role', value: currentRole }]);
|
||||
}, [currentRole]);
|
||||
|
||||
const getRowRuntime = React.useCallback((m) => {
|
||||
const rs = rowState[m.repo_id];
|
||||
const rowBusy = busy.has(m.repo_id);
|
||||
const isInstalling = rs?.phase === 'install_start' || (rs?.phase === 'active' && !rs.files && !rs.error);
|
||||
const isDeleting = rs?.phase === 'delete_start';
|
||||
const phase = rs?.phase;
|
||||
const fileList = rs?.files ? Object.entries(rs.files) : [];
|
||||
const totals = fileList.reduce((a, [, f]) => ({
|
||||
downloaded: a.downloaded + (f.downloaded || 0),
|
||||
total: a.total + (f.total || 0),
|
||||
done: a.done + (f.phase === 'done' ? 1 : 0),
|
||||
}), { downloaded: 0, total: 0, done: 0 });
|
||||
const hasFiles = fileList.length > 0;
|
||||
const aggPct = totals.total > 0 ? (totals.downloaded / totals.total) * 100 : null;
|
||||
const showBar = phase === 'install_start' || phase === 'active' || phase === 'delete_start';
|
||||
const activeFilename = fileList.find(([, f]) => f.phase !== 'done')?.[0];
|
||||
const unsupported = m.supported === false;
|
||||
|
||||
return {
|
||||
rs,
|
||||
rowBusy,
|
||||
isInstalling,
|
||||
isDeleting,
|
||||
phase,
|
||||
fileList,
|
||||
totals,
|
||||
hasFiles,
|
||||
aggPct,
|
||||
showBar,
|
||||
activeFilename,
|
||||
unsupported,
|
||||
};
|
||||
}, [busy, rowState]);
|
||||
|
||||
const columns = React.useMemo(() => [
|
||||
{
|
||||
id: 'name',
|
||||
accessorFn: m => `${m.label || ''} ${m.repo_id || ''}`,
|
||||
header: 'Model',
|
||||
size: 420,
|
||||
meta: { className: 'models-row__name' },
|
||||
cell: ({ row }) => {
|
||||
const m = row.original;
|
||||
const rt = getRowRuntime(m);
|
||||
return (
|
||||
<>
|
||||
<span className="models-row__title">
|
||||
<span
|
||||
className="models-row__avatar"
|
||||
style={{ background: orgColor(m.repo_id) }}
|
||||
title={m.repo_id.split('/')[0]}
|
||||
>
|
||||
{m.repo_id.split('/')[0].slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
{m.label}
|
||||
{m.required && <span className="models-row__tag">required</span>}
|
||||
</span>
|
||||
<span className="models-row__repo">
|
||||
<code>{m.repo_id}</code>
|
||||
{m.note && <span className="models-row__note"> · {m.note}</span>}
|
||||
</span>
|
||||
{rt.showBar && (
|
||||
<div className="models-row__progressline">
|
||||
<Progress
|
||||
value={rt.aggPct}
|
||||
tone={rt.isDeleting ? 'warn' : 'brand'}
|
||||
size="xs"
|
||||
/>
|
||||
<span className="models-row__progresstext">
|
||||
{rt.isDeleting
|
||||
? 'Removing cached revisions…'
|
||||
: rt.hasFiles
|
||||
? (() => {
|
||||
const sp = speedRef.current[m.repo_id];
|
||||
const now = Date.now();
|
||||
if (sp && rt.totals.downloaded > 0) {
|
||||
const dt = (now - sp.lastTime) / 1000;
|
||||
if (dt >= 2) {
|
||||
sp.speed = Math.max(0, (rt.totals.downloaded - sp.lastBytes) / dt);
|
||||
sp.lastBytes = rt.totals.downloaded;
|
||||
sp.lastTime = now;
|
||||
}
|
||||
} else {
|
||||
speedRef.current[m.repo_id] = { lastBytes: rt.totals.downloaded, lastTime: now, speed: 0 };
|
||||
}
|
||||
const speed = sp?.speed || 0;
|
||||
const speedStr = speed > 0 ? ` · ${fmtBytes(speed)}/s` : '';
|
||||
const pctStr = rt.aggPct != null ? ` (${Math.round(rt.aggPct)}%)` : '';
|
||||
const parts = [
|
||||
`${fmtBytes(rt.totals.downloaded)}${rt.totals.total ? ` / ${fmtBytes(rt.totals.total)}` : ''}${pctStr}${speedStr}`,
|
||||
];
|
||||
if (rt.fileList.length > 1) {
|
||||
parts.push(`${rt.totals.done}/${rt.fileList.length} files`);
|
||||
}
|
||||
if (rt.activeFilename) {
|
||||
parts.push(rt.activeFilename.split('/').pop());
|
||||
}
|
||||
return parts.join(' · ');
|
||||
})()
|
||||
: 'Preparing download…'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{rt.phase === 'install_error' && rt.rs?.error && (
|
||||
<span className="models-row__error">Install failed: {rt.rs.error}</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'role',
|
||||
accessorFn: m => (m.role || 'other').toLowerCase(),
|
||||
header: 'Role',
|
||||
size: 92,
|
||||
filterFn: (row, id, value) => !value || row.getValue(id) === value,
|
||||
cell: ({ row }) => <span className="models-row__role">{MODEL_ROLE_LABEL[row.getValue('role')] || row.original.role || 'Other'}</span>,
|
||||
},
|
||||
{
|
||||
id: 'size',
|
||||
accessorFn: m => m.installed ? (m.size_on_disk_bytes || 0) : (m.size_gb || 0) * 1024 ** 3,
|
||||
header: 'Size',
|
||||
size: 86,
|
||||
meta: { align: 'right', className: 'models-row__size' },
|
||||
cell: ({ row }) => {
|
||||
const m = row.original;
|
||||
return m.installed ? fmtBytes(m.size_on_disk_bytes) : `${m.size_gb} GB`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorFn: m => m.installed ? 2 : (m.supported === false ? 0 : 1),
|
||||
header: 'Status',
|
||||
size: 116,
|
||||
meta: { align: 'center', className: 'models-row__status' },
|
||||
cell: ({ row }) => {
|
||||
const m = row.original;
|
||||
const rt = getRowRuntime(m);
|
||||
return rt.isInstalling
|
||||
? <Badge tone="warn" size="xs"><Download size={10} /> {rt.aggPct != null ? `${Math.round(rt.aggPct)}%` : 'downloading'}</Badge>
|
||||
: rt.isDeleting
|
||||
? <Badge tone="warn" size="xs"><Trash2 size={10} /> deleting</Badge>
|
||||
: rt.rowBusy
|
||||
? <Badge tone="warn" size="xs"><RefreshCw size={10} className="spinner" /> working</Badge>
|
||||
: m.installed
|
||||
? <Badge tone="success" size="xs">installed</Badge>
|
||||
: rt.unsupported
|
||||
? <Badge tone="neutral" size="xs">{(m.platforms || []).join(', ')}</Badge>
|
||||
: <Badge tone="neutral" size="xs">not installed</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
size: 118,
|
||||
enableSorting: false,
|
||||
meta: { align: 'right', className: 'models-row__actions' },
|
||||
cell: ({ row }) => {
|
||||
const m = row.original;
|
||||
const rt = getRowRuntime(m);
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="icon" iconSize="sm"
|
||||
onClick={() => window.open(`https://huggingface.co/${m.repo_id}`, '_blank', 'noopener,noreferrer')}
|
||||
title="View on HuggingFace"
|
||||
aria-label="View on HuggingFace"
|
||||
>
|
||||
<ExternalLink size={11} />
|
||||
</Button>
|
||||
{!m.installed && !rt.rowBusy && !rt.isInstalling && !rt.unsupported && (
|
||||
<Button
|
||||
variant="subtle" size="sm"
|
||||
onClick={() => onInstall(m.repo_id)}
|
||||
leading={<Download size={11} />}
|
||||
>
|
||||
Install
|
||||
</Button>
|
||||
)}
|
||||
{m.installed && !rt.rowBusy && !rt.isDeleting && (
|
||||
<>
|
||||
<Button
|
||||
variant="icon" iconSize="sm"
|
||||
onClick={() => onReinstall(m.repo_id)}
|
||||
title="Reinstall"
|
||||
aria-label="Reinstall"
|
||||
>
|
||||
<RefreshCw size={11} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="icon" iconSize="sm"
|
||||
onClick={() => onDelete(m.repo_id)}
|
||||
title="Delete"
|
||||
aria-label="Delete"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
], [getRowRuntime, onDelete, onInstall, onReinstall]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: allModels,
|
||||
columns,
|
||||
getRowId: row => row.repo_id,
|
||||
state: {
|
||||
sorting,
|
||||
globalFilter: query,
|
||||
columnFilters,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onGlobalFilterChange: setQuery,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
globalFilterFn: (row, _columnId, value) => {
|
||||
const q = String(value || '').trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
const m = row.original;
|
||||
return [m.repo_id, m.label, m.note, m.role]
|
||||
.filter(Boolean)
|
||||
.some(v => String(v).toLowerCase().includes(q));
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
const tableRows = table.getRowModel().rows;
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: tableRows.length,
|
||||
getScrollElement: () => tableBodyRef.current,
|
||||
estimateSize: () => 68,
|
||||
overscan: 8,
|
||||
});
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<section className="settings-section">
|
||||
@@ -198,29 +483,6 @@ export function ModelStoreTab({ info, modelBadge }) {
|
||||
}
|
||||
if (!data) return null;
|
||||
|
||||
const groups = (data.models || []).reduce((acc, m) => {
|
||||
const k = (m.role || 'other').toLowerCase();
|
||||
(acc[k] = acc[k] || []).push(m);
|
||||
return acc;
|
||||
}, {});
|
||||
const ROLE_ORDER = ['tts', 'asr', 'diarisation', 'diarization', 'llm'];
|
||||
const ROLE_LABEL = { tts: 'TTS', asr: 'ASR', diarisation: 'Diarisation', diarization: 'Diarisation', llm: 'LLM', other: 'Other' };
|
||||
const roles = Object.keys(groups).sort((a, b) => {
|
||||
const ai = ROLE_ORDER.indexOf(a), bi = ROLE_ORDER.indexOf(b);
|
||||
return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi);
|
||||
});
|
||||
const currentRole = activeRole && groups[activeRole] ? activeRole : roles[0];
|
||||
const q = query.trim().toLowerCase();
|
||||
const rows = (currentRole ? groups[currentRole] : []).filter(m =>
|
||||
!q || m.repo_id.toLowerCase().includes(q) || (m.label || '').toLowerCase().includes(q));
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: 'name', label: 'Model', flex: 3 },
|
||||
{ key: 'size', label: 'Size', width: 80, align: 'right' },
|
||||
{ key: 'status', label: 'Status', width: 110, align: 'center' },
|
||||
{ key: 'actions', label: '', width: 108, align: 'right' },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="settings-section settings-section--compact">
|
||||
<div className="models-toolbar">
|
||||
@@ -237,19 +499,32 @@ export function ModelStoreTab({ info, modelBadge }) {
|
||||
</div>
|
||||
|
||||
{reco && reco.all_installed && (
|
||||
<div className="reco-banner">
|
||||
<div className="mb-4 flex items-center gap-3 rounded-[var(--chrome-radius-pill)] border border-[color-mix(in_srgb,#8ec07c_30%,transparent)] border-l-2 border-l-[#8ec07c] bg-[color-mix(in_srgb,#8ec07c_5%,transparent)] px-4 py-[6px] font-[var(--font-sans)] text-[var(--text-sm)] text-[var(--chrome-fg-muted)]">
|
||||
<CheckCircle size={12} color="#8ec07c" />
|
||||
<span className="reco-banner__text">
|
||||
<span className="flex-1">
|
||||
Recommended bundle installed for <strong>{reco.device.label}</strong>
|
||||
</span>
|
||||
<span className="reco-banner__size">{reco.total_gb} GB</span>
|
||||
<span className="text-[var(--text-xs)] text-[var(--chrome-fg-dim)]">{reco.total_gb} GB</span>
|
||||
</div>
|
||||
)}
|
||||
{reco && !reco.all_installed && (
|
||||
<div className="reco-banner reco-banner--action">
|
||||
<div className="reco-banner__body">
|
||||
<div className="reco-banner__title">Recommended for {reco.device.label}</div>
|
||||
<div className="reco-banner__rationale">{reco.rationale}</div>
|
||||
<div className="mb-4 flex flex-wrap items-start gap-3 rounded-[var(--chrome-radius-pill)] border border-[color-mix(in_srgb,#f3a5b6_30%,transparent)] border-l-2 border-l-[#f3a5b6] bg-[color-mix(in_srgb,#f3a5b6_5%,transparent)] px-4 py-2.5">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="text-[var(--text-sm)] font-semibold text-[var(--chrome-fg)]">Recommended for {reco.device.label}</div>
|
||||
<div className="text-[var(--text-xs)] leading-[1.4] text-[var(--chrome-fg-muted)]">{reco.rationale}</div>
|
||||
<div className="mt-1 flex flex-col gap-0.5">
|
||||
{reco.models.map(m => (
|
||||
<span key={m.repo_id} className={`inline-flex items-center gap-2 text-[var(--text-xs)] leading-[1.5] ${m.installed ? 'text-[var(--chrome-fg)]' : 'text-[var(--chrome-fg-muted)]'}`}>
|
||||
{m.installed ? '✓' : '○'} {m.label}
|
||||
<span className="font-[var(--chrome-font-mono)] text-[var(--text-2xs)] text-[var(--chrome-fg-dim)]">{m.size_gb} GB</span>
|
||||
{m.required && (
|
||||
<span className="rounded-[var(--chrome-radius-pill,999px)] border border-[color-mix(in_srgb,#d3869b_35%,transparent)] px-1 text-[0.58rem] uppercase tracking-[0.04em] text-[#d3869b]">
|
||||
required
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -264,21 +539,25 @@ export function ModelStoreTab({ info, modelBadge }) {
|
||||
)}
|
||||
|
||||
<div className="models-controls">
|
||||
{roles.length > 1 && (
|
||||
<Segmented
|
||||
size="sm"
|
||||
value={currentRole}
|
||||
onChange={setActiveRole}
|
||||
className="models-roletabs"
|
||||
items={roles.map(r => {
|
||||
<Segmented
|
||||
size="sm"
|
||||
value={currentRole}
|
||||
onChange={setActiveRole}
|
||||
className="models-roletabs"
|
||||
items={[
|
||||
{
|
||||
value: 'all',
|
||||
label: `All ${allInstalled}/${allModels.length}`,
|
||||
},
|
||||
...roles.map(r => {
|
||||
const installed = groups[r].filter(m => m.installed).length;
|
||||
return {
|
||||
value: r,
|
||||
label: `${ROLE_LABEL[r] || r.toUpperCase()} ${installed}/${groups[r].length}`,
|
||||
label: `${MODEL_ROLE_LABEL[r] || r.toUpperCase()} ${installed}/${groups[r].length}`,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
className="models-search"
|
||||
@@ -290,104 +569,72 @@ export function ModelStoreTab({ info, modelBadge }) {
|
||||
</div>
|
||||
|
||||
<Table className="models-table">
|
||||
<Table.Header columns={COLUMNS} />
|
||||
<div className="models-table__body">
|
||||
{rows.map((m) => {
|
||||
const rs = rowState[m.repo_id];
|
||||
const rowBusy = busy.has(m.repo_id);
|
||||
const isInstalling = rs?.phase === 'install_start' || (rs?.phase === 'active' && !rs.files && !rs.error);
|
||||
const isDeleting = rs?.phase === 'delete_start';
|
||||
const phase = rs?.phase;
|
||||
// Aggregate current download progress across all in-flight files.
|
||||
const fileList = rs?.files ? Object.entries(rs.files) : [];
|
||||
const totals = fileList.reduce((a, [, f]) => ({
|
||||
downloaded: a.downloaded + (f.downloaded || 0),
|
||||
total: a.total + (f.total || 0),
|
||||
done: a.done + (f.phase === 'done' ? 1 : 0),
|
||||
}), { downloaded: 0, total: 0, done: 0 });
|
||||
const hasFiles = fileList.length > 0;
|
||||
const aggPct = totals.total > 0 ? (totals.downloaded / totals.total) * 100 : null;
|
||||
const showBar = phase === 'install_start' || phase === 'active' || phase === 'delete_start';
|
||||
const activeFilename = fileList.find(([, f]) => f.phase !== 'done')?.[0];
|
||||
return (
|
||||
<div key={m.repo_id} className={`models-row ${m.installed ? 'is-ok' : 'is-off'}`}>
|
||||
<div className="models-row__cell models-row__name" style={{ flex: 3 }}>
|
||||
<span className="models-row__title">
|
||||
{m.label}
|
||||
{m.required && <span className="models-row__tag">required</span>}
|
||||
</span>
|
||||
<span className="models-row__repo">
|
||||
<code>{m.repo_id}</code>
|
||||
{m.note && <span className="models-row__note"> · {m.note}</span>}
|
||||
</span>
|
||||
{showBar && (
|
||||
<div className="models-row__progressline">
|
||||
<Progress
|
||||
value={aggPct}
|
||||
tone={isDeleting ? 'warn' : 'brand'}
|
||||
size="xs"
|
||||
/>
|
||||
<span className="models-row__progresstext">
|
||||
{isDeleting
|
||||
? 'Removing cached revisions…'
|
||||
: hasFiles
|
||||
? `${fmtBytes(totals.downloaded)}${totals.total ? ` / ${fmtBytes(totals.total)}` : ''} · ${fileList.length} file${fileList.length === 1 ? '' : 's'}${totals.done ? ` · ${totals.done} done` : ''}${activeFilename ? ` · ${activeFilename.split('/').pop()}` : ''}`
|
||||
: 'Preparing download…'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{phase === 'install_error' && rs?.error && (
|
||||
<span className="models-row__error">Install failed: {rs.error}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="models-row__cell models-row__size" style={{ width: 80, textAlign: 'right' }}>
|
||||
{m.installed ? fmtBytes(m.size_on_disk_bytes) : `${m.size_gb} GB`}
|
||||
</div>
|
||||
<div className="models-row__cell" style={{ width: 110, display: 'flex', justifyContent: 'center' }}>
|
||||
{isInstalling
|
||||
? <Badge tone="warn" size="xs"><Download size={10} /> {aggPct != null ? `${Math.round(aggPct)}%` : 'downloading'}</Badge>
|
||||
: isDeleting
|
||||
? <Badge tone="warn" size="xs"><Trash2 size={10} /> deleting</Badge>
|
||||
: rowBusy
|
||||
? <Badge tone="warn" size="xs"><RefreshCw size={10} className="spinner" /> working</Badge>
|
||||
: m.installed
|
||||
? <Badge tone="success" size="xs">installed</Badge>
|
||||
: <Badge tone="neutral" size="xs">not installed</Badge>}
|
||||
</div>
|
||||
<div className="models-row__cell models-row__actions" style={{ width: 108 }}>
|
||||
{!m.installed && !rowBusy && !isInstalling && (
|
||||
<Button
|
||||
variant="subtle" size="sm"
|
||||
onClick={() => onInstall(m.repo_id)}
|
||||
leading={<Download size={11} />}
|
||||
>
|
||||
Install
|
||||
</Button>
|
||||
)}
|
||||
{m.installed && !rowBusy && !isDeleting && (
|
||||
<>
|
||||
<Button
|
||||
variant="icon" iconSize="sm"
|
||||
onClick={() => onReinstall(m.repo_id)}
|
||||
title="Reinstall"
|
||||
aria-label="Reinstall"
|
||||
<div className="ui-table-header models-table__header">
|
||||
{table.getHeaderGroups().map(headerGroup => (
|
||||
<React.Fragment key={headerGroup.id}>
|
||||
{headerGroup.headers.map(header => {
|
||||
const meta = header.column.columnDef.meta || {};
|
||||
const canSort = header.column.getCanSort();
|
||||
return (
|
||||
<button
|
||||
key={header.id}
|
||||
type="button"
|
||||
className={[
|
||||
'ui-table-header__cell',
|
||||
`ui-table-header__cell--align-${meta.align || 'left'}`,
|
||||
canSort ? 'models-table__sort' : 'models-table__sort--off',
|
||||
].join(' ')}
|
||||
style={{ width: header.column.columnDef.size, flex: header.column.id === 'name' ? '1 1 auto' : '0 0 auto' }}
|
||||
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
||||
disabled={!canSort}
|
||||
title={canSort ? `Sort by ${String(header.column.columnDef.header || '')}` : undefined}
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{header.column.getIsSorted() === 'asc' && <span className="models-table__sortmark">↑</span>}
|
||||
{header.column.getIsSorted() === 'desc' && <span className="models-table__sortmark">↓</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div ref={tableBodyRef} className="models-table__body">
|
||||
<div className="models-table__virtual" style={{ height: rowVirtualizer.getTotalSize() }}>
|
||||
{rowVirtualizer.getVirtualItems().map(virtualRow => {
|
||||
const row = tableRows[virtualRow.index];
|
||||
const m = row.original;
|
||||
const rt = getRowRuntime(m);
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
className={`models-row ${m.installed ? 'is-ok' : 'is-off'}${rt.unsupported ? ' is-unsupported' : ''}`}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
style={{ transform: `translateY(${virtualRow.start}px)` }}
|
||||
>
|
||||
{row.getVisibleCells().map(cell => {
|
||||
const meta = cell.column.columnDef.meta || {};
|
||||
return (
|
||||
<div
|
||||
key={cell.id}
|
||||
className={`models-row__cell ${meta.className || ''}`}
|
||||
style={{
|
||||
width: cell.column.columnDef.size,
|
||||
flex: cell.column.id === 'name' ? '1 1 auto' : '0 0 auto',
|
||||
textAlign: meta.align || undefined,
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={11} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="icon" iconSize="sm"
|
||||
onClick={() => onDelete(m.repo_id)}
|
||||
title="Delete"
|
||||
aria-label="Delete"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
{tableRows.length === 0 && (
|
||||
<div className="models-table__empty">No models match your filters.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Table>
|
||||
</section>
|
||||
@@ -539,17 +786,19 @@ async function askConfirm(message, title = 'Confirm') {
|
||||
|
||||
export default function Settings() {
|
||||
const [activeTab, setActiveTab] = useState('models');
|
||||
const [info, setInfo] = useState(null);
|
||||
const [status, setStatus] = useState(null);
|
||||
const [logSource, setLogSource] = useState('backend');
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [logMeta, setLogMeta] = useState({ path: '', exists: false });
|
||||
const [loadingLogs, setLoadingLogs] = useState(false);
|
||||
const [appVersion, setAppVersion] = useState(null);
|
||||
const [tauriVersion, setTauriVersion] = useState(null);
|
||||
const [hw, setHw] = useState(null);
|
||||
const [updateState, setUpdateState] = useState('idle'); // idle|checking|downloading|uptodate|error
|
||||
|
||||
// TanStack Query — shared cache with App.jsx, no duplicate requests
|
||||
const { data: hw } = useSysinfo();
|
||||
const { data: status } = useModelStatus();
|
||||
const { data: info } = useSystemInfo();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return;
|
||||
(async () => {
|
||||
@@ -561,18 +810,7 @@ export default function Settings() {
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const pull = async () => {
|
||||
try {
|
||||
const s = await fetchSysinfo();
|
||||
if (!cancelled) setHw(s);
|
||||
} catch { /* backend not up yet */ }
|
||||
};
|
||||
pull();
|
||||
const iv = setInterval(pull, 6000);
|
||||
return () => { cancelled = true; clearInterval(iv); };
|
||||
}, []);
|
||||
// sysinfo polling is now handled by useSysinfo() hook above
|
||||
|
||||
const copyDiagnostics = useCallback(async () => {
|
||||
const nav = typeof navigator !== 'undefined' ? navigator : {};
|
||||
@@ -647,12 +885,8 @@ export default function Settings() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshInfo = useCallback(async () => {
|
||||
try {
|
||||
const [i, s] = await Promise.all([systemInfo(), fetchModelStatus()]);
|
||||
setInfo(i); setStatus(s);
|
||||
} catch (e) { /* ignore */ }
|
||||
}, []);
|
||||
// refreshInfo polling replaced by TanStack Query (useSystemInfo + useModelStatus)
|
||||
const refreshInfo = useCallback(() => {}, []);
|
||||
|
||||
const refreshLogs = useCallback(async () => {
|
||||
setLoadingLogs(true);
|
||||
@@ -681,12 +915,6 @@ export default function Settings() {
|
||||
}
|
||||
}, [logSource]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshInfo();
|
||||
const iv = setInterval(refreshInfo, 4000);
|
||||
return () => clearInterval(iv);
|
||||
}, [refreshInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'logs') refreshLogs();
|
||||
}, [activeTab, logSource, refreshLogs]);
|
||||
|
||||
@@ -2,35 +2,37 @@
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 32px 40px 48px;
|
||||
padding: 0 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
min-height: 0; /* let flex children shrink */
|
||||
overflow: hidden; /* NO page-level scroll — only embed scrolls */
|
||||
}
|
||||
|
||||
/* Hero stays constrained + centered so the headline doesn't stretch
|
||||
uncomfortably wide on ultrawide displays; the embedded panels below
|
||||
get the full width. */
|
||||
/* Hero + steps stay constrained and centered; they never scroll. */
|
||||
.setup-wizard__hero,
|
||||
.setup-wizard__steps {
|
||||
max-width: 720px;
|
||||
max-width: 760px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Guided-flow step header ─────────────────────────────────────────────── */
|
||||
/* ── Step pills ──────────────────────────────────────────────────────── */
|
||||
.setup-wizard__steps {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.setup-wizard__step {
|
||||
padding: 6px 14px;
|
||||
padding: 5px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
font-size: 0.74rem;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.4;
|
||||
color: var(--color-fg-muted);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
@@ -46,18 +48,25 @@
|
||||
color: #8ec07c;
|
||||
border-color: rgba(142, 192, 124, 0.35);
|
||||
}
|
||||
|
||||
/* ── Embed panel — the ONLY scrollable region ────────────────────────── */
|
||||
.setup-wizard__embed {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
padding: 4px 0;
|
||||
margin-top: 4px;
|
||||
flex: 1 1 0;
|
||||
min-height: 0; /* critical for flex child scroll */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ── Nav bar — always visible below the embed ────────────────────────── */
|
||||
.setup-wizard__nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
flex-shrink: 0;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.setup-wizard--centered {
|
||||
@@ -68,27 +77,43 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Hero — compact ──────────────────────────────────────────────────── */
|
||||
.setup-wizard__hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 24px 12px 12px;
|
||||
gap: 6px;
|
||||
padding: 20px 12px 8px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.setup-wizard__hero h1 {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
letter-spacing: -0.01em;
|
||||
font-size: 1.45rem;
|
||||
font-family: var(--font-display, var(--font-sans));
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.setup-wizard__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.setup-wizard__logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.setup-wizard__sub {
|
||||
margin: 0;
|
||||
color: var(--color-fg-muted);
|
||||
font-size: 0.85rem;
|
||||
max-width: 440px;
|
||||
line-height: 1.5;
|
||||
font-size: 0.84rem;
|
||||
max-width: 460px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* ── Card / checklist rows ───────────────────────────────────────────── */
|
||||
.setup-wizard__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -115,17 +140,73 @@
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.setup-wizard__row-body { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.setup-wizard__row-title { font-weight: 600; font-size: 0.85rem; }
|
||||
.setup-wizard__row-body { flex: 1; display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.setup-wizard__row-title { font-weight: 600; font-size: 0.84rem; line-height: 1.4; }
|
||||
|
||||
.setup-wizard__muted {
|
||||
color: var(--color-fg-muted);
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.setup-wizard__muted code { color: var(--color-fg-subtle); }
|
||||
|
||||
.setup-wizard__warn { color: var(--color-warn, #fabd2f); font-weight: 600; }
|
||||
|
||||
/* ── Welcome step cards ──────────────────────────────────────────────── */
|
||||
.setup-wizard__welcome {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.setup-wizard__welcome-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.setup-wizard__welcome-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.setup-wizard__welcome-card strong {
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.3;
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.setup-wizard__welcome-card p {
|
||||
margin: 0;
|
||||
color: var(--color-fg-muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.setup-wizard__welcome-num {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
background: rgba(211, 134, 155, 0.12);
|
||||
color: #d3869b;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.setup-wizard__welcome-note {
|
||||
margin: 0;
|
||||
color: var(--color-fg-subtle);
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Model list ──────────────────────────────────────────────────────── */
|
||||
.setup-wizard__models {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@@ -183,10 +264,25 @@
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
/* ── Footnote — pinned to bottom ─────────────────────────────────────── */
|
||||
.setup-wizard__footnote {
|
||||
color: var(--color-fg-subtle);
|
||||
font-size: 0.65rem;
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.64rem;
|
||||
margin: 4px 0 6px;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.setup-wizard__footnote code { font-size: 0.62rem; }
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
.setup-wizard {
|
||||
padding: 0 14px;
|
||||
}
|
||||
.setup-wizard__hero { padding: 14px 8px 6px; }
|
||||
.setup-wizard__hero h1 { font-size: 1.2rem; }
|
||||
.setup-wizard__sub { font-size: 0.78rem; }
|
||||
.setup-wizard__steps { gap: 4px; }
|
||||
.setup-wizard__step { padding: 4px 10px; font-size: 0.7rem; }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { CheckCircle, Loader, Sparkles, ArrowRight, AlertTriangle, XCircle, RefreshCw } from 'lucide-react';
|
||||
import { CheckCircle, Loader, ArrowRight, AlertTriangle, XCircle, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '../ui';
|
||||
import { setupStatus, preflight } from '../api/setup';
|
||||
import { useSetupStatus, usePreflight } from '../api/hooks';
|
||||
import { ModelStoreTab, EnginesTab } from './Settings';
|
||||
import './SetupWizard.css';
|
||||
|
||||
@@ -74,31 +74,23 @@ function PreflightPanel({ report, loading, onRecheck }) {
|
||||
*/
|
||||
export default function SetupWizard({ onReady }) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [status, setStatus] = useState(null);
|
||||
const [pre, setPre] = useState(null);
|
||||
const [preLoading, setPreLoading] = useState(false);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try { setStatus(await setupStatus()); }
|
||||
catch { /* backend warming up — retry on interval */ }
|
||||
}, []);
|
||||
// TanStack Query — shared cache, auto-refetch on step 2 (models)
|
||||
const setupQuery = useSetupStatus();
|
||||
const preQuery = usePreflight();
|
||||
const status = setupQuery.data ?? null;
|
||||
const pre = preQuery.data ?? null;
|
||||
const preLoading = preQuery.isLoading;
|
||||
|
||||
const recheckPreflight = useCallback(async () => {
|
||||
setPreLoading(true);
|
||||
try { setPre(await preflight()); }
|
||||
catch { /* backend not ready */ }
|
||||
finally { setPreLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { reload(); recheckPreflight(); }, [reload, recheckPreflight]);
|
||||
|
||||
// Poll while on the Models step so the Finish button unlocks as soon as
|
||||
// downloads complete, without the user having to click "Recheck".
|
||||
// Poll setup status every 4s while on Models step so "Finish" unlocks
|
||||
// as soon as downloads complete.
|
||||
useEffect(() => {
|
||||
if (step !== 2) return;
|
||||
const iv = setInterval(reload, 4000);
|
||||
const iv = setInterval(() => setupQuery.refetch(), 4000);
|
||||
return () => clearInterval(iv);
|
||||
}, [step, reload]);
|
||||
}, [step, setupQuery]);
|
||||
|
||||
const recheckPreflight = useCallback(() => { preQuery.refetch(); }, [preQuery]);
|
||||
|
||||
const modelsReady = !!status?.models_ready;
|
||||
const preflightOk = !!pre?.ok;
|
||||
@@ -110,8 +102,10 @@ export default function SetupWizard({ onReady }) {
|
||||
onDoubleClick={doubleClickMaximize}
|
||||
className="setup-wizard__hero"
|
||||
>
|
||||
<Sparkles size={36} color="#d3869b" />
|
||||
<h1 data-tauri-drag-region>Welcome to OmniVoice Studio</h1>
|
||||
<div className="setup-wizard__brand">
|
||||
<img src="/favicon.svg" alt="" className="setup-wizard__logo" />
|
||||
<h1 data-tauri-drag-region>OmniVoice Studio</h1>
|
||||
</div>
|
||||
<p className="setup-wizard__sub" data-tauri-drag-region>
|
||||
Dubbing, voice cloning, and voice design — all running locally on
|
||||
your machine. Four quick steps and you're in.
|
||||
@@ -137,39 +131,48 @@ export default function SetupWizard({ onReady }) {
|
||||
|
||||
{/* 0. Welcome */}
|
||||
{step === 0 && (
|
||||
<div className="setup-wizard__embed" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<strong>What happens next</strong>
|
||||
<ol style={{ margin: 0, paddingLeft: 20, lineHeight: 1.7, color: 'var(--color-fg-muted)', fontSize: '0.9rem' }}>
|
||||
<li>
|
||||
<strong>System check</strong> — we'll probe RAM, disk, GPU driver
|
||||
compatibility, ffmpeg, and network. Any blockers flagged upfront so
|
||||
nothing fails silently later.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Install models</strong> — we'll download ~5 GB of weights
|
||||
(OmniVoice TTS + Whisper). Required ones first; optional engines
|
||||
you can enable now or later.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Pick engines</strong> — choose which TTS / ASR / LLM
|
||||
backends to use. Defaults work; power users can pin specific
|
||||
engines per family.
|
||||
</li>
|
||||
<li>
|
||||
<strong>You're in.</strong> First launch takes ~5-10 minutes to
|
||||
download. After that, every launch is instant and fully offline.
|
||||
</li>
|
||||
</ol>
|
||||
<div>
|
||||
<>
|
||||
<div className="setup-wizard__embed">
|
||||
<div className="setup-wizard__welcome">
|
||||
<div className="setup-wizard__welcome-grid">
|
||||
<div className="flex items-start gap-3 rounded-[8px] border border-[rgba(255,255,255,0.05)] bg-[rgba(255,255,255,0.025)] px-3.5 py-2.5">
|
||||
<span className="mt-px flex h-6 w-6 shrink-0 items-center justify-center rounded-[6px] bg-[rgba(211,134,155,0.12)] text-[0.72rem] font-bold text-[var(--color-brand)]">1</span>
|
||||
<div>
|
||||
<strong className="mb-0.5 block text-[0.84rem] leading-[1.3]">System check</strong>
|
||||
<p className="m-0 text-[0.78rem] leading-[1.5] text-[var(--color-fg-muted)]">Probe RAM, disk, GPU, ffmpeg, network. Blockers are flagged upfront.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 rounded-[8px] border border-[rgba(255,255,255,0.05)] bg-[rgba(255,255,255,0.025)] px-3.5 py-2.5">
|
||||
<span className="mt-px flex h-6 w-6 shrink-0 items-center justify-center rounded-[6px] bg-[rgba(211,134,155,0.12)] text-[0.72rem] font-bold text-[var(--color-brand)]">2</span>
|
||||
<div>
|
||||
<strong className="mb-0.5 block text-[0.84rem] leading-[1.3]">Install models</strong>
|
||||
<p className="m-0 text-[0.78rem] leading-[1.5] text-[var(--color-fg-muted)]">Download ~5 GB of weights — TTS + Whisper. Required models first, optional ones later.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 rounded-[8px] border border-[rgba(255,255,255,0.05)] bg-[rgba(255,255,255,0.025)] px-3.5 py-2.5">
|
||||
<span className="mt-px flex h-6 w-6 shrink-0 items-center justify-center rounded-[6px] bg-[rgba(211,134,155,0.12)] text-[0.72rem] font-bold text-[var(--color-brand)]">3</span>
|
||||
<div>
|
||||
<strong className="mb-0.5 block text-[0.84rem] leading-[1.3]">Pick engines</strong>
|
||||
<p className="m-0 text-[0.78rem] leading-[1.5] text-[var(--color-fg-muted)]">Choose TTS / ASR / LLM backends. Defaults work out of the box.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="m-0 text-center text-[0.74rem] leading-[1.5] text-[var(--color-fg-subtle)]">
|
||||
First run takes 5–10 minutes to download. After that, every launch is instant and fully offline.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="setup-wizard__nav">
|
||||
<span />
|
||||
<Button
|
||||
variant="primary" size="lg"
|
||||
variant="primary" size="sm"
|
||||
onClick={() => setStep(1)}
|
||||
trailing={<ArrowRight size={14} />}
|
||||
>
|
||||
Get started
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 1. System check */}
|
||||
|
||||
@@ -3,10 +3,12 @@ import {
|
||||
Search, Download, Play, Pause, Trash2, User, Film,
|
||||
Clock, Grid, List, X, Save, Loader, Music, Sparkles,
|
||||
Star, Crown, Gamepad2, BookOpen, Mic, RotateCcw,
|
||||
FileAudio, UserPlus, MoreVertical,
|
||||
FileAudio, UserPlus, MoreVertical, Scissors,
|
||||
} from 'lucide-react';
|
||||
import { Button, Input } from '../ui';
|
||||
import { listCategories, listGalleryVoices, searchYoutube, downloadYoutubeClip, deleteGalleryVoice, saveVoiceAsProfile } from '../api/gallery';
|
||||
import { searchYoutube, downloadYoutubeClip, deleteGalleryVoice, saveVoiceAsProfile, uploadVoiceClip } from '../api/gallery';
|
||||
import { useGalleryCategories, useGalleryVoices } from '../api/hooks';
|
||||
import AudioTrimmer from '../components/AudioTrimmer';
|
||||
import './VoiceGallery.css';
|
||||
|
||||
// Check if running in Tauri
|
||||
@@ -24,49 +26,30 @@ const CATEGORY_ICONS = {
|
||||
};
|
||||
|
||||
export default function VoiceGallery() {
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [voices, setVoices] = useState([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [isLoadingVoices, setIsLoadingVoices] = useState(false);
|
||||
const [playingVoiceId, setPlayingVoiceId] = useState(null);
|
||||
const [playingAudio, setPlayingAudio] = useState(null);
|
||||
const [viewMode, setViewMode] = useState('list');
|
||||
const [trimmingVoice, setTrimmingVoice] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
}, []);
|
||||
const { data: categories = [] } = useGalleryCategories();
|
||||
|
||||
const queryParams = React.useMemo(() => {
|
||||
const p = {};
|
||||
if (selectedCategory) p.category = selectedCategory;
|
||||
if (searchQuery.trim()) p.search = searchQuery.trim();
|
||||
return p;
|
||||
}, [selectedCategory, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
loadVoices();
|
||||
}, [selectedCategory]);
|
||||
const voicesQuery = useGalleryVoices(queryParams);
|
||||
const voices = voicesQuery.data || [];
|
||||
const isLoadingVoices = voicesQuery.isLoading;
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const data = await listCategories();
|
||||
setCategories(data);
|
||||
} catch (e) {
|
||||
console.error('Failed to load categories:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const loadVoices = async () => {
|
||||
setIsLoadingVoices(true);
|
||||
try {
|
||||
const params = {};
|
||||
if (selectedCategory) params.category = selectedCategory;
|
||||
if (searchQuery.trim()) params.search = searchQuery.trim();
|
||||
const data = await listGalleryVoices(params);
|
||||
setVoices(data || []);
|
||||
} catch (e) {
|
||||
console.error('Failed to load voices:', e);
|
||||
} finally {
|
||||
setIsLoadingVoices(false);
|
||||
}
|
||||
};
|
||||
const loadVoices = () => voicesQuery.refetch();
|
||||
|
||||
const handleSearch = async () => {
|
||||
setIsSearching(true);
|
||||
@@ -193,6 +176,39 @@ export default function VoiceGallery() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCropClick = async (voice) => {
|
||||
try {
|
||||
const { apiUrl } = await import('../api/client');
|
||||
const response = await fetch(apiUrl(`/gallery/voices/${voice.id}/preview`));
|
||||
if (!response.ok) throw new Error("Failed to fetch audio");
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], `${voice.name}.wav`, { type: 'audio/wav' });
|
||||
setTrimmingVoice({ voice, file });
|
||||
} catch (e) {
|
||||
alert("Failed to load audio for trimming: " + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmTrim = async (trimmedFile) => {
|
||||
if (!trimmingVoice) return;
|
||||
try {
|
||||
const { voice } = trimmingVoice;
|
||||
const formData = new FormData();
|
||||
formData.append('name', `${voice.name} (Cropped)`);
|
||||
formData.append('character', voice.character);
|
||||
formData.append('category', voice.category);
|
||||
formData.append('description', voice.description || '');
|
||||
formData.append('audio', trimmedFile);
|
||||
|
||||
await uploadVoiceClip(formData);
|
||||
|
||||
loadVoices();
|
||||
setTrimmingVoice(null);
|
||||
} catch (e) {
|
||||
alert("Failed to upload cropped voice: " + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const onSearchKey = (e) => {
|
||||
if (e.key === 'Enter') handleSearch();
|
||||
};
|
||||
@@ -204,7 +220,7 @@ export default function VoiceGallery() {
|
||||
<div className="header-text">
|
||||
<h2>Voice Gallery</h2>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => loadVoices()} title="Reload">
|
||||
<Button variant="ghost" size="sm" onClick={() => voicesQuery.refetch()} title="Reload">
|
||||
<RotateCcw size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -322,6 +338,13 @@ export default function VoiceGallery() {
|
||||
>
|
||||
<UserPlus size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="action-btn"
|
||||
onClick={() => handleCropClick(voice)}
|
||||
title="Crop audio"
|
||||
>
|
||||
<Scissors size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="action-btn danger"
|
||||
onClick={() => handleDeleteVoice(voice)}
|
||||
@@ -335,6 +358,15 @@ export default function VoiceGallery() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{trimmingVoice && (
|
||||
<AudioTrimmer
|
||||
file={trimmingVoice.file}
|
||||
maxSeconds={60}
|
||||
onConfirm={handleConfirmTrim}
|
||||
onCancel={() => setTrimmingVoice(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,20 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React from 'react';
|
||||
import * as RadixDialog from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import Button from './Button';
|
||||
import './Dialog.css';
|
||||
|
||||
/**
|
||||
* Dialog — accessible modal.
|
||||
* Dialog — accessible modal backed by @radix-ui/react-dialog.
|
||||
*
|
||||
* - Click-outside closes (unless dismissable={false}).
|
||||
* - ESC closes.
|
||||
* - Focus is returned to the previously-focused element on close.
|
||||
* - `role="dialog" aria-modal="true"` wired up.
|
||||
* Provides focus trapping, Escape-to-close, scroll lock, and
|
||||
* proper ARIA attributes out of the box.
|
||||
*
|
||||
* @param open controlled visibility
|
||||
* @param onClose called on backdrop click / ESC / close button
|
||||
* @param title string in the header; omit for header-less dialog
|
||||
* @param footer node rendered in the footer region (actions)
|
||||
* @param size 'sm' | 'md' | 'lg' | 'xl'
|
||||
* @param open controlled visibility
|
||||
* @param onClose called on backdrop click / ESC / close button
|
||||
* @param title string | ReactNode in the header; omit for header-less dialog
|
||||
* @param footer node rendered in the footer region (actions)
|
||||
* @param size 'sm' | 'md' | 'lg' | 'xl'
|
||||
* @param dismissable whether backdrop click / ESC closes (default true)
|
||||
*/
|
||||
export default function Dialog({
|
||||
@@ -27,59 +26,49 @@ export default function Dialog({
|
||||
dismissable = true,
|
||||
children,
|
||||
}) {
|
||||
const dialogRef = useRef(null);
|
||||
const previouslyFocused = useRef(null);
|
||||
const handleOpenChange = (nextOpen) => {
|
||||
if (!nextOpen && dismissable) onClose?.();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
previouslyFocused.current = document.activeElement;
|
||||
const firstFocusable = dialogRef.current?.querySelector(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
firstFocusable?.focus?.();
|
||||
const handleEscapeKeyDown = (e) => {
|
||||
if (!dismissable) e.preventDefault();
|
||||
};
|
||||
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape' && dismissable) {
|
||||
e.stopPropagation();
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
previouslyFocused.current?.focus?.();
|
||||
};
|
||||
}, [open, dismissable, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
const handlePointerDownOutside = (e) => {
|
||||
if (!dismissable) e.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ui-dialog-backdrop"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget && dismissable) onClose?.();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className={`ui-dialog ui-dialog--${size}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? 'ui-dialog-title' : undefined}
|
||||
>
|
||||
{(title || dismissable) && (
|
||||
<header className="ui-dialog__header">
|
||||
{title && <h2 id="ui-dialog-title" className="ui-dialog__title">{title}</h2>}
|
||||
{dismissable && (
|
||||
<Button variant="icon" iconSize="sm" onClick={onClose} aria-label="Close">
|
||||
<X size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
)}
|
||||
<div className="ui-dialog__body">{children}</div>
|
||||
{footer && <footer className="ui-dialog__footer">{footer}</footer>}
|
||||
</div>
|
||||
</div>
|
||||
<RadixDialog.Root open={open} onOpenChange={handleOpenChange}>
|
||||
<RadixDialog.Portal>
|
||||
<RadixDialog.Overlay className="ui-dialog-backdrop" />
|
||||
<RadixDialog.Content
|
||||
className={`ui-dialog ui-dialog--${size}`}
|
||||
onEscapeKeyDown={handleEscapeKeyDown}
|
||||
onPointerDownOutside={handlePointerDownOutside}
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
{(title || dismissable) && (
|
||||
<header className="ui-dialog__header">
|
||||
{title && (
|
||||
<RadixDialog.Title className="ui-dialog__title">
|
||||
{title}
|
||||
</RadixDialog.Title>
|
||||
)}
|
||||
{dismissable && (
|
||||
<RadixDialog.Close asChild>
|
||||
<Button variant="icon" iconSize="sm" aria-label="Close">
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</RadixDialog.Close>
|
||||
)}
|
||||
</header>
|
||||
)}
|
||||
{!title && <RadixDialog.Title className="sr-only">Dialog</RadixDialog.Title>}
|
||||
<div className="ui-dialog__body">{children}</div>
|
||||
{footer && <footer className="ui-dialog__footer">{footer}</footer>}
|
||||
</RadixDialog.Content>
|
||||
</RadixDialog.Portal>
|
||||
</RadixDialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,20 @@
|
||||
import React, {
|
||||
useState, useRef, useId, useEffect, useCallback, cloneElement, isValidElement,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import React, { useCallback, isValidElement } from 'react';
|
||||
import * as RadixMenu from '@radix-ui/react-dropdown-menu';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import './Menu.css';
|
||||
|
||||
/**
|
||||
* Menu — floating action menu triggered by a child element.
|
||||
* Backed by @radix-ui/react-dropdown-menu for keyboard navigation,
|
||||
* collision-aware positioning, and proper ARIA attributes.
|
||||
*
|
||||
* <Menu
|
||||
* placement="bottom-start"
|
||||
* items={[
|
||||
* { id: 'rename', label: 'Rename', icon: Pencil, onSelect: () => …, shortcut: '⌘R' },
|
||||
* { id: 'dup', label: 'Duplicate', onSelect: () => … },
|
||||
* 'separator',
|
||||
* { id: 'delete', label: 'Delete', icon: Trash2, destructive: true, onSelect: () => … },
|
||||
* ]}
|
||||
* >
|
||||
* <Button variant="icon">…</Button>
|
||||
* </Menu>
|
||||
*
|
||||
* - Wraps exactly one child — the trigger. Click opens, click-outside closes.
|
||||
* - Keyboard: Space/Enter opens on focused trigger; ↑↓ navigate; Enter/Space select;
|
||||
* ESC closes (returns focus to trigger); Tab closes.
|
||||
* - `aria-haspopup`, `aria-expanded`, `role=menu`, `role=menuitem` all wired.
|
||||
* - Rendered into a Portal at document.body so panels don't clip it.
|
||||
*
|
||||
* Items: array of either
|
||||
* - 'separator' (or { type: 'separator' })
|
||||
* - { id, label, icon?, shortcut?, disabled?, destructive?, onSelect }
|
||||
*
|
||||
* Placement: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'.
|
||||
* @param children exactly one child — the trigger element
|
||||
* @param items array of items or 'separator' strings
|
||||
* @param placement 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'
|
||||
* @param open controlled open state (optional)
|
||||
* @param onOpenChange callback on open/close
|
||||
* @param width optional fixed width (px)
|
||||
* @param disabled disable the trigger
|
||||
*/
|
||||
export default function Menu({
|
||||
children,
|
||||
@@ -38,185 +22,64 @@ export default function Menu({
|
||||
placement = 'bottom-start',
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
width, // optional fixed width (px)
|
||||
width,
|
||||
disabled = false,
|
||||
}) {
|
||||
const isControlled = controlledOpen != null;
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const open = isControlled ? controlledOpen : internalOpen;
|
||||
|
||||
const setOpen = useCallback((next) => {
|
||||
if (!isControlled) setInternalOpen(next);
|
||||
onOpenChange?.(next);
|
||||
}, [isControlled, onOpenChange]);
|
||||
|
||||
const triggerRef = useRef(null);
|
||||
const panelRef = useRef(null);
|
||||
const menuId = useId();
|
||||
const [focusIndex, setFocusIndex] = useState(-1);
|
||||
const [coords, setCoords] = useState(null);
|
||||
|
||||
const enabledIndices = items
|
||||
.map((it, i) => (typeof it === 'string' || it?.type === 'separator' || it?.disabled ? -1 : i))
|
||||
.filter((i) => i !== -1);
|
||||
|
||||
// Position the panel relative to the trigger.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const trigger = triggerRef.current;
|
||||
if (!trigger) return;
|
||||
const compute = () => {
|
||||
const r = trigger.getBoundingClientRect();
|
||||
const below = placement.startsWith('bottom');
|
||||
const end = placement.endsWith('end');
|
||||
setCoords({
|
||||
top: below ? r.bottom + 4 : r.top - 4,
|
||||
left: end ? r.right : r.left,
|
||||
end,
|
||||
below,
|
||||
});
|
||||
};
|
||||
compute();
|
||||
window.addEventListener('scroll', compute, true);
|
||||
window.addEventListener('resize', compute);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', compute, true);
|
||||
window.removeEventListener('resize', compute);
|
||||
};
|
||||
}, [open, placement]);
|
||||
|
||||
// Focus first enabled item when opened by keyboard; reset when closed.
|
||||
useEffect(() => {
|
||||
if (!open) { setFocusIndex(-1); return; }
|
||||
// default focus index = first enabled
|
||||
setFocusIndex(enabledIndices[0] ?? -1);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
// Click-outside + ESC dismiss.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onMouseDown = (e) => {
|
||||
if (
|
||||
triggerRef.current && !triggerRef.current.contains(e.target) &&
|
||||
panelRef.current && !panelRef.current.contains(e.target)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus?.();
|
||||
} else if (e.key === 'Tab') {
|
||||
setOpen(false);
|
||||
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
if (!enabledIndices.length) return;
|
||||
e.preventDefault();
|
||||
const dir = e.key === 'ArrowDown' ? 1 : -1;
|
||||
const pos = enabledIndices.indexOf(focusIndex);
|
||||
const next = (pos + dir + enabledIndices.length) % enabledIndices.length;
|
||||
setFocusIndex(enabledIndices[next]);
|
||||
} else if (e.key === 'Home') {
|
||||
e.preventDefault(); setFocusIndex(enabledIndices[0] ?? -1);
|
||||
} else if (e.key === 'End') {
|
||||
e.preventDefault(); setFocusIndex(enabledIndices[enabledIndices.length - 1] ?? -1);
|
||||
}
|
||||
};
|
||||
window.addEventListener('mousedown', onMouseDown);
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', onMouseDown);
|
||||
window.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open, focusIndex, enabledIndices, setOpen]);
|
||||
|
||||
// Focus the active menu item as focusIndex changes.
|
||||
useEffect(() => {
|
||||
if (!open || focusIndex < 0) return;
|
||||
const el = panelRef.current?.querySelector(`[data-ui-menu-idx="${focusIndex}"]`);
|
||||
el?.focus?.();
|
||||
}, [focusIndex, open]);
|
||||
// Map our placement string to Radix side + align
|
||||
const sideMap = {
|
||||
'bottom-start': { side: 'bottom', align: 'start' },
|
||||
'bottom-end': { side: 'bottom', align: 'end' },
|
||||
'top-start': { side: 'top', align: 'start' },
|
||||
'top-end': { side: 'top', align: 'end' },
|
||||
};
|
||||
const { side, align } = sideMap[placement] || sideMap['bottom-start'];
|
||||
|
||||
if (!isValidElement(children)) {
|
||||
// Keep the API resilient to a bare string or nothing.
|
||||
return children ?? null;
|
||||
}
|
||||
|
||||
const trigger = cloneElement(children, {
|
||||
ref: (node) => {
|
||||
triggerRef.current = node;
|
||||
const orig = children.ref;
|
||||
if (typeof orig === 'function') orig(node);
|
||||
else if (orig && 'current' in orig) orig.current = node;
|
||||
},
|
||||
'aria-haspopup': 'menu',
|
||||
'aria-expanded': open,
|
||||
'aria-controls': open ? menuId : undefined,
|
||||
onClick: (e) => {
|
||||
children.props.onClick?.(e);
|
||||
if (e.defaultPrevented || disabled) return;
|
||||
setOpen(!open);
|
||||
},
|
||||
onKeyDown: (e) => {
|
||||
children.props.onKeyDown?.(e);
|
||||
if (disabled) return;
|
||||
if (!open && (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown')) {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
const rootProps = {};
|
||||
if (controlledOpen != null) {
|
||||
rootProps.open = controlledOpen;
|
||||
rootProps.onOpenChange = onOpenChange;
|
||||
} else if (onOpenChange) {
|
||||
rootProps.onOpenChange = onOpenChange;
|
||||
}
|
||||
|
||||
const handleSelect = (item) => {
|
||||
if (item.disabled) return;
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus?.();
|
||||
item.onSelect?.();
|
||||
};
|
||||
|
||||
const panel = open && coords ? createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
id={menuId}
|
||||
role="menu"
|
||||
className={`ui-menu ui-menu--${coords.below ? 'below' : 'above'} ui-menu--${coords.end ? 'end' : 'start'}`}
|
||||
style={{
|
||||
top: coords.below ? coords.top : undefined,
|
||||
bottom: coords.below ? undefined : window.innerHeight - coords.top,
|
||||
left: coords.end ? undefined : coords.left,
|
||||
right: coords.end ? window.innerWidth - coords.left : undefined,
|
||||
width,
|
||||
}}
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
if (item === 'separator' || item?.type === 'separator') {
|
||||
return <div key={`sep-${i}`} role="separator" className="ui-menu__separator" />;
|
||||
}
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.id ?? i}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-ui-menu-idx={i}
|
||||
className={`ui-menu__item ${item.destructive ? 'is-destructive' : ''} ${item.disabled ? 'is-disabled' : ''}`}
|
||||
aria-disabled={item.disabled || undefined}
|
||||
tabIndex={focusIndex === i ? 0 : -1}
|
||||
onMouseEnter={() => setFocusIndex(i)}
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
{Icon && <Icon size={12} className="ui-menu__icon" />}
|
||||
<span className="ui-menu__label">{item.label}</span>
|
||||
{item.shortcut && <span className="ui-menu__shortcut">{item.shortcut}</span>}
|
||||
{item.trailing}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>,
|
||||
document.body,
|
||||
) : null;
|
||||
|
||||
return <>{trigger}{panel}</>;
|
||||
return (
|
||||
<RadixMenu.Root {...rootProps}>
|
||||
<RadixMenu.Trigger asChild disabled={disabled}>
|
||||
{children}
|
||||
</RadixMenu.Trigger>
|
||||
<RadixMenu.Portal>
|
||||
<RadixMenu.Content
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={4}
|
||||
className={`ui-menu ui-menu--below ui-menu--${align}`}
|
||||
style={width ? { width } : undefined}
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
if (item === 'separator' || item?.type === 'separator') {
|
||||
return <RadixMenu.Separator key={`sep-${i}`} className="ui-menu__separator" />;
|
||||
}
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<RadixMenu.Item
|
||||
key={item.id ?? i}
|
||||
className={`ui-menu__item ${item.destructive ? 'is-destructive' : ''} ${item.disabled ? 'is-disabled' : ''}`}
|
||||
disabled={item.disabled}
|
||||
onSelect={() => item.onSelect?.()}
|
||||
>
|
||||
{Icon && <Icon size={12} className="ui-menu__icon" />}
|
||||
<span className="ui-menu__label">{item.label}</span>
|
||||
{item.shortcut && <span className="ui-menu__shortcut">{item.shortcut}</span>}
|
||||
{item.trailing}
|
||||
</RadixMenu.Item>
|
||||
);
|
||||
})}
|
||||
</RadixMenu.Content>
|
||||
</RadixMenu.Portal>
|
||||
</RadixMenu.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React from 'react';
|
||||
import * as RadixProgress from '@radix-ui/react-progress';
|
||||
import './Progress.css';
|
||||
|
||||
/**
|
||||
* Progress — determinate or indeterminate progress bar.
|
||||
* Backed by @radix-ui/react-progress for proper ARIA value attributes.
|
||||
*
|
||||
* @param value 0–100 when determinate. Omit for indeterminate.
|
||||
* @param tone 'brand' (default) | 'success' | 'warn' | 'danger'
|
||||
@@ -19,21 +21,19 @@ export default function Progress({
|
||||
}) {
|
||||
const indeterminate = value == null;
|
||||
const showShimmer = shimmer ?? !indeterminate;
|
||||
const clamped = indeterminate ? 100 : Math.max(0, Math.min(100, value));
|
||||
const clamped = indeterminate ? null : Math.max(0, Math.min(100, value));
|
||||
|
||||
return (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={indeterminate ? undefined : clamped}
|
||||
<RadixProgress.Root
|
||||
value={clamped}
|
||||
max={100}
|
||||
className={`ui-progress ui-progress--${tone} ui-progress--size-${size} ${indeterminate ? 'is-indeterminate' : ''} ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
<div
|
||||
<RadixProgress.Indicator
|
||||
className={`ui-progress__fill ${showShimmer ? 'has-shimmer' : ''}`}
|
||||
style={indeterminate ? undefined : { width: `${clamped}%` }}
|
||||
/>
|
||||
</div>
|
||||
</RadixProgress.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import React from 'react';
|
||||
import * as ToggleGroup from '@radix-ui/react-toggle-group';
|
||||
import './Segmented.css';
|
||||
|
||||
/**
|
||||
* Segmented — compact segmented control for small option sets.
|
||||
* Good for density pickers (S/M/L), view toggles, mode switches.
|
||||
* Backed by @radix-ui/react-toggle-group for keyboard navigation
|
||||
* and proper aria-pressed state management.
|
||||
*
|
||||
* @param items array of { value, label } — label is short text
|
||||
* @param value currently selected `value`
|
||||
* @param items array of { value, label, title? }
|
||||
* @param value currently selected `value`
|
||||
* @param onChange (value) => void
|
||||
* @param size 'xs' | 'sm'
|
||||
* @param size 'xs' | 'sm'
|
||||
*/
|
||||
export default function Segmented({
|
||||
items = [],
|
||||
@@ -19,26 +21,26 @@ export default function Segmented({
|
||||
...rest
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
<ToggleGroup.Root
|
||||
type="single"
|
||||
value={value}
|
||||
onValueChange={(val) => {
|
||||
// Radix fires '' when you re-click the active item; ignore that
|
||||
if (val) onChange?.(val);
|
||||
}}
|
||||
className={`ui-seg ui-seg--size-${size} ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const active = value === item.value;
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
className={`ui-seg__opt ${active ? 'is-active' : ''}`}
|
||||
onClick={() => onChange?.(item.value)}
|
||||
title={item.title || undefined}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{items.map((item) => (
|
||||
<ToggleGroup.Item
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
className={`ui-seg__opt ${value === item.value ? 'is-active' : ''}`}
|
||||
title={item.title || undefined}
|
||||
>
|
||||
{item.label}
|
||||
</ToggleGroup.Item>
|
||||
))}
|
||||
</ToggleGroup.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* ── Slider primitive ───────────────────────────────────────────── */
|
||||
/* ── Slider primitive (Radix UI) ──────────────────────────────── */
|
||||
|
||||
.ui-slider { width: 100%; display: flex; flex-direction: column; gap: var(--space-1); }
|
||||
.ui-slider__label {
|
||||
@@ -13,53 +13,51 @@
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.ui-slider__input {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
/* Radix Slider Root */
|
||||
.ui-slider__root {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Track */
|
||||
.ui-slider__track {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Filled range */
|
||||
.ui-slider__range {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--color-brand);
|
||||
}
|
||||
|
||||
/* Thumb */
|
||||
.ui-slider__thumb {
|
||||
display: block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-fg);
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--color-bg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform var(--dur-fast) var(--ease-spring), background var(--dur-fast);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
/* painted track = filled portion (brand) + rest (muted) */
|
||||
background:
|
||||
linear-gradient(to right,
|
||||
var(--color-brand) 0%,
|
||||
var(--color-brand) var(--ui-slider-pct, 0%),
|
||||
rgba(255, 255, 255, 0.08) var(--ui-slider-pct, 0%),
|
||||
rgba(255, 255, 255, 0.08) 100%);
|
||||
transition: background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.ui-slider__input::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-fg);
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--color-bg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform var(--dur-fast) var(--ease-spring), background var(--dur-fast);
|
||||
}
|
||||
.ui-slider__input::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
||||
.ui-slider__input::-webkit-slider-thumb:active { transform: scale(1.1); background: var(--color-brand); }
|
||||
|
||||
.ui-slider__input::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-fg);
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--color-bg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform var(--dur-fast) var(--ease-spring), background var(--dur-fast);
|
||||
}
|
||||
.ui-slider__input::-moz-range-thumb:hover { transform: scale(1.25); }
|
||||
.ui-slider__input::-moz-range-thumb:active { transform: scale(1.1); background: var(--color-brand); }
|
||||
|
||||
.ui-slider__input:focus-visible { box-shadow: var(--focus-ring); }
|
||||
.ui-slider__thumb:hover { transform: scale(1.25); }
|
||||
.ui-slider__thumb:active { transform: scale(1.1); background: var(--color-brand); }
|
||||
.ui-slider__thumb:focus-visible { box-shadow: var(--focus-ring); }
|
||||
|
||||
.ui-slider__value {
|
||||
font-size: var(--text-xs);
|
||||
@@ -75,6 +73,6 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* size sm: shorter value bubble */
|
||||
.ui-slider--size-sm .ui-slider__input { height: 2px; }
|
||||
/* size sm: shorter */
|
||||
.ui-slider--size-sm .ui-slider__track { height: 2px; }
|
||||
.ui-slider--size-sm .ui-slider__value { font-size: var(--text-2xs); padding: 0 4px; }
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import React, { forwardRef, useId } from 'react';
|
||||
import * as RadixSlider from '@radix-ui/react-slider';
|
||||
import './Slider.css';
|
||||
|
||||
/**
|
||||
* Slider — styled horizontal range input.
|
||||
* Backed by @radix-ui/react-slider for full keyboard accessibility
|
||||
* (arrow keys, Home/End) and proper ARIA value announcements.
|
||||
*
|
||||
* @param value controlled number
|
||||
* @param onChange receives the new number (not the event)
|
||||
* @param min, max, step standard HTMLInputRange props
|
||||
* @param min, max, step standard range props
|
||||
* @param format optional (v) => string for the value bubble
|
||||
* @param showValue show the trailing value bubble (default true)
|
||||
* @param label optional small label above the track
|
||||
@@ -29,25 +32,27 @@ const Slider = forwardRef(function Slider(
|
||||
ref,
|
||||
) {
|
||||
const id = useId();
|
||||
const pct = ((Number(value) - min) / (max - min)) * 100;
|
||||
|
||||
return (
|
||||
<div className={`ui-slider ui-slider--size-${size} ${className}`}>
|
||||
{label && <label htmlFor={id} className="ui-slider__label">{label}</label>}
|
||||
<div className="ui-slider__row">
|
||||
<input
|
||||
<RadixSlider.Root
|
||||
ref={ref}
|
||||
id={id}
|
||||
type="range"
|
||||
className="ui-slider__input"
|
||||
value={value}
|
||||
className="ui-slider__root"
|
||||
value={[Number(value)]}
|
||||
onValueChange={([v]) => onChange?.(v)}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={(e) => onChange?.(Number(e.target.value))}
|
||||
style={{ '--ui-slider-pct': `${pct}%` }}
|
||||
{...rest}
|
||||
/>
|
||||
>
|
||||
<RadixSlider.Track className="ui-slider__track">
|
||||
<RadixSlider.Range className="ui-slider__range" />
|
||||
</RadixSlider.Track>
|
||||
<RadixSlider.Thumb className="ui-slider__thumb" />
|
||||
</RadixSlider.Root>
|
||||
{showValue && (
|
||||
<span className="ui-slider__value" aria-live="polite">
|
||||
{format(value)}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import React from 'react';
|
||||
import * as RadixTabs from '@radix-ui/react-tabs';
|
||||
import './Tabs.css';
|
||||
|
||||
/**
|
||||
* Tabs — pill-style segmented tab group.
|
||||
* Tabs — pill-style segmented tab group, backed by @radix-ui/react-tabs.
|
||||
*
|
||||
* Provides roving tabindex, arrow-key navigation, and proper
|
||||
* aria-selected / role="tab" / role="tablist" attributes.
|
||||
*
|
||||
* @param items array of { id, label, icon?, accent? }
|
||||
* @param value currently selected id
|
||||
@@ -20,29 +24,31 @@ export default function Tabs({
|
||||
...rest
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
className={`ui-tabs ui-tabs--${variant} ui-tabs--size-${size} ${className}`}
|
||||
{...rest}
|
||||
<RadixTabs.Root
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
activationMode="manual"
|
||||
>
|
||||
{items.map((item) => {
|
||||
const active = value === item.id;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
tabIndex={active ? 0 : -1}
|
||||
className={`ui-tabs__tab ${active ? 'is-active' : ''}`}
|
||||
onClick={() => onChange?.(item.id)}
|
||||
style={active && item.accent ? { '--ui-tab-accent': item.accent } : undefined}
|
||||
>
|
||||
{Icon && <Icon size={12} className="ui-tabs__icon" />}
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<RadixTabs.List
|
||||
className={`ui-tabs ui-tabs--${variant} ui-tabs--size-${size} ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const active = value === item.id;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<RadixTabs.Trigger
|
||||
key={item.id}
|
||||
value={item.id}
|
||||
className={`ui-tabs__tab ${active ? 'is-active' : ''}`}
|
||||
style={active && item.accent ? { '--ui-tab-accent': item.accent } : undefined}
|
||||
>
|
||||
{Icon && <Icon size={12} className="ui-tabs__icon" />}
|
||||
<span>{item.label}</span>
|
||||
</RadixTabs.Trigger>
|
||||
);
|
||||
})}
|
||||
</RadixTabs.List>
|
||||
</RadixTabs.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import React, { useState, useRef, useId, useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import * as RadixTooltip from '@radix-ui/react-tooltip';
|
||||
import './Tooltip.css';
|
||||
|
||||
/**
|
||||
* Tooltip — keyboard-accessible replacement for `title=`.
|
||||
* Backed by @radix-ui/react-tooltip for collision-aware positioning.
|
||||
*
|
||||
* Shows on hover and on keyboard focus. Dismisses on Escape.
|
||||
* Wraps exactly one child; forwards aria-describedby to it.
|
||||
*
|
||||
* @param content tooltip body (string or node)
|
||||
* @param placement 'top' | 'bottom' | 'left' | 'right'
|
||||
* @param delay ms before showing (default 300)
|
||||
* @param content tooltip body (string or node)
|
||||
* @param placement 'top' | 'bottom' | 'left' | 'right'
|
||||
* @param delay ms before showing (default 300)
|
||||
*/
|
||||
export default function Tooltip({
|
||||
content,
|
||||
@@ -17,51 +16,28 @@ export default function Tooltip({
|
||||
delay = 300,
|
||||
children,
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const timer = useRef(null);
|
||||
const id = useId();
|
||||
|
||||
const show = () => {
|
||||
clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => setOpen(true), delay);
|
||||
};
|
||||
const hide = () => {
|
||||
clearTimeout(timer.current);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e) => { if (e.key === 'Escape') hide(); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => () => clearTimeout(timer.current), []);
|
||||
|
||||
if (!content) return children;
|
||||
if (!React.isValidElement(children)) return children;
|
||||
|
||||
const trigger = React.cloneElement(children, {
|
||||
'aria-describedby': open ? id : children.props['aria-describedby'],
|
||||
onMouseEnter: (...args) => { show(); children.props.onMouseEnter?.(...args); },
|
||||
onMouseLeave: (...args) => { hide(); children.props.onMouseLeave?.(...args); },
|
||||
onFocus: (...args) => { show(); children.props.onFocus?.(...args); },
|
||||
onBlur: (...args) => { hide(); children.props.onBlur?.(...args); },
|
||||
});
|
||||
// Map our placement names to Radix side names
|
||||
const sideMap = { top: 'top', bottom: 'bottom', left: 'left', right: 'right' };
|
||||
const side = sideMap[placement] || 'top';
|
||||
|
||||
return (
|
||||
<span className="ui-tooltip-wrap">
|
||||
{trigger}
|
||||
{open && (
|
||||
<span
|
||||
id={id}
|
||||
role="tooltip"
|
||||
className={`ui-tooltip ui-tooltip--${placement}`}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<RadixTooltip.Provider delayDuration={delay}>
|
||||
<RadixTooltip.Root>
|
||||
<RadixTooltip.Trigger asChild>
|
||||
{children}
|
||||
</RadixTooltip.Trigger>
|
||||
<RadixTooltip.Portal>
|
||||
<RadixTooltip.Content
|
||||
side={side}
|
||||
sideOffset={5}
|
||||
className={`ui-tooltip ui-tooltip--${placement}`}
|
||||
>
|
||||
{content}
|
||||
</RadixTooltip.Content>
|
||||
</RadixTooltip.Portal>
|
||||
</RadixTooltip.Root>
|
||||
</RadixTooltip.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [tailwindcss(), react()],
|
||||
clearScreen: false,
|
||||
resolve: {
|
||||
preserveSymlinks: false,
|
||||
|
||||
@@ -4,13 +4,16 @@
|
||||
"private": true,
|
||||
"packageManager": "bun@1.3.11",
|
||||
"scripts": {
|
||||
"setup:api": "uv sync && uv run python scripts/setup_cudnn.py",
|
||||
"dev:api": "uv run uvicorn main:app --app-dir backend --host 0.0.0.0 --port 3900 --reload",
|
||||
"dev:frontend": "bun run --cwd frontend dev",
|
||||
"dev:desktop": "bun run --cwd frontend desktop",
|
||||
"wait:api": "wait-on -t 120000 http-get://localhost:3900/system/info",
|
||||
"wait:api": "wait-on -t 300000 http-get://localhost:3900/system/info",
|
||||
"wait:frontend": "wait-on -t 120000 http://localhost:5173",
|
||||
"dev": "concurrently -n api,fe -c green,cyan --kill-others-on-fail \"bun run dev:api\" \"bun run wait:api && bun run dev:frontend\"",
|
||||
"desktop": "concurrently -n api,app -c green,magenta --kill-others-on-fail \"bun run dev:api\" \"bun run wait:api && bun run dev:desktop\"",
|
||||
"predev": "kill-port 3900 3901 || true",
|
||||
"dev": "bun run setup:api && concurrently -n api,fe -c green,cyan --kill-others-on-fail \"bun run dev:api\" \"bun run wait:api && bun run dev:frontend\"",
|
||||
"predesktop": "kill-port 3900 3901 || true",
|
||||
"desktop": "bun run setup:api && concurrently -n api,app -c green,magenta --kill-others-on-fail \"bun run dev:api\" \"bun run wait:api && bun run dev:desktop\"",
|
||||
"build": "turbo run build",
|
||||
"start": "turbo run start",
|
||||
"test:frontend": "node --test tests/frontend/*.test.mjs"
|
||||
@@ -20,6 +23,8 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1",
|
||||
"kill-port-process": "^4.0.2",
|
||||
"playwright": "^1.59.1",
|
||||
"turbo": "^2.9.6",
|
||||
"typescript": "^6.0.3",
|
||||
"wait-on": "^9.0.5"
|
||||
|
||||
|
Before Width: | Height: | Size: 962 KiB After Width: | Height: | Size: 358 KiB |
@@ -0,0 +1,103 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
deviceScaleFactor: 2,
|
||||
colorScheme: 'dark',
|
||||
});
|
||||
const page = await ctx.newPage();
|
||||
const dir = 'docs';
|
||||
|
||||
await page.goto('http://localhost:3901', { waitUntil: 'networkidle', timeout: 15000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Helper: click a sidebar icon by index (0-based, top to bottom)
|
||||
async function clickSidebarIcon(index) {
|
||||
const icons = page.locator('nav button, nav a, aside button, aside a, [class*="sidebar"] button, [class*="sidebar"] a, [class*="Sidebar"] button');
|
||||
const count = await icons.count();
|
||||
if (index < count) {
|
||||
await icons.nth(index).click();
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Gallery / Voice Library — click the icon that looks like a folder/library
|
||||
// Try to find gallery-related navigation
|
||||
try {
|
||||
// Look for gallery/library nav item
|
||||
const galleryBtn = page.locator('button:has-text("Gallery"), a:has-text("Gallery"), [data-tab="gallery"], [title*="allery"], [title*="ibrary"]').first();
|
||||
if (await galleryBtn.isVisible({ timeout: 1000 })) {
|
||||
await galleryBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
await page.screenshot({ path: `${dir}/screenshot-gallery.png`, fullPage: false });
|
||||
console.log('✅ screenshot-gallery.png');
|
||||
}
|
||||
} catch(e) { console.log('Gallery not found via text, trying sidebar icons'); }
|
||||
|
||||
// 2. Settings page
|
||||
try {
|
||||
const settingsBtn = page.locator('button:has-text("Settings"), a:has-text("Settings"), [data-tab="settings"], [title*="etting"]').first();
|
||||
if (await settingsBtn.isVisible({ timeout: 1000 })) {
|
||||
await settingsBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
await page.screenshot({ path: `${dir}/screenshot-settings.png`, fullPage: false });
|
||||
console.log('✅ screenshot-settings.png');
|
||||
}
|
||||
} catch(e) { console.log('Settings not found via text'); }
|
||||
|
||||
// 3. Try clicking each sidebar icon to discover hidden views
|
||||
// The sidebar icons are the vertical strip on the left
|
||||
const sideIcons = page.locator('aside > div > button, aside > button, nav[class*="side"] button, [class*="sidebar"] > button, [class*="Sidebar"] > button');
|
||||
const iconCount = await sideIcons.count();
|
||||
console.log(`Found ${iconCount} sidebar icons`);
|
||||
|
||||
// Click each icon and screenshot if it reveals a new view
|
||||
const captured = new Set();
|
||||
for (let i = 0; i < Math.min(iconCount, 10); i++) {
|
||||
try {
|
||||
await sideIcons.nth(i).click();
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Get the page title/header to name the screenshot
|
||||
const header = await page.locator('h1, h2, [class*="title"], [class*="header"] span').first().textContent().catch(() => '');
|
||||
const name = (header || `view-${i}`).toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 30);
|
||||
|
||||
if (!captured.has(name)) {
|
||||
captured.add(name);
|
||||
await page.screenshot({ path: `${dir}/screenshot-${name}.png`, fullPage: false });
|
||||
console.log(`✅ screenshot-${name}.png (icon ${i})`);
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// 4. Also try bottom-bar items (LOGS, Backend, Frontend, Tauri)
|
||||
try {
|
||||
const logsBtn = page.locator('button:has-text("LOGS"), [class*="log"] button').first();
|
||||
if (await logsBtn.isVisible({ timeout: 1000 })) {
|
||||
await logsBtn.click();
|
||||
await page.waitForTimeout(1500);
|
||||
// Expand log panel if collapsed
|
||||
await page.screenshot({ path: `${dir}/screenshot-logs.png`, fullPage: false });
|
||||
console.log('✅ screenshot-logs.png');
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// 5. A/B Compare button
|
||||
try {
|
||||
// First go to launchpad
|
||||
await page.goto('http://localhost:3901', { waitUntil: 'networkidle', timeout: 10000 });
|
||||
await page.waitForTimeout(1500);
|
||||
const abBtn = page.locator('button:has-text("A/B"), button:has-text("Compare")').first();
|
||||
if (await abBtn.isVisible({ timeout: 1000 })) {
|
||||
await abBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
await page.screenshot({ path: `${dir}/screenshot-ab-compare.png`, fullPage: false });
|
||||
console.log('✅ screenshot-ab-compare.png');
|
||||
}
|
||||
} catch(e) { console.log('A/B Compare not found'); }
|
||||
|
||||
await browser.close();
|
||||
console.log('All done!');
|
||||
})();
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Post-install setup for CUDA environments.
|
||||
|
||||
Ensures cuDNN 8 libraries are available for CTranslate2 (faster-whisper /
|
||||
WhisperX) alongside PyTorch 2.8+'s cuDNN 9. Run automatically as part of
|
||||
`bun run setup:api` — no user action required.
|
||||
|
||||
Cross-platform:
|
||||
- Linux: installs .so.8 libs into cudnn8_compat/
|
||||
- Windows: installs .dll libs into cudnn8_compat/
|
||||
- macOS: skipped (no CUDA)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import glob
|
||||
|
||||
|
||||
def _find_compat_dir():
|
||||
"""Return the cudnn8_compat target directory, auto-detecting venv layout."""
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
project_root = os.path.dirname(script_dir)
|
||||
venv_dir = os.path.join(project_root, ".venv")
|
||||
|
||||
if not os.path.isdir(venv_dir):
|
||||
return None
|
||||
|
||||
if sys.platform == "win32":
|
||||
# Windows: .venv/Lib/site-packages/
|
||||
sp = os.path.join(venv_dir, "Lib", "site-packages", "cudnn8_compat")
|
||||
else:
|
||||
# Linux: .venv/lib/pythonX.Y/site-packages/
|
||||
pyver = f"python{sys.version_info.major}.{sys.version_info.minor}"
|
||||
sp = os.path.join(venv_dir, "lib", pyver, "site-packages", "cudnn8_compat")
|
||||
|
||||
return sp
|
||||
|
||||
|
||||
def _cudnn8_lib_dir(compat_dir):
|
||||
"""Return the cuDNN lib subdirectory within the compat install."""
|
||||
if sys.platform == "win32":
|
||||
return os.path.join(compat_dir, "nvidia", "cudnn", "bin")
|
||||
return os.path.join(compat_dir, "nvidia", "cudnn", "lib")
|
||||
|
||||
|
||||
def _count_cudnn8_libs(lib_dir):
|
||||
"""Count cuDNN 8 shared libraries in the given directory."""
|
||||
if sys.platform == "win32":
|
||||
return len(glob.glob(os.path.join(lib_dir, "cudnn*64_8.dll")))
|
||||
return len(glob.glob(os.path.join(lib_dir, "libcudnn*.so.8")))
|
||||
|
||||
|
||||
def main():
|
||||
# macOS — no CUDA, nothing to do
|
||||
if sys.platform == "darwin":
|
||||
return
|
||||
|
||||
compat_dir = _find_compat_dir()
|
||||
if compat_dir is None:
|
||||
return
|
||||
|
||||
lib_dir = _cudnn8_lib_dir(compat_dir)
|
||||
|
||||
# Already installed?
|
||||
if os.path.isdir(lib_dir):
|
||||
n = _count_cudnn8_libs(lib_dir)
|
||||
if n >= 5:
|
||||
print(f"✓ cuDNN 8 compat: {n} libraries ready")
|
||||
return
|
||||
|
||||
# Check if CUDA is available before installing GPU-only libs
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", "import torch; print(torch.cuda.is_available())"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if result.stdout.strip() != "True":
|
||||
print("✓ No CUDA — cuDNN 8 compat not needed")
|
||||
return
|
||||
except Exception:
|
||||
pass # Can't detect CUDA — install anyway, it's harmless on CPU
|
||||
|
||||
print("⚙ Installing cuDNN 8 compatibility libraries for CTranslate2...")
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable, "-m", "pip", "install",
|
||||
"--no-deps", "--target", compat_dir,
|
||||
"nvidia-cudnn-cu12==8.9.7.29",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
)
|
||||
n = _count_cudnn8_libs(lib_dir)
|
||||
print(f"✓ cuDNN 8 installed: {n} libraries")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"⚠ cuDNN 8 install failed (transcription may not work on CUDA):")
|
||||
print(f" {(e.stderr or '')[:300]}")
|
||||
except Exception as e:
|
||||
print(f"⚠ cuDNN 8 install skipped: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -28,6 +28,10 @@ def app_client(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("OMNIVOICE_DATA_DIR", str(tmp_path))
|
||||
import core.config as _cfg
|
||||
importlib.reload(_cfg)
|
||||
# Reload core.tasks so TaskManager gets a fresh asyncio.Queue bound to the
|
||||
# current event loop (TestClient creates its own loop per fixture).
|
||||
import core.tasks as _tasks
|
||||
importlib.reload(_tasks)
|
||||
from api.routers import dub_core as _dc
|
||||
importlib.reload(_dc)
|
||||
from api.routers import dub_export as _dx
|
||||
|
||||
@@ -86,7 +86,7 @@ def test_preflight_detects_apple_silicon():
|
||||
"""On mac-ARM, vendor → 'apple' and backend → 'mps'."""
|
||||
if sys.platform != "darwin":
|
||||
pytest.skip("apple-silicon branch only exercisable on darwin")
|
||||
from api.routers.setup import _detect_gpu
|
||||
from api.routers.setup.wizard import _detect_gpu
|
||||
info = _detect_gpu()
|
||||
# mac-Intel CI hosts also hit darwin; only assert vendor if arch matches.
|
||||
import platform as _p
|
||||
@@ -97,8 +97,8 @@ def test_preflight_detects_apple_silicon():
|
||||
|
||||
def test_preflight_handles_missing_nvidia_smi():
|
||||
"""When nvidia-smi is absent, vendor falls through (not nvidia)."""
|
||||
from api.routers.setup import _detect_gpu, _run_cmd # noqa
|
||||
with patch("api.routers.setup._run_cmd", return_value=(-1, "")):
|
||||
from api.routers.setup.wizard import _detect_gpu, _run_cmd # noqa
|
||||
with patch("api.routers.setup.wizard._run_cmd", return_value=(-1, "")):
|
||||
info = _detect_gpu()
|
||||
# On mac-ARM the apple branch returns before _run_cmd; skip that case.
|
||||
import platform as _p
|
||||
@@ -111,7 +111,7 @@ def test_preflight_nvidia_driver_below_min_flags_fail():
|
||||
import platform as _p
|
||||
if sys.platform == "darwin" and _p.machine() == "arm64":
|
||||
pytest.skip("apple-silicon branch returns before nvidia-smi — not reachable")
|
||||
from api.routers import setup as setup_mod
|
||||
from api.routers.setup import wizard as setup_mod
|
||||
|
||||
def fake_run_cmd(args, timeout=2.0):
|
||||
if args and args[0] == "nvidia-smi":
|
||||
@@ -131,7 +131,7 @@ def test_preflight_amd_flags_warn_when_no_rocm_torch():
|
||||
import platform as _p
|
||||
if sys.platform == "darwin" and _p.machine() == "arm64":
|
||||
pytest.skip("apple-silicon branch returns before rocm-smi")
|
||||
from api.routers import setup as setup_mod
|
||||
from api.routers.setup import wizard as setup_mod
|
||||
|
||||
def fake_run_cmd(args, timeout=2.0):
|
||||
if args and args[0] == "rocm-smi":
|
||||
@@ -151,7 +151,7 @@ def test_preflight_amd_flags_warn_when_no_rocm_torch():
|
||||
|
||||
def test_preflight_network_handles_offline():
|
||||
"""_probe_network must gracefully return False on connection error."""
|
||||
from api.routers.setup import _probe_network
|
||||
from api.routers.setup.wizard import _probe_network
|
||||
# Deliberately unreachable host:port
|
||||
assert _probe_network(host="10.255.255.1", timeout=0.3) is False
|
||||
|
||||
@@ -160,7 +160,7 @@ def test_preflight_network_handles_offline():
|
||||
|
||||
def test_preflight_ram_fail_threshold():
|
||||
"""Below _RAM_FAIL_GB → fail status in the RAM check."""
|
||||
from api.routers import setup as setup_mod
|
||||
from api.routers.setup import wizard as setup_mod
|
||||
|
||||
with patch.object(setup_mod, "_ram_gb", return_value=4.0):
|
||||
r = client_factory().get("/setup/preflight").json()
|
||||
@@ -170,7 +170,7 @@ def test_preflight_ram_fail_threshold():
|
||||
|
||||
def test_preflight_ram_warn_threshold():
|
||||
"""Between fail and warn thresholds → warn."""
|
||||
from api.routers import setup as setup_mod
|
||||
from api.routers.setup import wizard as setup_mod
|
||||
|
||||
with patch.object(setup_mod, "_ram_gb", return_value=10.0):
|
||||
r = client_factory().get("/setup/preflight").json()
|
||||
|
||||