Stability pass: DB leaks, App.jsx hooks refactor, desktop bootstrap (#49)
* fix: eliminate DB connection leaks, race conditions, and deprecated asyncio API ## DB Connection Leaks (P0) - Convert 38 raw get_db() calls to db_conn() context manager across 14 router files - Connections are now guaranteed to close even when exceptions are raised - profiles.py create_profile: clean up orphaned audio file if DB insert fails - profiles.py lock_profile: consolidate 3 separate conn.close() error paths ## Race Condition (P1) - Add _dub_jobs_lock (threading.Lock) to protect _dub_jobs dict in dub_pipeline.py - get_job/put_job now thread-safe for concurrent dub sessions ## asyncio Deprecation (P2) - Replace 23 asyncio.get_event_loop() calls with asyncio.get_running_loop() - Prevents DeprecationWarning on Python 3.12+ and future breakage on 3.14 ## Quick Fixes - gallery.py preview_voice: remove filesystem path from error response (P2) - dub_pipeline.py parse_vtt_segments: remove redundant `import re` inside loop (P3) - gallery.py _init_gallery_db: use db_conn() context manager (P2) * refactor: extract hooks, centralize isTauri, add pytest-cov ## Frontend - Extract useTTS hook (150 LOC) — TTS generation, streaming, audio ingestion - Extract useProfiles hook (219 LOC) — voice profile CRUD, lock/unlock, preview - Centralize isTauri detection: dialog.js, VoiceGallery.jsx, Settings.jsx now import from utils/media.js instead of 4 different detection patterns ## Backend - Add pytest-cov to dev dependencies - Baseline coverage: 39% across backend/ (214 tests pass) - Add .coverage to .gitignore * feat: add Vitest + checkJs, extract useDubWorkflow + useAppData hooks ## Frontend Testing (new) - Set up Vitest with jsdom environment + @testing-library/react - 11 tests: utils (isTauri, formatTime, constants) + Zustand store (mode, text, dubStep, pill) - Scripts: 'test' (vitest run), 'test:watch' (vitest), 'test:legacy' (node runner) ## App.jsx Decomposition (continued) - Extract useDubWorkflow hook (387 LOC) — upload, ingest, transcribe SSE, translate, generate SSE, abort, stop, cleanup - Extract useAppData hook (181 LOC) — data loading, localStorage persistence, WebSocket real-time updates, model-status pill management ## TypeScript checkJs - Enable checkJs: true in tsconfig.json for IDE-level type checking - 947 existing errors (informational, not blocking builds) - noImplicitAny remains false to avoid blocking * ci: add Vitest step, fix useProfiles duplicate state ## CI - Add 'Run Vitest (frontend)' step — runs 11 unit tests - Override --checkJs false in CI typecheck to avoid 947 pre-existing errors - Rename legacy test step for clarity ## Hooks - Fix useProfiles to accept loadProfiles from parent (useAppData) instead of managing its own duplicate profiles array * refactor: wire hooks into App.jsx — 2067 → 1129 LOC (-45%) App.jsx now delegates to extracted hooks instead of inline logic: - useAppData: data loading, localStorage, WebSocket, model pill - useProfiles: voice profile CRUD, lock/unlock, preview - useTTS: generation, streaming, audio ingestion - useDubWorkflow: upload, transcribe SSE, translate, generate SSE 988 lines removed. All handler logic lives in focused, independently testable hooks. Store selectors and render JSX stay in App.jsx as the shell. Verified: vite build clean, 11 frontend + 214 backend tests pass. * feat: show real-time percentage on model loading pill Backend: register hf_progress listener during _load_model_sync() so download/weight-loading tqdm events update _loading_detail with a progress percentage (0-99%). get_model_status() now includes a 'progress' field that the frontend polls. Frontend: useAppData reads msQuery.data.progress and calls setPillProgress() — the FloatingPill already renders the percentage text and progress bar width from this value. * fix: prevent FileNotFoundError in desktop bundle during model init transformers >=4.52 calls _can_set_experts_implementation() and _can_set_attn_implementation() during PreTrainedModel.__init__, which open the class source file via open(class_file). In a Tauri desktop bundle, module.__file__ points to a path that doesn't exist on disk, causing: FileNotFoundError: .../omnivoice/models/omnivoice.py Override both classmethods on OmniVoice to return static values without filesystem access. OmniVoice doesn't use MoE experts (return False), but does support flex/flash attn (return True). * fix: sync source dirs on every bootstrap, not just first run The Tauri bootstrap previously only copied omnivoice/ and backend/ to Application Support on the first run. Subsequent app updates kept using stale source files, preventing bug fixes from landing. Now ensure_venv_ready() always syncs both directories from the bundle resources before returning, even when the venv is healthy. This fixes the FileNotFoundError crash where the old omnivoice.py lacked the _can_set_experts_implementation override. * ui: premium setup wizard polish - Primary button: solid gradient fill with hover glow + lift + press - Stepper nav: connected pills with glow ring on active step - Welcome cards: glassmorphism with stagger-in animations, lucide icons, left-border accent strip, hover translate - Preflight panel: colored icon pill backgrounds, stagger-slide entrance - Step transitions: fade+slide animation via keyed wrapper - Footnote: shortened paths (~/ notation), Reveal in Finder button - Recommendation banner: gradient background with accent glow - Compact spacing throughout for denser, professional layout * fix: kill zombie backend on clean+retry bootstrap When clean_and_retry_bootstrap removes the project dir, any old uvicorn process still running from the deleted paths remains alive on port 3900. The subsequent retry_bootstrap sees the port is healthy and attaches to the zombie instead of re-bootstrapping. Now explicitly kill any process on the backend port after cleaning, before calling retry_bootstrap. * feat: integrate speaker clones into dubbing interface, sanitize system environment variables for subprocesses, and improve FFMPEG binary path resolution. * fix: restore docker compose default + drop dead setSeed call - deploy/docker-compose.yml: remove profiles: ["cpu"] from the default service so `docker compose up` matches the comment on line 5. With the profile present, no service auto-started. - frontend/src/App.jsx: drop the setSeed call in restoreHistory. The selector was never reintroduced after the App.jsx hooks split, and there is no seed state in the store — seeds are generated fresh per call in useTTS and only read from history items for display. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address CodeRabbit review — async detection, dub stream, bootstrap fail-fast - backend/services/tts_backend.py: invert async-context detection in _ensure_loaded. The previous code unconditionally caught its own diagnostic RuntimeError and then called asyncio.run() inside a running loop, masking the intended error message. - frontend/src/hooks/useDubWorkflow.js: require a terminal `done` event before reporting dub success. Without this, a dropped stream after partial progress would flip the UI to `done`, refresh history, and play the completion ping as if generation finished. - frontend/src/hooks/useDubWorkflow.js: restore the previous step when tasksCancel() fails. The UI was getting stuck in `stopping` forever on cancel errors. - frontend/src-tauri/src/bootstrap.rs: fail-fast when source sync fails after the existing directory has already been removed. The previous warn-and-continue path could leave the install with no backend/ or omnivoice/ sources and defer the failure to backend startup with a cryptic error. - backend/api/routers/generation.py: add `from e` to the ValueError → HTTPException re-raise (Ruff B904). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: preserve % suffix in TTS generation timer The 100ms timer in useTTS was rewriting generationTime to a plain elapsed-seconds string, which immediately wiped the "(xx%)" download suffix written on the next iteration of the response-body loop. The real-time percentage was flickering on/off as a result. Read the previous value inside the setter and reattach any existing percent suffix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
20ade687f6
commit
a1ef66c321
@@ -75,14 +75,18 @@ jobs:
|
||||
working-directory: frontend
|
||||
run: bun install
|
||||
|
||||
# checkJs is true in tsconfig for IDE feedback, but 947 pre-existing
|
||||
# JS errors remain. Override to false in CI so only .ts files block.
|
||||
- name: Frontend typecheck
|
||||
working-directory: frontend
|
||||
run: bunx tsc --noEmit
|
||||
run: bunx tsc --noEmit --checkJs false
|
||||
|
||||
# Invoke node directly (not `bun run test`) because `bun run` auto-aliases
|
||||
# `node` to `bun` in script bodies, and bun doesn't support
|
||||
# --experimental-strip-types.
|
||||
- name: Run frontend node:test
|
||||
- name: Run Vitest (frontend)
|
||||
working-directory: frontend
|
||||
run: bunx vitest run
|
||||
|
||||
# Legacy node:test runner for tests/frontend/*.test.mjs
|
||||
- name: Run frontend node:test (legacy)
|
||||
working-directory: frontend
|
||||
run: node --experimental-strip-types --no-warnings --test ../tests/frontend/*.test.mjs
|
||||
|
||||
|
||||
@@ -85,3 +85,4 @@ test-results/
|
||||
# Research repos (local only)
|
||||
research/
|
||||
marketing.md
|
||||
.coverage
|
||||
|
||||
@@ -97,7 +97,7 @@ async def _run_batch_pipeline(job_id: str, job: dict):
|
||||
import tempfile
|
||||
import soundfile as sf
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
video_path = job["video_path"]
|
||||
langs = job["langs"]
|
||||
batch_dir = os.path.join(DATA_DIR, "batch", job_id)
|
||||
@@ -275,16 +275,13 @@ async def _run_batch_pipeline(job_id: str, job: dict):
|
||||
|
||||
# Use voice_id if provided
|
||||
if job.get("voice_id"):
|
||||
from core.db import get_db
|
||||
from core.db import db_conn
|
||||
from core.config import VOICES_DIR as _VD
|
||||
conn = get_db()
|
||||
try:
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?",
|
||||
(job["voice_id"],),
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if row:
|
||||
if row["is_locked"] and row["locked_audio_path"]:
|
||||
ref_audio = os.path.join(_VD, row["locked_audio_path"])
|
||||
|
||||
@@ -81,7 +81,7 @@ async def transcribe_audio(
|
||||
return result, backend.id
|
||||
|
||||
from services.model_manager import _gpu_pool
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
t0 = time.perf_counter()
|
||||
result, engine_id = await loop.run_in_executor(_gpu_pool, _run)
|
||||
elapsed = round(time.perf_counter() - t0, 2)
|
||||
|
||||
@@ -204,7 +204,7 @@ async def _transcribe_buffer(chunks: list[bytes]) -> str:
|
||||
result = backend.transcribe(tmp, word_timestamps=False)
|
||||
return result.get("text", "")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
text = await loop.run_in_executor(_gpu_pool, _run)
|
||||
return text.strip()
|
||||
finally:
|
||||
@@ -252,7 +252,7 @@ async def _transcribe_buffer_full(chunks: list[bytes]) -> dict:
|
||||
"engine": backend.id,
|
||||
}
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(_gpu_pool, _run)
|
||||
finally:
|
||||
try:
|
||||
|
||||
@@ -15,7 +15,7 @@ from typing import Optional, List
|
||||
from fastapi import APIRouter, File, Form, UploadFile, HTTPException, Query
|
||||
from fastapi.responses import FileResponse, Response, StreamingResponse, JSONResponse
|
||||
|
||||
from core.db import get_db, db_conn
|
||||
from core.db import db_conn
|
||||
from core.config import DATA_DIR, DUB_DIR, PREVIEW_DIR, VOICES_DIR
|
||||
from core.tasks import task_manager
|
||||
from core import event_bus
|
||||
@@ -87,23 +87,16 @@ def dub_abort(job_id: str):
|
||||
|
||||
@router.get("/dub/history")
|
||||
def list_dub_history():
|
||||
conn = get_db()
|
||||
try:
|
||||
with db_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM dub_history ORDER BY created_at DESC LIMIT 30").fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@router.delete("/dub/history")
|
||||
def clear_dub_history():
|
||||
"""Delete persisted dub rows and their on-disk dirs (scoped to known IDs)."""
|
||||
conn = get_db()
|
||||
try:
|
||||
with db_conn() as conn:
|
||||
ids = [r["id"] for r in conn.execute("SELECT id FROM dub_history").fetchall()]
|
||||
conn.execute("DELETE FROM dub_history")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
for jid in ids:
|
||||
safe = _safe_job_dir(jid)
|
||||
if safe and os.path.isdir(safe):
|
||||
@@ -318,7 +311,7 @@ async def dub_transcribe_stream(job_id: str):
|
||||
return
|
||||
import math
|
||||
import tempfile
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _load():
|
||||
audio_np, sr = sf.read(asr_audio_target, dtype="float32")
|
||||
@@ -630,7 +623,7 @@ async def dub_transcribe(job_id: str):
|
||||
return segments
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
segments_result = await loop.run_in_executor(_gpu_pool, _transcribe)
|
||||
except asyncio.CancelledError:
|
||||
|
||||
@@ -7,7 +7,7 @@ import logging
|
||||
from fastapi import APIRouter, HTTPException, Query, Response
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
|
||||
from core.db import get_db
|
||||
from core.db import db_conn
|
||||
from core.config import DUB_DIR
|
||||
from core.tasks import task_manager
|
||||
from api.routers.dub_core import _get_job
|
||||
|
||||
@@ -8,7 +8,7 @@ import torchaudio
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from core.db import get_db
|
||||
from core.db import db_conn
|
||||
from core.config import DUB_DIR, VOICES_DIR
|
||||
from core.tasks import task_manager
|
||||
from schemas.requests import DubRequest
|
||||
@@ -131,11 +131,8 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
profile_id = None # prevent the voice_profiles lookup below
|
||||
|
||||
if profile_id:
|
||||
conn = get_db()
|
||||
try:
|
||||
with db_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if row:
|
||||
if row["is_locked"] and row["locked_audio_path"]:
|
||||
ref_audio = os.path.join(VOICES_DIR, row["locked_audio_path"])
|
||||
@@ -204,7 +201,7 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
except Exception as e:
|
||||
logger.debug("direction parse skipped for %s: %s", getattr(seg, 'id', '?'), e)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
# Fast-preview mode for interactive edits — trade ~10–20 %
|
||||
# quality for ~2× speed by dropping flow-matching steps.
|
||||
@@ -439,13 +436,10 @@ async def preview_segment(job_id: str, req: SegmentPreviewRequest):
|
||||
|
||||
instruct_str = req.instruct
|
||||
if pid:
|
||||
conn = get_db()
|
||||
try:
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (pid,)
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if row:
|
||||
if row["is_locked"] and row["locked_audio_path"]:
|
||||
ref_audio = os.path.join(VOICES_DIR, row["locked_audio_path"])
|
||||
@@ -477,7 +471,7 @@ async def preview_segment(job_id: str, req: SegmentPreviewRequest):
|
||||
)
|
||||
return normalize_audio(mastered, target_dBFS=-2.0)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
audio_tensor = await loop.run_in_executor(_gpu_pool, _gen)
|
||||
|
||||
sr = getattr(_model, "sampling_rate", 24000)
|
||||
|
||||
@@ -126,7 +126,7 @@ async def dub_translate(req: TranslateRequest):
|
||||
provider = (req.provider if req.provider else os.environ.get("TRANSLATE_PROVIDER", "google")).lower()
|
||||
lang_code = TRANSLATE_CODES.get(req.target_lang, req.target_lang)
|
||||
api_key = os.environ.get("TRANSLATE_API_KEY", "")
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
src_lang = _resolve_source_lang(req)
|
||||
|
||||
# Offline NLLB Transformer Translation
|
||||
|
||||
@@ -6,7 +6,7 @@ import subprocess
|
||||
import platform
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from core.db import get_db
|
||||
from core.db import db_conn
|
||||
from core.config import OUTPUTS_DIR
|
||||
from core import event_bus
|
||||
from schemas.requests import ExportRequest, ExportRecordRequest, RevealRequest
|
||||
@@ -90,15 +90,11 @@ def export_file(req: ExportRequest):
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
export_id = str(uuid.uuid4())[:8]
|
||||
conn = get_db()
|
||||
try:
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO export_history (id, filename, destination_path, mode, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(export_id, req.source_filename, dest, req.mode, time.time()),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
event_bus.emit("export_history", {"action": "exported", "id": export_id})
|
||||
return {"success": True, "id": export_id}
|
||||
|
||||
@@ -106,26 +102,19 @@ def export_file(req: ExportRequest):
|
||||
@router.post("/export/record")
|
||||
def record_export(req: ExportRecordRequest):
|
||||
export_id = str(uuid.uuid4())[:8]
|
||||
conn = get_db()
|
||||
try:
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO export_history (id, filename, destination_path, mode, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(export_id, req.filename, req.destination_path, req.mode, time.time()),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
event_bus.emit("export_history", {"action": "recorded", "id": export_id})
|
||||
return {"success": True, "id": export_id}
|
||||
|
||||
|
||||
@router.get("/export/history")
|
||||
def get_export_history():
|
||||
conn = get_db()
|
||||
try:
|
||||
with db_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM export_history ORDER BY created_at DESC LIMIT 50").fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
|
||||
+175
-200
@@ -11,7 +11,7 @@ from fastapi import APIRouter, File, Form, UploadFile, HTTPException, Query
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.db import get_db
|
||||
from core.db import db_conn
|
||||
from core.config import VOICES_DIR, OUTPUTS_DIR
|
||||
from core import event_bus
|
||||
|
||||
@@ -91,31 +91,29 @@ class VoiceEntry(BaseModel):
|
||||
|
||||
def _init_gallery_db():
|
||||
"""Initialize the voice gallery table."""
|
||||
conn = get_db()
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS voice_gallery (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
character TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
source_type TEXT NOT NULL,
|
||||
source_url TEXT,
|
||||
audio_path TEXT NOT NULL,
|
||||
duration REAL NOT NULL,
|
||||
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()
|
||||
with db_conn() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS voice_gallery (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
character TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
source_type TEXT NOT NULL,
|
||||
source_url TEXT,
|
||||
audio_path TEXT NOT NULL,
|
||||
duration REAL NOT NULL,
|
||||
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")
|
||||
|
||||
|
||||
@router.get("/gallery/categories")
|
||||
@@ -131,7 +129,6 @@ def list_voices(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
):
|
||||
"""List voices in the gallery, optionally filtered by category or search."""
|
||||
conn = get_db()
|
||||
query = "SELECT * FROM voice_gallery"
|
||||
params = []
|
||||
conditions = []
|
||||
@@ -148,8 +145,8 @@ def list_voices(
|
||||
query += " ORDER BY created_at DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
conn.close()
|
||||
with db_conn() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
results = []
|
||||
for row in rows:
|
||||
@@ -162,11 +159,10 @@ def list_voices(
|
||||
@router.get("/gallery/voices/{voice_id}")
|
||||
def get_voice(voice_id: str):
|
||||
"""Get a specific voice from the gallery."""
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_gallery WHERE id = ?", (voice_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_gallery WHERE id = ?", (voice_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Voice not found")
|
||||
r = dict(row)
|
||||
@@ -177,24 +173,21 @@ def get_voice(voice_id: str):
|
||||
@router.delete("/gallery/voices/{voice_id}")
|
||||
def delete_voice(voice_id: str):
|
||||
"""Delete a voice from the gallery."""
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
"SELECT audio_path FROM voice_gallery WHERE id = ?", (voice_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Voice not found")
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT audio_path FROM voice_gallery WHERE id = ?", (voice_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Voice not found")
|
||||
|
||||
audio_path = row["audio_path"]
|
||||
if audio_path and os.path.exists(audio_path):
|
||||
try:
|
||||
os.remove(audio_path)
|
||||
except Exception:
|
||||
pass
|
||||
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 = ?", (voice_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
conn.execute("DELETE FROM voice_gallery WHERE id = ?", (voice_id,))
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@@ -303,29 +296,28 @@ async def download_youtube_clip(
|
||||
final_path = Path(output_path)
|
||||
actual_path.rename(final_path)
|
||||
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO voice_gallery
|
||||
(id, name, character, category, source_type, source_url, audio_path, duration, description, tags, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
voice_id,
|
||||
character_name,
|
||||
character_name,
|
||||
category,
|
||||
"youtube",
|
||||
video_url,
|
||||
output_path,
|
||||
duration,
|
||||
description,
|
||||
json.dumps([character_name.lower(), category]),
|
||||
time.time(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
conn = db_conn()
|
||||
with conn as c:
|
||||
c.execute(
|
||||
"""
|
||||
INSERT INTO voice_gallery
|
||||
(id, name, character, category, source_type, source_url, audio_path, duration, description, tags, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
voice_id,
|
||||
character_name,
|
||||
character_name,
|
||||
category,
|
||||
"youtube",
|
||||
video_url,
|
||||
output_path,
|
||||
duration,
|
||||
description,
|
||||
json.dumps([character_name.lower(), category]),
|
||||
time.time(),
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -364,29 +356,27 @@ async def upload_voice_clip(
|
||||
except Exception:
|
||||
duration = 10.0
|
||||
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO voice_gallery
|
||||
(id, name, character, category, source_type, source_url, audio_path, duration, description, tags, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
voice_id,
|
||||
name,
|
||||
character,
|
||||
category,
|
||||
"upload",
|
||||
None,
|
||||
audio_path,
|
||||
duration,
|
||||
description,
|
||||
json.dumps([character.lower(), category]),
|
||||
time.time(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO voice_gallery
|
||||
(id, name, character, category, source_type, source_url, audio_path, duration, description, tags, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
voice_id,
|
||||
name,
|
||||
character,
|
||||
category,
|
||||
"upload",
|
||||
None,
|
||||
audio_path,
|
||||
duration,
|
||||
description,
|
||||
json.dumps([character.lower(), category]),
|
||||
time.time(),
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
"id": voice_id,
|
||||
@@ -402,41 +392,37 @@ async def save_voice_as_profile(
|
||||
profile_name: str = Query(..., description="Name for the voice profile"),
|
||||
):
|
||||
"""Save a gallery voice as a voice profile for cloning."""
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_gallery WHERE id = ?", (voice_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_gallery WHERE id = ?", (voice_id,)
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Voice not found")
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Voice not found")
|
||||
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
import shutil
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
import shutil
|
||||
|
||||
ext = os.path.splitext(row["audio_path"])[1]
|
||||
new_audio_path = os.path.join(VOICES_DIR, f"{profile_id}{ext}")
|
||||
shutil.copy(row["audio_path"], new_audio_path)
|
||||
ext = os.path.splitext(row["audio_path"])[1]
|
||||
new_audio_path = os.path.join(VOICES_DIR, f"{profile_id}{ext}")
|
||||
shutil.copy(row["audio_path"], new_audio_path)
|
||||
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO voice_profiles (id, name, ref_audio_path, ref_text, instruct, language, seed, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
profile_id,
|
||||
profile_name,
|
||||
f"{profile_id}{ext}",
|
||||
row["description"] or "",
|
||||
row["character"] or "",
|
||||
"Auto",
|
||||
None,
|
||||
time.time(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO voice_profiles (id, name, ref_audio_path, ref_text, instruct, language, seed, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
profile_id,
|
||||
profile_name,
|
||||
f"{profile_id}{ext}",
|
||||
row["description"] or "",
|
||||
row["character"] or "",
|
||||
"Auto",
|
||||
None,
|
||||
time.time(),
|
||||
),
|
||||
)
|
||||
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
||||
|
||||
return {"profile_id": profile_id, "name": profile_name}
|
||||
@@ -445,11 +431,10 @@ async def save_voice_as_profile(
|
||||
@router.get("/gallery/voices/{voice_id}/preview")
|
||||
def preview_voice(voice_id: str):
|
||||
"""Get a voice clip for preview playback."""
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
"SELECT audio_path FROM voice_gallery WHERE id = ?", (voice_id,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT audio_path FROM voice_gallery WHERE id = ?", (voice_id,)
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Voice not found")
|
||||
@@ -475,7 +460,7 @@ def preview_voice(voice_id: str):
|
||||
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Audio not found: abs={is_absolute}, exists={path_exists}, path={audio_path}",
|
||||
detail="Audio file not found. It may have been deleted or moved.",
|
||||
)
|
||||
|
||||
|
||||
@@ -484,35 +469,31 @@ def preview_voice(voice_id: str):
|
||||
@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")
|
||||
with db_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM voice_gallery WHERE id = ?", (voice_id,)).fetchone()
|
||||
if not row:
|
||||
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"])
|
||||
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": []}
|
||||
if not updates:
|
||||
return {"success": True, "updated": []}
|
||||
|
||||
params.append(voice_id)
|
||||
conn.execute(f"UPDATE voice_gallery SET {', '.join(updates)} WHERE id = ?", params)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
params.append(voice_id)
|
||||
conn.execute(f"UPDATE voice_gallery SET {', '.join(updates)} WHERE id = ?", params)
|
||||
return {"success": True, "updated": list(body.keys())}
|
||||
|
||||
|
||||
@@ -523,58 +504,52 @@ def batch_delete_voices(body: dict):
|
||||
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()
|
||||
with db_conn() as conn:
|
||||
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
|
||||
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")
|
||||
with db_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM voice_gallery WHERE id = ?", (voice_id,)).fetchone()
|
||||
if not row:
|
||||
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")
|
||||
voice = dict(row)
|
||||
audio_path = voice["audio_path"]
|
||||
if not os.path.exists(audio_path):
|
||||
raise HTTPException(status_code=404, detail="Audio file not found on disk")
|
||||
|
||||
import shutil
|
||||
import uuid
|
||||
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)
|
||||
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()
|
||||
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),
|
||||
)
|
||||
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
||||
|
||||
return {"success": True, "profile_id": profile_id, "name": voice["name"]}
|
||||
|
||||
@@ -11,7 +11,7 @@ from typing import Optional
|
||||
from fastapi import APIRouter, File, Form, UploadFile, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from core.db import get_db, db_conn
|
||||
from core.db import db_conn
|
||||
from core.config import OUTPUTS_DIR, VOICES_DIR
|
||||
from services.model_manager import get_model, _gpu_pool
|
||||
from services.audio_dsp import apply_mastering, normalize_audio
|
||||
@@ -49,6 +49,9 @@ def _run_inference(
|
||||
mastered_audio = apply_mastering(audio_out, sample_rate=model.sampling_rate if hasattr(model, 'sampling_rate') else 24000)
|
||||
return normalize_audio(mastered_audio, target_dBFS=-2.0)
|
||||
|
||||
except ValueError as e:
|
||||
# Don't wrap validation errors in OOM message
|
||||
raise e
|
||||
except Exception as e:
|
||||
import gc
|
||||
gc.collect()
|
||||
@@ -90,11 +93,8 @@ async def generate_speech(
|
||||
resolved_profile_id = None
|
||||
|
||||
if profile_id:
|
||||
conn = get_db()
|
||||
try:
|
||||
with db_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if row:
|
||||
resolved_profile_id = profile_id
|
||||
if row["is_locked"] and row["locked_audio_path"]:
|
||||
@@ -131,7 +131,7 @@ async def generate_speech(
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
audio_tensor = await loop.run_in_executor(
|
||||
_gpu_pool, _run_inference,
|
||||
_model, text, language, ref_audio_path, ref_text, instruct, duration,
|
||||
@@ -182,6 +182,9 @@ async def generate_speech(
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
logger.error("Validation failed: %s", e)
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
except Exception as e:
|
||||
tb = traceback.format_exc()
|
||||
logger.error("Inference failed: %s\n%s", e, tb)
|
||||
@@ -212,11 +215,8 @@ def _safe_output_path(name):
|
||||
|
||||
@router.get("/history")
|
||||
def list_history():
|
||||
conn = get_db()
|
||||
try:
|
||||
with db_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM generation_history ORDER BY created_at DESC LIMIT 50").fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@router.delete("/history")
|
||||
|
||||
@@ -36,7 +36,7 @@ from fastapi import APIRouter, File, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
|
||||
from core.config import OUTPUTS_DIR, VOICES_DIR
|
||||
from core.db import db_conn, get_db
|
||||
from core.db import db_conn
|
||||
from core import event_bus
|
||||
|
||||
logger = logging.getLogger("omnivoice.marketplace")
|
||||
@@ -60,13 +60,10 @@ MAX_BUNDLE_BYTES = 100 * 1024 * 1024
|
||||
@router.post("/export/{profile_id}")
|
||||
def export_profile(profile_id: str):
|
||||
"""Export a voice profile as a downloadable .omnivoice bundle (ZIP)."""
|
||||
conn = get_db()
|
||||
try:
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id = ?", (profile_id,)
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Voice profile not found")
|
||||
@@ -239,13 +236,10 @@ def publish_to_marketplace(
|
||||
OmniVoice instances on the same machine (or shared network drive)
|
||||
can discover and import it.
|
||||
"""
|
||||
conn = get_db()
|
||||
try:
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id = ?", (profile_id,)
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Voice profile not found")
|
||||
|
||||
@@ -227,15 +227,12 @@ async def create_speech(req: SpeechRequest):
|
||||
if voice not in _OPENAI_VOICE_ALIASES and voice != "default":
|
||||
# Try to resolve as a voice profile ID
|
||||
try:
|
||||
from core.db import get_db
|
||||
from core.db import db_conn
|
||||
from core.config import VOICES_DIR
|
||||
conn = get_db()
|
||||
try:
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (voice,)
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if row:
|
||||
if row["is_locked"] and row["locked_audio_path"]:
|
||||
kw["ref_audio"] = os.path.join(VOICES_DIR, row["locked_audio_path"])
|
||||
@@ -253,7 +250,7 @@ async def create_speech(req: SpeechRequest):
|
||||
kw["voice"] = voice
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
wav, sr = await loop.run_in_executor(_gpu_pool, _run_tts, backend, req.input, kw)
|
||||
except Exception as e:
|
||||
logger.exception("OpenAI TTS failed: %s", e)
|
||||
@@ -318,7 +315,7 @@ async def create_transcription(
|
||||
backend = get_active_asr_backend()
|
||||
|
||||
# Run transcription in the thread pool to avoid blocking the event loop
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
word_ts = response_format == "verbose_json"
|
||||
result = await loop.run_in_executor(
|
||||
_gpu_pool,
|
||||
@@ -422,14 +419,11 @@ def list_voices():
|
||||
|
||||
# Include voice profiles from the database
|
||||
try:
|
||||
from core.db import get_db
|
||||
conn = get_db()
|
||||
try:
|
||||
from core.db import db_conn
|
||||
with db_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, language FROM voice_profiles ORDER BY name"
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
for row in rows:
|
||||
voices.append({
|
||||
"voice_id": row["id"],
|
||||
|
||||
@@ -7,7 +7,7 @@ from fastapi import APIRouter, File, Form, UploadFile, HTTPException
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.db import get_db, db_conn
|
||||
from core.db import db_conn
|
||||
from core.config import VOICES_DIR, OUTPUTS_DIR
|
||||
from core import event_bus
|
||||
from core.personalities import get_personalities
|
||||
@@ -30,9 +30,8 @@ def list_personalities():
|
||||
|
||||
@router.get("/profiles")
|
||||
def list_profiles():
|
||||
conn = get_db()
|
||||
rows = conn.execute("SELECT * FROM voice_profiles ORDER BY created_at DESC").fetchall()
|
||||
conn.close()
|
||||
with db_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM voice_profiles ORDER BY created_at DESC").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@router.post("/profiles")
|
||||
@@ -53,13 +52,17 @@ async def create_profile(
|
||||
with open(audio_path, "wb") as f:
|
||||
f.write(await ref_audio.read())
|
||||
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles (id, name, ref_audio_path, ref_text, instruct, language, seed, personality, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(profile_id, name, audio_filename, ref_text, instruct, language, seed, personality, time.time())
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
try:
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles (id, name, ref_audio_path, ref_text, instruct, language, seed, personality, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(profile_id, name, audio_filename, ref_text, instruct, language, seed, personality, time.time())
|
||||
)
|
||||
except Exception:
|
||||
# Clean up orphaned audio file if DB insert fails
|
||||
if os.path.exists(audio_path):
|
||||
os.remove(audio_path)
|
||||
raise
|
||||
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
||||
return {"id": profile_id, "name": name}
|
||||
|
||||
@@ -162,9 +165,8 @@ def get_profile_usage(profile_id: str):
|
||||
|
||||
@router.get("/profiles/{profile_id}/audio")
|
||||
def get_profile_audio(profile_id: str):
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT ref_audio_path, locked_audio_path FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
conn.close()
|
||||
with db_conn() as conn:
|
||||
row = conn.execute("SELECT ref_audio_path, locked_audio_path FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
if not row:
|
||||
return Response("Profile not found", status_code=404)
|
||||
audio_file = row["locked_audio_path"] or row["ref_audio_path"]
|
||||
@@ -181,79 +183,69 @@ async def lock_profile(
|
||||
history_id: str = Form(...),
|
||||
seed: Optional[int] = Form(None),
|
||||
):
|
||||
conn = get_db()
|
||||
profile = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
if not profile:
|
||||
conn.close()
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Voice profile not found. It may have been deleted from another window — refresh the sidebar to see the current list.",
|
||||
with db_conn() as conn:
|
||||
profile = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
if not profile:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Voice profile not found. It may have been deleted from another window — refresh the sidebar to see the current list.",
|
||||
)
|
||||
|
||||
history = conn.execute("SELECT * FROM generation_history WHERE id=?", (history_id,)).fetchone()
|
||||
if not history or not history["audio_path"]:
|
||||
raise HTTPException(status_code=404, detail="History item not found or has no audio")
|
||||
|
||||
src_path = os.path.join(OUTPUTS_DIR, history["audio_path"])
|
||||
if not os.path.exists(src_path):
|
||||
raise HTTPException(status_code=404, detail="Audio file not found on disk")
|
||||
|
||||
locked_filename = f"{profile_id}_locked.wav"
|
||||
locked_path = os.path.join(VOICES_DIR, locked_filename)
|
||||
shutil.copy2(src_path, locked_path)
|
||||
|
||||
ref_text = history["text"][:100] if history["text"] else ""
|
||||
|
||||
conn.execute(
|
||||
"UPDATE voice_profiles SET locked_audio_path=?, seed=?, is_locked=1, ref_text=? WHERE id=?",
|
||||
(locked_filename, seed, ref_text, profile_id)
|
||||
)
|
||||
|
||||
history = conn.execute("SELECT * FROM generation_history WHERE id=?", (history_id,)).fetchone()
|
||||
if not history or not history["audio_path"]:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="History item not found or has no audio")
|
||||
|
||||
src_path = os.path.join(OUTPUTS_DIR, history["audio_path"])
|
||||
if not os.path.exists(src_path):
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Audio file not found on disk")
|
||||
|
||||
locked_filename = f"{profile_id}_locked.wav"
|
||||
locked_path = os.path.join(VOICES_DIR, locked_filename)
|
||||
shutil.copy2(src_path, locked_path)
|
||||
|
||||
ref_text = history["text"][:100] if history["text"] else ""
|
||||
|
||||
conn.execute(
|
||||
"UPDATE voice_profiles SET locked_audio_path=?, seed=?, is_locked=1, ref_text=? WHERE id=?",
|
||||
(locked_filename, seed, ref_text, profile_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
event_bus.emit("profiles", {"action": "locked", "id": profile_id})
|
||||
return {"locked": True, "profile_id": profile_id, "locked_audio_path": locked_filename}
|
||||
|
||||
@router.post("/profiles/{profile_id}/unlock")
|
||||
async def unlock_profile(profile_id: str):
|
||||
conn = get_db()
|
||||
profile = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
if not profile:
|
||||
conn.close()
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Voice profile not found. It may have been deleted from another window — refresh the sidebar to see the current list.",
|
||||
with db_conn() as conn:
|
||||
profile = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
if not profile:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Voice profile not found. It may have been deleted from another window — refresh the sidebar to see the current list.",
|
||||
)
|
||||
|
||||
if profile["locked_audio_path"]:
|
||||
locked_path = os.path.join(VOICES_DIR, profile["locked_audio_path"])
|
||||
if os.path.exists(locked_path):
|
||||
os.remove(locked_path)
|
||||
|
||||
conn.execute(
|
||||
"UPDATE voice_profiles SET locked_audio_path='', seed=NULL, is_locked=0 WHERE id=?",
|
||||
(profile_id,)
|
||||
)
|
||||
|
||||
if profile["locked_audio_path"]:
|
||||
locked_path = os.path.join(VOICES_DIR, profile["locked_audio_path"])
|
||||
if os.path.exists(locked_path):
|
||||
os.remove(locked_path)
|
||||
|
||||
conn.execute(
|
||||
"UPDATE voice_profiles SET locked_audio_path='', seed=NULL, is_locked=0 WHERE id=?",
|
||||
(profile_id,)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
event_bus.emit("profiles", {"action": "unlocked", "id": profile_id})
|
||||
return {"unlocked": True, "profile_id": profile_id}
|
||||
|
||||
@router.delete("/profiles/{profile_id}")
|
||||
def delete_profile(profile_id: str):
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT ref_audio_path, locked_audio_path FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
if row:
|
||||
for col in ["ref_audio_path", "locked_audio_path"]:
|
||||
if row[col]:
|
||||
path = os.path.join(VOICES_DIR, row[col])
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
# Prevent FOREIGN KEY constraint failure
|
||||
conn.execute("UPDATE generation_history SET profile_id = NULL WHERE profile_id=?", (profile_id,))
|
||||
conn.execute("DELETE FROM voice_profiles WHERE id=?", (profile_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with db_conn() as conn:
|
||||
row = conn.execute("SELECT ref_audio_path, locked_audio_path FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
if row:
|
||||
for col in ["ref_audio_path", "locked_audio_path"]:
|
||||
if row[col]:
|
||||
path = os.path.join(VOICES_DIR, row[col])
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
# Prevent FOREIGN KEY constraint failure
|
||||
conn.execute("UPDATE generation_history SET profile_id = NULL WHERE profile_id=?", (profile_id,))
|
||||
conn.execute("DELETE FROM voice_profiles WHERE id=?", (profile_id,))
|
||||
event_bus.emit("profiles", {"action": "deleted", "id": profile_id})
|
||||
return {"deleted": profile_id}
|
||||
|
||||
@@ -3,7 +3,7 @@ import time
|
||||
import json
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from core.db import get_db
|
||||
from core.db import db_conn
|
||||
from core import event_bus
|
||||
from schemas.requests import ProjectSaveRequest
|
||||
|
||||
@@ -11,18 +11,16 @@ router = APIRouter()
|
||||
|
||||
@router.get("/projects")
|
||||
async def list_projects():
|
||||
conn = get_db()
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, video_path, duration, created_at, updated_at FROM studio_projects ORDER BY updated_at DESC"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
with db_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, video_path, duration, created_at, updated_at FROM studio_projects ORDER BY updated_at DESC"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@router.get("/projects/{project_id}")
|
||||
async def get_project(project_id: str):
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT * FROM studio_projects WHERE id=?", (project_id,)).fetchone()
|
||||
conn.close()
|
||||
with db_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM studio_projects WHERE id=?", (project_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
result = dict(row)
|
||||
@@ -39,38 +37,31 @@ async def get_project(project_id: str):
|
||||
async def create_project(req: ProjectSaveRequest):
|
||||
project_id = str(uuid.uuid4())[:8]
|
||||
now = time.time()
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO studio_projects (id, name, video_path, audio_path, duration, state_json, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)",
|
||||
(project_id, req.name, req.video_path, req.audio_path, req.duration, json.dumps(req.state), now, now),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO studio_projects (id, name, video_path, audio_path, duration, state_json, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)",
|
||||
(project_id, req.name, req.video_path, req.audio_path, req.duration, json.dumps(req.state), now, now),
|
||||
)
|
||||
event_bus.emit("projects", {"action": "created", "id": project_id})
|
||||
return {"id": project_id, "name": req.name, "created_at": now}
|
||||
|
||||
@router.put("/projects/{project_id}")
|
||||
async def update_project(project_id: str, req: ProjectSaveRequest):
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT id FROM studio_projects WHERE id=?", (project_id,)).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
now = time.time()
|
||||
conn.execute(
|
||||
"UPDATE studio_projects SET name=?, video_path=?, audio_path=?, duration=?, state_json=?, updated_at=? WHERE id=?",
|
||||
(req.name, req.video_path, req.audio_path, req.duration, json.dumps(req.state), now, project_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with db_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM studio_projects WHERE id=?", (project_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
now = time.time()
|
||||
conn.execute(
|
||||
"UPDATE studio_projects SET name=?, video_path=?, audio_path=?, duration=?, state_json=?, updated_at=? WHERE id=?",
|
||||
(req.name, req.video_path, req.audio_path, req.duration, json.dumps(req.state), now, project_id),
|
||||
)
|
||||
event_bus.emit("projects", {"action": "updated", "id": project_id})
|
||||
return {"id": project_id, "name": req.name, "updated_at": now}
|
||||
|
||||
@router.delete("/projects/{project_id}")
|
||||
async def delete_project(project_id: str):
|
||||
conn = get_db()
|
||||
conn.execute("DELETE FROM studio_projects WHERE id=?", (project_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with db_conn() as conn:
|
||||
conn.execute("DELETE FROM studio_projects WHERE id=?", (project_id,))
|
||||
event_bus.emit("projects", {"action": "deleted", "id": project_id})
|
||||
return {"deleted": project_id}
|
||||
|
||||
@@ -46,7 +46,7 @@ def _safe_put(queue: asyncio.Queue, event) -> None:
|
||||
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()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def listener(event):
|
||||
try:
|
||||
@@ -108,7 +108,7 @@ async def install_model(req: InstallModelRequest):
|
||||
f"Retry in {remaining}s or check your network."
|
||||
),
|
||||
)
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _do():
|
||||
token = hf_progress.current_repo_id.set(req.repo_id)
|
||||
|
||||
@@ -400,7 +400,7 @@ def preflight():
|
||||
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()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
async def _do_warmup():
|
||||
try:
|
||||
|
||||
@@ -120,16 +120,13 @@ async def ws_tts(websocket: WebSocket):
|
||||
voice = data.get("voice")
|
||||
if voice:
|
||||
try:
|
||||
from core.db import get_db
|
||||
from core.db import db_conn
|
||||
from core.config import VOICES_DIR
|
||||
conn = get_db()
|
||||
try:
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?",
|
||||
(voice,),
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if row:
|
||||
if row["is_locked"] and row["locked_audio_path"]:
|
||||
kw["ref_audio"] = os.path.join(
|
||||
@@ -150,7 +147,7 @@ async def ws_tts(websocket: WebSocket):
|
||||
|
||||
# Run generation in the GPU pool
|
||||
from services.model_manager import _gpu_pool
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _generate():
|
||||
import torch
|
||||
|
||||
+1
-1
@@ -252,7 +252,7 @@ async def lifespan(app: FastAPI):
|
||||
async def _preload_capture_asr():
|
||||
try:
|
||||
from services.model_manager import _gpu_pool, _loading_detail
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
def _warm():
|
||||
from services.asr_backend import get_capture_asr_backend
|
||||
_loading_detail["sub_stage"] = "loading_asr"
|
||||
|
||||
@@ -467,7 +467,7 @@ class PyTorchWhisperBackend(ASRBackend):
|
||||
import asyncio
|
||||
from services.model_manager import get_model
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
if loop.is_running():
|
||||
raise RuntimeError(
|
||||
"PyTorchWhisperBackend needs the ASR pipe — pass it via constructor "
|
||||
|
||||
@@ -96,7 +96,7 @@ async def generate_segments_batched(
|
||||
from services.audio_dsp import apply_mastering, normalize_audio
|
||||
|
||||
sr = getattr(model, "sampling_rate", 24000)
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
results: list[tuple[int, torch.Tensor, int]] = []
|
||||
total = len(segments)
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ logger = logging.getLogger("omnivoice.dub_pipeline")
|
||||
# backward compat during the transition.
|
||||
|
||||
_dub_jobs: dict[str, dict] = {}
|
||||
_dub_jobs_lock = threading.Lock()
|
||||
_active_procs: dict[str, list] = {}
|
||||
_active_procs_lock = threading.Lock()
|
||||
|
||||
@@ -106,14 +107,11 @@ def find_cached_job(content_hash: str, exclude_job_id: str) -> Optional[dict]:
|
||||
"""
|
||||
if not content_hash:
|
||||
return None
|
||||
conn = get_db()
|
||||
try:
|
||||
with db_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, job_data FROM dub_history WHERE content_hash=? AND id!=? ORDER BY created_at DESC LIMIT 5",
|
||||
(content_hash, exclude_job_id),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
for row in rows:
|
||||
try:
|
||||
job = json.loads(row["job_data"])
|
||||
@@ -183,17 +181,16 @@ def get_job(job_id: str) -> Optional[dict]:
|
||||
"""Look up a job. Checks the in-memory cache first, then falls back to
|
||||
`dub_history.job_data` so saved projects still resolve after restart.
|
||||
"""
|
||||
if job_id in _dub_jobs:
|
||||
return _dub_jobs[job_id]
|
||||
conn = get_db()
|
||||
try:
|
||||
with _dub_jobs_lock:
|
||||
if job_id in _dub_jobs:
|
||||
return _dub_jobs[job_id]
|
||||
with db_conn() as conn:
|
||||
row = conn.execute("SELECT job_data FROM dub_history WHERE id=?", (job_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if row and row["job_data"]:
|
||||
try:
|
||||
job = json.loads(row["job_data"])
|
||||
_dub_jobs[job_id] = job
|
||||
with _dub_jobs_lock:
|
||||
_dub_jobs[job_id] = job
|
||||
return job
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Failed to decode dub_history.job_data for %s: %s", job_id, e)
|
||||
@@ -202,7 +199,8 @@ def get_job(job_id: str) -> Optional[dict]:
|
||||
|
||||
def put_job(job_id: str, job: dict) -> None:
|
||||
"""Insert / replace the in-memory job record. Does NOT persist."""
|
||||
_dub_jobs[job_id] = job
|
||||
with _dub_jobs_lock:
|
||||
_dub_jobs[job_id] = job
|
||||
|
||||
|
||||
def save_job(job_id: str, job: dict, filename: str = "", duration: float = 0.0, content_hash: str = "") -> None:
|
||||
@@ -387,8 +385,7 @@ def parse_vtt_segments(vtt_path: str) -> list[dict]:
|
||||
continue
|
||||
text = " ".join(ln.strip() for ln in lines[1:]).strip()
|
||||
# Strip inline styling like <c.colorE5E5E5>foo</c> or <00:00:01.200>
|
||||
import re as _re
|
||||
text = _re.sub(r"<[^>]+>", "", text)
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
if text:
|
||||
segments.append({"start": start, "end": end, "text": text})
|
||||
return segments
|
||||
|
||||
@@ -30,8 +30,10 @@ def find_ffmpeg():
|
||||
"""
|
||||
# 1. Env var injected by Tauri host
|
||||
env_path = os.environ.get("FFMPEG_PATH")
|
||||
if env_path and os.path.isfile(env_path):
|
||||
return env_path
|
||||
if env_path:
|
||||
resolved = shutil.which(env_path)
|
||||
if resolved:
|
||||
return resolved
|
||||
# 2. imageio-ffmpeg bundled static binary
|
||||
try:
|
||||
import imageio_ffmpeg
|
||||
@@ -58,8 +60,10 @@ def find_ffprobe():
|
||||
3. System ``PATH``.
|
||||
"""
|
||||
env_path = os.environ.get("FFPROBE_PATH")
|
||||
if env_path and os.path.isfile(env_path):
|
||||
return env_path
|
||||
if env_path:
|
||||
resolved = shutil.which(env_path)
|
||||
if resolved:
|
||||
return resolved
|
||||
try:
|
||||
ffmpeg_path = find_ffmpeg()
|
||||
candidate = ffmpeg_path.replace("ffmpeg", "ffprobe")
|
||||
|
||||
@@ -114,7 +114,7 @@ async def sandboxed_generate(
|
||||
)
|
||||
proc.start()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _wait():
|
||||
proc.join(timeout=timeout)
|
||||
|
||||
@@ -49,6 +49,7 @@ _loading_detail: dict = {
|
||||
"sub_stage": None, # importing | loading_weights | loading_asr | compiling | ready | error
|
||||
"detail": "", # human-readable description
|
||||
"error": None, # error message string if failed
|
||||
"progress": None, # 0-100 percentage (None = indeterminate)
|
||||
}
|
||||
|
||||
# ── ROCm GFX version overrides ───────────────────────────────────────
|
||||
@@ -160,15 +161,33 @@ def get_best_device():
|
||||
|
||||
return "cpu"
|
||||
|
||||
def _set_loading(sub_stage: str, detail: str = "", error: str | None = None):
|
||||
def _set_loading(sub_stage: str, detail: str = "", error: str | None = None, progress: float | None = None):
|
||||
"""Update the loading detail dict atomically."""
|
||||
_loading_detail["sub_stage"] = sub_stage
|
||||
_loading_detail["detail"] = detail
|
||||
_loading_detail["error"] = error
|
||||
_loading_detail["progress"] = progress
|
||||
|
||||
|
||||
def _load_model_sync():
|
||||
global model
|
||||
from utils.hf_progress import register_listener, unregister_listener
|
||||
|
||||
# Register a listener that updates _loading_detail with real-time
|
||||
# download/weight-loading percentages from hf_hub_download tqdm bars.
|
||||
def _on_hf_progress(ev):
|
||||
pct = ev.get("pct", 0.0)
|
||||
filename = ev.get("filename", "")
|
||||
phase = ev.get("phase", "")
|
||||
if pct > 0:
|
||||
pct_int = min(round(pct * 100), 99) # cap at 99 until fully done
|
||||
detail = _loading_detail.get("detail", "")
|
||||
# Append percentage to the existing detail label
|
||||
base = detail.split(" —")[0].split(" (")[0] # strip old suffix
|
||||
_loading_detail["progress"] = pct_int
|
||||
_loading_detail["detail"] = f"{base} — {pct_int}%"
|
||||
|
||||
lid = register_listener(_on_hf_progress)
|
||||
try:
|
||||
_set_loading("importing", "Importing PyTorch & OmniVoice runtime…")
|
||||
logger.info("Importing PyTorch & OmniVoice runtime…")
|
||||
@@ -191,7 +210,7 @@ def _load_model_sync():
|
||||
except Exception as e:
|
||||
logger.info("torch.compile skipped: %s", e)
|
||||
|
||||
_set_loading("ready", "Model ready")
|
||||
_set_loading("ready", "Model ready", progress=100)
|
||||
logger.info("OmniVoice model loaded successfully.")
|
||||
return _model
|
||||
except Exception as exc:
|
||||
@@ -199,6 +218,8 @@ def _load_model_sync():
|
||||
_set_loading("error", "Model loading failed", error=err_msg)
|
||||
logger.error("Model loading failed: %s", err_msg)
|
||||
raise
|
||||
finally:
|
||||
unregister_listener(lid)
|
||||
|
||||
async def get_model():
|
||||
global model, _last_used
|
||||
@@ -264,6 +285,9 @@ def get_model_status():
|
||||
if sub:
|
||||
result["sub_stage"] = sub
|
||||
result["detail"] = _loading_detail.get("detail", "")
|
||||
progress = _loading_detail.get("progress")
|
||||
if progress is not None:
|
||||
result["progress"] = progress
|
||||
err = _loading_detail.get("error")
|
||||
if err:
|
||||
result["error"] = err
|
||||
|
||||
@@ -291,7 +291,7 @@ async def cinematic_refine_many(
|
||||
Returns a list of dicts keyed the same length + order, each carrying
|
||||
`id`, `text`, `literal`, `critique`, optional `error`.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
directions = directions or {}
|
||||
|
||||
# Bound concurrency so we don't fan out 500 simultaneous requests.
|
||||
|
||||
@@ -131,19 +131,18 @@ class OmniVoiceBackend(TTSBackend):
|
||||
# Reuse model_manager's cached instance so we don't double-load.
|
||||
from services.model_manager import get_model
|
||||
import asyncio
|
||||
# Caller is sync; spin up a fresh loop if needed.
|
||||
# Caller is sync; spin up a fresh loop if needed. get_running_loop()
|
||||
# raises only when *no* loop is running — that's the safe path where
|
||||
# we can bootstrap with asyncio.run().
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# Already inside an async context — caller should await
|
||||
# `get_model()` themselves and pass it in via the constructor.
|
||||
raise RuntimeError(
|
||||
"OmniVoiceBackend.generate() called inside an async context without a pre-loaded model. "
|
||||
"Pass `model=await get_model()` to the constructor."
|
||||
)
|
||||
self._model = loop.run_until_complete(get_model())
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
self._model = asyncio.run(get_model())
|
||||
return
|
||||
raise RuntimeError(
|
||||
"OmniVoiceBackend.generate() called inside an async context without a pre-loaded model. "
|
||||
"Pass `model=await get_model()` to the constructor."
|
||||
)
|
||||
|
||||
def generate(self, text, **kw) -> torch.Tensor:
|
||||
self._ensure_loaded()
|
||||
|
||||
@@ -235,7 +235,7 @@ async def analyse_video(
|
||||
Returns:
|
||||
VideoContext with per-segment and global visual analysis.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
ctx = VideoContext()
|
||||
|
||||
# Extract timestamps at segment midpoints
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/cli": "^2.11.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
@@ -61,12 +63,24 @@
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.1.5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
|
||||
|
||||
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="],
|
||||
|
||||
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.1.1", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ=="],
|
||||
|
||||
"@asamuzakjp/generational-cache": ["@asamuzakjp/generational-cache@1.0.1", "", {}, "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg=="],
|
||||
|
||||
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||
@@ -101,6 +115,20 @@
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
|
||||
|
||||
"@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="],
|
||||
|
||||
"@csstools/css-calc": ["@csstools/css-calc@3.2.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w=="],
|
||||
|
||||
"@csstools/css-color-parser": ["@csstools/css-color-parser@4.1.0", "", { "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.2.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ=="],
|
||||
|
||||
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="],
|
||||
|
||||
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.3", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg=="],
|
||||
|
||||
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
@@ -123,6 +151,8 @@
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
|
||||
|
||||
"@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="],
|
||||
|
||||
"@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=="],
|
||||
@@ -363,6 +393,12 @@
|
||||
|
||||
"@tauri-apps/plugin-window-state": ["@tauri-apps/plugin-window-state@2.4.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-OuvdrzyY8Q5Dbzpj+GcrnV1iCeoZbcFdzMjanZMMcAEUNy/6PH5pxZPXpaZLOR7whlzXiuzx0L9EKZbH7zpdRw=="],
|
||||
|
||||
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
|
||||
|
||||
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
|
||||
|
||||
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
|
||||
|
||||
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-wnvOWuVWJ5EUHNKxExEWiGlTeVpLG1L0PCu5MUozyC1P2SHGiWsmpW6/yAuShH91Fa2TAHOvdCRBzriZh4j4Eg=="],
|
||||
|
||||
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mA0FIPMwwN3lodDkQYaGxj6PeT7ZaN5aCEbkKn/WB+ZB9yJdVWA4J83GH7t43jqDc5dcnVluVN5UFx3plRiXhA=="],
|
||||
@@ -377,6 +413,12 @@
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
@@ -389,6 +431,20 @@
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@4.1.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@4.1.5", "", { "dependencies": { "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw=="],
|
||||
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.5", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/runner@4.1.5", "", { "dependencies": { "@vitest/utils": "4.1.5", "pathe": "^2.0.3" } }, "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@4.1.5", "", {}, "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
@@ -401,6 +457,10 @@
|
||||
|
||||
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"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=="],
|
||||
@@ -409,6 +469,8 @@
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.17", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA=="],
|
||||
|
||||
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||
@@ -417,6 +479,8 @@
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001787", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="],
|
||||
|
||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
@@ -433,18 +497,30 @@
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
|
||||
|
||||
"css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
|
||||
|
||||
"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=="],
|
||||
@@ -453,10 +529,14 @@
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.21.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA=="],
|
||||
|
||||
"entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
@@ -483,10 +563,14 @@
|
||||
|
||||
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
||||
|
||||
"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=="],
|
||||
@@ -549,6 +633,8 @@
|
||||
|
||||
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||
|
||||
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
|
||||
|
||||
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
|
||||
|
||||
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
|
||||
@@ -561,6 +647,8 @@
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
|
||||
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
@@ -569,6 +657,8 @@
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
|
||||
|
||||
"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=="],
|
||||
@@ -581,6 +671,8 @@
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
@@ -625,18 +717,24 @@
|
||||
|
||||
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
"lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="],
|
||||
|
||||
"lucide-react": ["lucide-react@1.14.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA=="],
|
||||
|
||||
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
|
||||
|
||||
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
@@ -651,6 +749,8 @@
|
||||
|
||||
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
|
||||
|
||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
|
||||
"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=="],
|
||||
@@ -661,10 +761,14 @@
|
||||
|
||||
"parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
|
||||
|
||||
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
@@ -679,6 +783,8 @@
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
|
||||
|
||||
"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=="],
|
||||
@@ -693,6 +799,8 @@
|
||||
|
||||
"react-i18next": ["react-i18next@17.0.6", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.0.1", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-WzJ6SMKF+GTD7JZZqxSR1AKKmXjaSu39sClUrNlwxS4Tl7a99O+ltFy6yhPMO+wgZuxpQjJ2PZkfrQKmAqrLhw=="],
|
||||
|
||||
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
||||
|
||||
"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=="],
|
||||
@@ -701,12 +809,18 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-rc.17", "", { "dependencies": { "@oxc-project/types": "=0.127.0", "@rolldown/pluginutils": "1.0.0-rc.17" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-x64": "1.0.0-rc.17", "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA=="],
|
||||
|
||||
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
|
||||
|
||||
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
@@ -717,24 +831,48 @@
|
||||
|
||||
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
|
||||
|
||||
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||
|
||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="],
|
||||
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
|
||||
|
||||
"tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="],
|
||||
|
||||
"tldts-core": ["tldts-core@7.0.30", "", {}, "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q=="],
|
||||
|
||||
"tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
|
||||
|
||||
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
|
||||
|
||||
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
@@ -745,6 +883,8 @@
|
||||
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
|
||||
|
||||
"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=="],
|
||||
@@ -759,18 +899,34 @@
|
||||
|
||||
"vite": ["vite@8.0.10", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.17", "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-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw=="],
|
||||
|
||||
"vitest": ["vitest@4.1.5", "", { "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", "@vitest/pretty-format": "4.1.5", "@vitest/runner": "4.1.5", "@vitest/snapshot": "4.1.5", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.5", "@vitest/browser-preview": "4.1.5", "@vitest/browser-webdriverio": "4.1.5", "@vitest/coverage-istanbul": "4.1.5", "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg=="],
|
||||
|
||||
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
|
||||
|
||||
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"wavesurfer.js": ["wavesurfer.js@7.12.6", "", {}, "sha512-zSxPgOFprtyJ31ppHQF0+E9jAmjAhi1rR36yIW6h1GOYdpRxDe6mbkYtlChqLK0Iz8ROBweiEFw2zus7tDFibA=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
|
||||
|
||||
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
@@ -789,6 +945,8 @@
|
||||
|
||||
"zustand": ["zustand@5.0.12", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="],
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"@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=="],
|
||||
@@ -813,10 +971,16 @@
|
||||
|
||||
"@tauri-apps/plugin-window-state/@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
|
||||
|
||||
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
|
||||
|
||||
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.17", "", {}, "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg=="],
|
||||
|
||||
"vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
@@ -24,6 +24,7 @@ WORKDIR /app
|
||||
# Enable unbuffered logs and optimizations
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV UV_SYSTEM_PYTHON=1
|
||||
ENV HF_HOME=/app/omnivoice_data/huggingface
|
||||
# Allow bare imports (from core.config, from services.*, etc.) when
|
||||
# uvicorn is started as `backend.main:app` from WORKDIR /app.
|
||||
@@ -31,6 +32,7 @@ ENV PYTHONPATH=/app/backend
|
||||
|
||||
# Install system dependencies (FFmpeg is critical for torchaudio/scene splitting)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ffmpeg \
|
||||
libsndfile1 \
|
||||
curl \
|
||||
|
||||
@@ -22,9 +22,9 @@ services:
|
||||
image: ghcr.io/debpalash/omnivoice-studio:latest
|
||||
# To build from source instead of pulling, comment out `image:` and
|
||||
# uncomment the two lines below:
|
||||
# build:
|
||||
# context: ..
|
||||
# dockerfile: deploy/Dockerfile
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
container_name: omnivoice-studio
|
||||
ports:
|
||||
- "127.0.0.1:3900:3900"
|
||||
@@ -46,9 +46,9 @@ services:
|
||||
# ── GPU mode — activate with: docker compose --profile gpu up
|
||||
omnivoice-gpu:
|
||||
image: ghcr.io/debpalash/omnivoice-studio:latest
|
||||
# build:
|
||||
# context: ..
|
||||
# dockerfile: deploy/Dockerfile
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
container_name: omnivoice-studio-gpu
|
||||
profiles: ["gpu"]
|
||||
ports:
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --experimental-strip-types --no-warnings --test ../tests/frontend/*.test.mjs",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:legacy": "node --experimental-strip-types --no-warnings --test ../tests/frontend/*.test.mjs",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
@@ -51,6 +53,8 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/cli": "^2.11.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
@@ -58,7 +62,9 @@
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.10"
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +181,7 @@ pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Opt
|
||||
env.push(("FFPROBE_PATH".into(), ffprobe_path.to_string_lossy().into()));
|
||||
}
|
||||
let mut cmd = Command::new(&python);
|
||||
cmd.env_remove("PYTHONHOME").env_remove("PYTHONPATH").env_remove("LD_LIBRARY_PATH");
|
||||
for (k, v) in &env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
|
||||
@@ -203,6 +203,13 @@ pub fn clean_and_retry_bootstrap(app: tauri::AppHandle, state: tauri::State<'_,
|
||||
let _ = fs::remove_dir_all(&project_dir);
|
||||
}
|
||||
}
|
||||
// Kill any zombie backend still occupying the port from the deleted
|
||||
// project dir, otherwise bootstrap will "attach" to the stale process.
|
||||
if crate::backend::port_in_use(backend_port()) {
|
||||
log::warn!("Clean retry: killing stale backend on port {}", backend_port());
|
||||
crate::backend::kill_orphan_on_port(backend_port());
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
retry_bootstrap(app, state);
|
||||
}
|
||||
|
||||
@@ -284,12 +291,43 @@ pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress:
|
||||
let backend_dir = project_dir.join("backend");
|
||||
|
||||
if venv_py.is_file() && backend_dir.is_dir() {
|
||||
let uvicorn_check = Command::new(&venv_py)
|
||||
let mut uvicorn_check_cmd = Command::new(&venv_py);
|
||||
uvicorn_check_cmd.env_remove("PYTHONHOME").env_remove("PYTHONPATH").env_remove("LD_LIBRARY_PATH");
|
||||
let uvicorn_check = uvicorn_check_cmd
|
||||
.args(["-c", "import uvicorn"])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
if matches!(uvicorn_check, Ok(ref s) if s.success()) {
|
||||
// Always sync source dirs from bundle so code fixes land on
|
||||
// existing installs without requiring a full clean+reinstall.
|
||||
let resource_dir = app.path().resource_dir().ok();
|
||||
if let Some(ref res) = resource_dir {
|
||||
let flat = res.clone();
|
||||
let up2 = res.join("_up_").join("_up_");
|
||||
let (res_omni, res_backend) = if flat.join("pyproject.toml").is_file() {
|
||||
(flat.join("omnivoice"), flat.join("backend"))
|
||||
} else {
|
||||
(up2.join("omnivoice"), up2.join("backend"))
|
||||
};
|
||||
if res_omni.is_dir() {
|
||||
let omnivoice_dir = project_dir.join("omnivoice");
|
||||
let _ = fs::remove_dir_all(&omnivoice_dir);
|
||||
if let Err(e) = copy_dir_recursive(&res_omni, &omnivoice_dir) {
|
||||
fail(progress, &format!("Failed to sync omnivoice/ sources: {}", e));
|
||||
return None;
|
||||
}
|
||||
log::info!("Synced omnivoice/ from bundle");
|
||||
}
|
||||
if res_backend.is_dir() {
|
||||
let _ = fs::remove_dir_all(&backend_dir);
|
||||
if let Err(e) = copy_dir_recursive(&res_backend, &backend_dir) {
|
||||
fail(progress, &format!("Failed to sync backend/ sources: {}", e));
|
||||
return None;
|
||||
}
|
||||
log::info!("Synced backend/ from bundle");
|
||||
}
|
||||
}
|
||||
return Some((venv_py, backend_dir));
|
||||
}
|
||||
log::warn!(
|
||||
@@ -304,6 +342,7 @@ pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress:
|
||||
Err(e) => { fail(progress, &e); return None; }
|
||||
};
|
||||
let mut repair_cmd = Command::new(&uv_path);
|
||||
repair_cmd.env_remove("PYTHONHOME").env_remove("PYTHONPATH").env_remove("LD_LIBRARY_PATH");
|
||||
let has_lockfile = project_dir.join("uv.lock").is_file();
|
||||
if has_lockfile {
|
||||
repair_cmd.args(["sync", "--frozen", "--no-dev", "--verbose"]);
|
||||
@@ -386,6 +425,7 @@ pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress:
|
||||
set_stage(p, BootstrapStage::CreatingVenv);
|
||||
}
|
||||
let mut venv_cmd = Command::new(&uv_path);
|
||||
venv_cmd.env_remove("PYTHONHOME").env_remove("PYTHONPATH").env_remove("LD_LIBRARY_PATH");
|
||||
venv_cmd.args(["venv", "--python", "3.11", "--managed-python"]).current_dir(&project_dir);
|
||||
let status = run_streaming(app, "creating_venv", &mut venv_cmd);
|
||||
if !matches!(status, Ok(ref s) if s.success()) {
|
||||
@@ -397,6 +437,7 @@ pub fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress:
|
||||
set_stage(p, BootstrapStage::InstallingDeps);
|
||||
}
|
||||
let mut sync_cmd = Command::new(&uv_path);
|
||||
sync_cmd.env_remove("PYTHONHOME").env_remove("PYTHONPATH").env_remove("LD_LIBRARY_PATH");
|
||||
let has_lockfile = project_dir.join("uv.lock").is_file();
|
||||
if has_lockfile {
|
||||
sync_cmd
|
||||
|
||||
+51
-989
File diff suppressed because it is too large
Load Diff
@@ -155,6 +155,9 @@
|
||||
border-radius: 8px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.bootstrap-splash__sub-progress {
|
||||
|
||||
@@ -76,8 +76,13 @@ export default function CastingView({
|
||||
{speakers.map((speaker) => {
|
||||
const currentAssignment = assignments[speaker.id];
|
||||
const isAuto = currentAssignment?.startsWith('auto:');
|
||||
let autoName = speaker.label;
|
||||
if (isAuto && currentAssignment) {
|
||||
const match = Object.keys(autoClones || {}).find(spk => `auto:${(spk || '').toLowerCase().replace(/\s+/g, '_')}` === currentAssignment);
|
||||
if (match) autoName = match;
|
||||
}
|
||||
const assignedProfile = isAuto
|
||||
? { name: `Auto-clone (${speaker.label})`, type: 'clone' }
|
||||
? { name: `🎤 From video (${autoName})`, type: 'clone' }
|
||||
: profiles.find(p => p.id === currentAssignment);
|
||||
|
||||
return (
|
||||
@@ -123,20 +128,26 @@ export default function CastingView({
|
||||
{/* Dropdown */}
|
||||
{openDropdown === speaker.id && (
|
||||
<div className="casting-dropdown">
|
||||
{/* Auto-clone option */}
|
||||
{autoClones[speaker.id] && (
|
||||
<button
|
||||
className={`casting-dropdown__item ${isAuto ? 'is-active' : ''}`}
|
||||
onClick={() => assign(speaker.id, `auto:${speaker.id}`)}
|
||||
>
|
||||
<Shuffle size={11} />
|
||||
<span>Auto-clone from video</span>
|
||||
{isAuto && <Check size={11} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{autoClones[speaker.id] && profiles.length > 0 && (
|
||||
<div className="casting-dropdown__divider" />
|
||||
{/* Auto-clone options */}
|
||||
{autoClones && Object.keys(autoClones).length > 0 && (
|
||||
<>
|
||||
{Object.keys(autoClones).map(spk => {
|
||||
const autoId = `auto:${(spk || '').toLowerCase().replace(/\s+/g, '_')}`;
|
||||
const isActiveAuto = currentAssignment === autoId;
|
||||
return (
|
||||
<button
|
||||
key={autoId}
|
||||
className={`casting-dropdown__item ${isActiveAuto ? 'is-active' : ''}`}
|
||||
onClick={() => assign(speaker.id, autoId)}
|
||||
>
|
||||
<Shuffle size={11} />
|
||||
<span>🎤 {spk}</span>
|
||||
{isActiveAuto && <Check size={11} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{profiles.length > 0 && <div className="casting-dropdown__divider" />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Saved profiles */}
|
||||
|
||||
@@ -17,7 +17,7 @@ function rowClass(isActive, isDone, selected) {
|
||||
|
||||
function DubSegmentRow({
|
||||
seg, idx, style, disabled, isActive, isDone, previewLoading, selected,
|
||||
profiles, onEditField, onDelete, onRestore, onPreview, onSelect, onSplit, onMerge, canMerge,
|
||||
profiles, speakerClones, onEditField, onDelete, onRestore, onPreview, onSelect, onSplit, onMerge, canMerge,
|
||||
onDirect,
|
||||
}) {
|
||||
const syncColor = seg.sync_ratio === undefined ? null
|
||||
@@ -153,6 +153,14 @@ function DubSegmentRow({
|
||||
onChange={(e) => onEditField(seg.id, 'profile_id', e.target.value)}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
{speakerClones && Object.keys(speakerClones).length > 0 && (
|
||||
<optgroup label="From Video">
|
||||
{Object.keys(speakerClones).map(spk => {
|
||||
const autoId = `auto:${(spk || '').toLowerCase().replace(/\s+/g, '_')}`;
|
||||
return <option key={autoId} value={autoId}>🎤 {spk}</option>;
|
||||
})}
|
||||
</optgroup>
|
||||
)}
|
||||
{profiles.length > 0 && (
|
||||
<optgroup label="Clone Profiles">
|
||||
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
@@ -243,5 +251,6 @@ export default memo(DubSegmentRow, (prev, next) => (
|
||||
prev.selected === next.selected &&
|
||||
prev.canMerge === next.canMerge &&
|
||||
prev.profiles === next.profiles &&
|
||||
prev.speakerClones === next.speakerClones &&
|
||||
prev.idx === next.idx
|
||||
));
|
||||
|
||||
@@ -18,7 +18,7 @@ const COLUMNS = [
|
||||
];
|
||||
|
||||
export default function DubSegmentTable({
|
||||
segments, profiles, dubStep, dubProgress, previewLoadingId,
|
||||
segments, profiles, speakerClones, dubStep, dubProgress, previewLoadingId,
|
||||
selectedIds, onSelect, onSelectAll, onClearSelection,
|
||||
onEditField, onDelete, onRestore, onPreview, onSplit, onMerge, onDirect,
|
||||
}) {
|
||||
@@ -66,13 +66,13 @@ export default function DubSegmentTable({
|
||||
}, [filtered]);
|
||||
|
||||
const rowProps = useMemo(() => ({
|
||||
filtered, profiles, disabled, dubStep, dubProgress, previewLoadingId,
|
||||
filtered, profiles, speakerClones, disabled, dubStep, dubProgress, previewLoadingId,
|
||||
selectedIds, onSelect, onEditField, onDelete, onRestore, onPreview, onSplit, onMerge, onDirect,
|
||||
segments,
|
||||
}), [filtered, profiles, disabled, dubStep, dubProgress, previewLoadingId,
|
||||
}), [filtered, profiles, speakerClones, disabled, dubStep, dubProgress, previewLoadingId,
|
||||
selectedIds, onSelect, onEditField, onDelete, onRestore, onPreview, onSplit, onMerge, onDirect, segments]);
|
||||
|
||||
const Row = useCallback(({ index, style, filtered: fl, profiles: profs, disabled: dis, dubProgress: prog, dubStep: step, previewLoadingId: previewId, selectedIds: sel, onSelect: pick, onEditField: edit, onDelete: del, onRestore: rest, onPreview: prev, onSplit: split, onMerge: merge, onDirect: direct, segments: segs }) => {
|
||||
const Row = useCallback(({ index, style, filtered: fl, profiles: profs, speakerClones: clones, disabled: dis, dubProgress: prog, dubStep: step, previewLoadingId: previewId, selectedIds: sel, onSelect: pick, onEditField: edit, onDelete: del, onRestore: rest, onPreview: prev, onSplit: split, onMerge: merge, onDirect: direct, segments: segs }) => {
|
||||
const seg = fl[index];
|
||||
if (!seg) return null;
|
||||
const absoluteIndex = segs.indexOf(seg);
|
||||
@@ -87,6 +87,7 @@ export default function DubSegmentTable({
|
||||
selected={sel && sel.has(seg.id)}
|
||||
canMerge={canMerge}
|
||||
profiles={profs}
|
||||
speakerClones={clones}
|
||||
onEditField={edit} onDelete={del} onRestore={rest} onPreview={prev}
|
||||
onSelect={pick} onSplit={split} onMerge={merge} onDirect={direct}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useAppStore } from '../store';
|
||||
import { listProfiles } from '../api/profiles';
|
||||
import { listHistory } from '../api/generate';
|
||||
import { listProjects } from '../api/projects';
|
||||
import { listDubHistory } from '../api/dub';
|
||||
import { listExportHistory, exportAction, exportReveal, exportRecord } from '../api/exports';
|
||||
import { modelStatus as apiModelStatus } from '../api/system';
|
||||
import { useSysinfo, useModelStatus } from '../api/hooks';
|
||||
import useRealtimeEvents from './useRealtimeEvents';
|
||||
import { isTauri, fileToMediaUrl } from '../utils/media';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
/**
|
||||
* Encapsulates all data-loading effects, localStorage persistence,
|
||||
* real-time WebSocket updates, and model-status pill management.
|
||||
*/
|
||||
export default function useAppData() {
|
||||
const mode = useAppStore(s => s.mode);
|
||||
const setMode = useAppStore(s => s.setMode);
|
||||
const uiScale = useAppStore(s => s.uiScale);
|
||||
const setUiScale = useAppStore(s => s.setUiScale);
|
||||
const setText = useAppStore(s => s.setText);
|
||||
const text = useAppStore(s => s.text);
|
||||
const setLanguage = useAppStore(s => s.setLanguage);
|
||||
const language = useAppStore(s => s.language);
|
||||
const setIsSidebarCollapsed = useAppStore(s => s.setIsSidebarCollapsed);
|
||||
const isSidebarCollapsed = useAppStore(s => s.isSidebarCollapsed);
|
||||
const setSidebarTab = useAppStore(s => s.setSidebarTab);
|
||||
const sidebarTab = useAppStore(s => s.sidebarTab);
|
||||
const setVdStates = useAppStore(s => s.setVdStates);
|
||||
const vdStates = useAppStore(s => s.vdStates);
|
||||
const speed = useAppStore(s => s.speed);
|
||||
const setSpeed = useAppStore(s => s.setSpeed);
|
||||
const steps = useAppStore(s => s.steps);
|
||||
const setSteps = useAppStore(s => s.setSteps);
|
||||
const cfg = useAppStore(s => s.cfg);
|
||||
const setCfg = useAppStore(s => s.setCfg);
|
||||
const denoise = useAppStore(s => s.denoise);
|
||||
const setDenoise = useAppStore(s => s.setDenoise);
|
||||
const dubJobId = useAppStore(s => s.dubJobId);
|
||||
const setDubJobId = useAppStore(s => s.setDubJobId);
|
||||
const dubFilename = useAppStore(s => s.dubFilename);
|
||||
const setDubFilename = useAppStore(s => s.setDubFilename);
|
||||
const dubDuration = useAppStore(s => s.dubDuration);
|
||||
const setDubDuration = useAppStore(s => s.setDubDuration);
|
||||
const dubSegments = useAppStore(s => s.dubSegments);
|
||||
const setDubSegments = useAppStore(s => s.setDubSegments);
|
||||
const dubLang = useAppStore(s => s.dubLang);
|
||||
const setDubLang = useAppStore(s => s.setDubLang);
|
||||
const dubLangCode = useAppStore(s => s.dubLangCode);
|
||||
const setDubLangCode = useAppStore(s => s.setDubLangCode);
|
||||
const dubTracks = useAppStore(s => s.dubTracks);
|
||||
const setDubTracks = useAppStore(s => s.setDubTracks);
|
||||
const dubStep = useAppStore(s => s.dubStep);
|
||||
const setDubStep = useAppStore(s => s.setDubStep);
|
||||
const dubTranscript = useAppStore(s => s.dubTranscript);
|
||||
const setDubTranscript = useAppStore(s => s.setDubTranscript);
|
||||
const exportTracks = useAppStore(s => s.exportTracks);
|
||||
const setExportTracks = useAppStore(s => s.setExportTracks);
|
||||
const preserveBg = useAppStore(s => s.preserveBg);
|
||||
const setPreserveBg = useAppStore(s => s.setPreserveBg);
|
||||
const defaultTrack = useAppStore(s => s.defaultTrack);
|
||||
const setDefaultTrack = useAppStore(s => s.setDefaultTrack);
|
||||
|
||||
const [profiles, setProfiles] = useState([]);
|
||||
const [history, setHistory] = useState([]);
|
||||
const [dubHistory, setDubHistory] = useState([]);
|
||||
const [studioProjects, setStudioProjects] = useState([]);
|
||||
const [exportHistory, setExportHistory] = useState([]);
|
||||
const [showOverrides, setShowOverrides] = useState(false);
|
||||
|
||||
// ── Model status + sysinfo (TanStack Query) ──
|
||||
const sysQuery = useSysinfo();
|
||||
const msQuery = useModelStatus();
|
||||
const sysStats = sysQuery.data ?? null;
|
||||
const modelStatus = msQuery.data?.status ?? 'idle';
|
||||
const modelSubStage = msQuery.data?.sub_stage ?? null;
|
||||
const modelDetail = msQuery.data?.detail ?? '';
|
||||
const modelError = msQuery.data?.error ?? null;
|
||||
const modelProgress = msQuery.data?.progress ?? null;
|
||||
|
||||
// ── Model loading pill ──
|
||||
const prevModelStatusRef = useRef(modelStatus);
|
||||
useEffect(() => {
|
||||
const prev = prevModelStatusRef.current;
|
||||
prevModelStatusRef.current = modelStatus;
|
||||
const pill = useAppStore.getState();
|
||||
if (modelStatus === 'loading') {
|
||||
const label = modelDetail || 'Loading model…';
|
||||
if (prev !== 'loading' && pill.stage === 'idle') pill.showPill('loading-model', label);
|
||||
else if (pill.stage === 'loading-model') pill.setPillLabel(label);
|
||||
// Forward real-time percentage from backend
|
||||
if (modelProgress !== null && pill.stage === 'loading-model') {
|
||||
pill.setPillProgress(modelProgress);
|
||||
}
|
||||
}
|
||||
if (modelStatus === 'ready' && prev === 'loading' && pill.stage === 'loading-model') pill.completePill('Model ready');
|
||||
if (modelSubStage === 'error' && modelError && pill.stage === 'loading-model') pill.errorPill(modelError);
|
||||
}, [modelStatus, modelSubStage, modelDetail, modelError, modelProgress]);
|
||||
|
||||
// ── Data loading callbacks ──
|
||||
const loadProfiles = useCallback(async () => { try { setProfiles(await listProfiles()); } catch (e) {} }, []);
|
||||
const loadHistory = useCallback(async () => { try { setHistory(await listHistory()); } catch (e) {} }, []);
|
||||
const loadDubHistory = useCallback(async () => { try { setDubHistory(await listDubHistory()); } catch (e) {} }, []);
|
||||
const loadProjects = useCallback(async () => { try { setStudioProjects(await listProjects()); } catch (e) {} }, []);
|
||||
const loadExportHistory = useCallback(async () => { try { setExportHistory(await listExportHistory()); } catch (e) {} }, []);
|
||||
|
||||
// ── WebSocket real-time updates ──
|
||||
useRealtimeEvents({
|
||||
projects: () => loadProjects(),
|
||||
profiles: () => loadProfiles(),
|
||||
dub_history: () => loadDubHistory(),
|
||||
export_history: () => loadExportHistory(),
|
||||
generation_history: () => loadHistory(),
|
||||
});
|
||||
|
||||
// ── Initial data load with backend retry ──
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const loadAll = async () => {
|
||||
let delay = 1000;
|
||||
while (!cancelled) {
|
||||
try { await apiModelStatus(); break; } catch (e) {}
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
delay = Math.min(delay * 2, 4000);
|
||||
}
|
||||
if (cancelled) return;
|
||||
loadProfiles(); loadHistory(); loadDubHistory(); loadProjects(); loadExportHistory();
|
||||
};
|
||||
loadAll();
|
||||
// Restore local UI state
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('omni_ui') || '{}');
|
||||
if (saved.uiScale) setUiScale(saved.uiScale);
|
||||
if (saved.text) setText(saved.text);
|
||||
if (saved.mode) setMode(saved.mode);
|
||||
if (saved.vdStates) setVdStates(saved.vdStates);
|
||||
if (saved.language) setLanguage(saved.language);
|
||||
if (saved.isSidebarCollapsed !== undefined) setIsSidebarCollapsed(saved.isSidebarCollapsed);
|
||||
if (saved.sidebarTab) setSidebarTab(saved.sidebarTab);
|
||||
if (saved.dubJobId) setDubJobId(saved.dubJobId);
|
||||
if (saved.dubFilename) setDubFilename(saved.dubFilename);
|
||||
if (saved.dubDuration !== undefined) setDubDuration(saved.dubDuration);
|
||||
if (saved.dubSegments) setDubSegments(saved.dubSegments.map(s => ({ ...s, text_original: s.text_original || s.text || '' })));
|
||||
if (saved.dubLang) setDubLang(saved.dubLang);
|
||||
if (saved.dubLangCode) setDubLangCode(saved.dubLangCode);
|
||||
if (saved.dubTracks) setDubTracks(saved.dubTracks);
|
||||
if (saved.dubStep) setDubStep(saved.dubStep);
|
||||
if (saved.dubTranscript) setDubTranscript(saved.dubTranscript);
|
||||
if (saved.exportTracks) setExportTracks(saved.exportTracks);
|
||||
if (saved.preserveBg !== undefined) setPreserveBg(saved.preserveBg);
|
||||
if (saved.defaultTrack) setDefaultTrack(saved.defaultTrack);
|
||||
if (saved.exportHistory) setExportHistory(saved.exportHistory);
|
||||
if (saved.speed) setSpeed(saved.speed);
|
||||
if (saved.steps) setSteps(saved.steps);
|
||||
if (saved.cfg) setCfg(saved.cfg);
|
||||
if (saved.denoise !== undefined) setDenoise(saved.denoise);
|
||||
if (saved.showOverrides !== undefined) setShowOverrides(saved.showOverrides);
|
||||
} catch (e) {}
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// ── Persist to localStorage ──
|
||||
useEffect(() => {
|
||||
localStorage.setItem('omni_ui', JSON.stringify({
|
||||
uiScale, text, mode, vdStates, language,
|
||||
isSidebarCollapsed, sidebarTab,
|
||||
dubJobId, dubFilename, dubDuration, dubSegments,
|
||||
dubLang, dubLangCode, dubTracks, dubStep, dubTranscript,
|
||||
exportTracks, preserveBg, defaultTrack, exportHistory,
|
||||
speed, steps, cfg, denoise, showOverrides
|
||||
}));
|
||||
}, [uiScale, text, mode, vdStates, language, isSidebarCollapsed, sidebarTab,
|
||||
dubJobId, dubFilename, dubDuration, dubSegments, dubLang, dubLangCode,
|
||||
dubTracks, dubStep, dubTranscript, exportTracks, preserveBg, defaultTrack,
|
||||
exportHistory, speed, steps, cfg, denoise, showOverrides
|
||||
]);
|
||||
|
||||
return {
|
||||
profiles, history, dubHistory, studioProjects, exportHistory,
|
||||
showOverrides, setShowOverrides,
|
||||
sysStats, modelStatus,
|
||||
loadProfiles, loadHistory, loadDubHistory, loadProjects, loadExportHistory,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useAppStore } from '../store';
|
||||
import {
|
||||
dubUpload, dubIngestUrl, dubAbort as apiDubAbort, dubCleanupSegments,
|
||||
dubTranslate, dubGenerate, tasksStreamUrl, tasksCancel,
|
||||
transcribeStreamUrl,
|
||||
} from '../api/dub';
|
||||
import { PRESETS } from '../utils/constants';
|
||||
import { apiPost } from '../api/client';
|
||||
import { API } from '../api/client';
|
||||
import { playPing, isTauri } from '../utils/media';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
/**
|
||||
* Encapsulates the entire dub pipeline workflow:
|
||||
* upload → prep → transcribe → translate → generate → export
|
||||
*
|
||||
* Extracts ~700 LOC of handler logic from App.jsx.
|
||||
*/
|
||||
export default function useDubWorkflow({ loadProjects, loadProfiles, loadDubHistory }) {
|
||||
const dubJobId = useAppStore(s => s.dubJobId);
|
||||
const setDubJobId = useAppStore(s => s.setDubJobId);
|
||||
const dubStep = useAppStore(s => s.dubStep);
|
||||
const setDubStep = useAppStore(s => s.setDubStep);
|
||||
const dubSegments = useAppStore(s => s.dubSegments);
|
||||
const setDubSegments = useAppStore(s => s.setDubSegments);
|
||||
const dubLang = useAppStore(s => s.dubLang);
|
||||
const dubLangCode = useAppStore(s => s.dubLangCode);
|
||||
const dubInstruct = useAppStore(s => s.dubInstruct);
|
||||
const setDubFilename = useAppStore(s => s.setDubFilename);
|
||||
const setDubDuration = useAppStore(s => s.setDubDuration);
|
||||
const setDubError = useAppStore(s => s.setDubError);
|
||||
const setDubTracks = useAppStore(s => s.setDubTracks);
|
||||
const setDubTranscript = useAppStore(s => s.setDubTranscript);
|
||||
const setDubProgress = useAppStore(s => s.setDubProgress);
|
||||
const setIsTranslating = useAppStore(s => s.setIsTranslating);
|
||||
const dubTaskId = useAppStore(s => s.dubTaskId);
|
||||
const setDubTaskId = useAppStore(s => s.setDubTaskId);
|
||||
const setDubPrepStage = useAppStore(s => s.setDubPrepStage);
|
||||
const setSpeakerClones = useAppStore(s => s.setSpeakerClones);
|
||||
const setPreviewSegIds = useAppStore(s => s.setPreviewSegIds);
|
||||
const steps = useAppStore(s => s.steps);
|
||||
const cfg = useAppStore(s => s.cfg);
|
||||
const speed = useAppStore(s => s.speed);
|
||||
const translateQuality = useAppStore(s => s.translateQuality);
|
||||
const glossaryTerms = useAppStore(s => s.glossaryTerms);
|
||||
const preserveBg = useAppStore(s => s.preserveBg);
|
||||
const defaultTrack = useAppStore(s => s.defaultTrack);
|
||||
const exportTracks = useAppStore(s => s.exportTracks);
|
||||
const dualSubs = useAppStore(s => s.dualSubs);
|
||||
const burnSubs = useAppStore(s => s.burnSubs);
|
||||
|
||||
const [translateProvider, setTranslateProvider] = useState('argos');
|
||||
const [showTranscript, setShowTranscript] = useState(false);
|
||||
const [previewAudios, setPreviewAudios] = useState({});
|
||||
const [transcribeStart, setTranscribeStart] = useState(null);
|
||||
const [transcribeElapsed, setTranscribeElapsed] = useState(0);
|
||||
|
||||
const dubAbortCtrlRef = useRef(null);
|
||||
const dubClientJobIdRef = useRef(null);
|
||||
|
||||
// Timer for transcribe elapsed
|
||||
useEffect(() => {
|
||||
if (!transcribeStart) { setTranscribeElapsed(0); return; }
|
||||
const iv = setInterval(() => setTranscribeElapsed(Math.floor((Date.now() - transcribeStart) / 1000)), 500);
|
||||
return () => clearInterval(iv);
|
||||
}, [transcribeStart]);
|
||||
|
||||
// ── SSE: wait for transcription stream ──
|
||||
const _waitForTranscribe = useCallback((jobId, ctrl) => new Promise((resolve, reject) => {
|
||||
const evt = new EventSource(transcribeStreamUrl(jobId));
|
||||
let gotFinal = false;
|
||||
const close = () => { try { evt.close(); } catch {} };
|
||||
const onAbortSignal = () => { close(); reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); };
|
||||
ctrl.signal.addEventListener('abort', onAbortSignal, { once: true });
|
||||
|
||||
evt.addEventListener('start', () => {});
|
||||
evt.addEventListener('segments', (e) => {
|
||||
try {
|
||||
const m = JSON.parse(e.data);
|
||||
const incoming = (m.segments || []).map((s, i) => ({
|
||||
...s,
|
||||
id: s.id != null ? String(s.id) : `c${m.chunk}-${i}`,
|
||||
text_original: s.text_original || s.text || '',
|
||||
}));
|
||||
setDubSegments(prev => [...prev, ...incoming]);
|
||||
} catch (err) { /* ignore parse errors */ }
|
||||
});
|
||||
evt.addEventListener('final', (e) => {
|
||||
try {
|
||||
const m = JSON.parse(e.data);
|
||||
gotFinal = true;
|
||||
setDubSegments((m.segments || []).map((s, i) => ({
|
||||
...s,
|
||||
id: s.id != null ? String(s.id) : String(i),
|
||||
text_original: s.text_original || s.text || '',
|
||||
})));
|
||||
setDubTranscript(m.full_transcript || '');
|
||||
if (m.speaker_clones && typeof m.speaker_clones === 'object') {
|
||||
setSpeakerClones(m.speaker_clones);
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
evt.addEventListener('done', () => { close(); ctrl.signal.removeEventListener('abort', onAbortSignal); resolve(); });
|
||||
evt.addEventListener('aborted', () => { close(); ctrl.signal.removeEventListener('abort', onAbortSignal); reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); });
|
||||
evt.addEventListener('error', (e) => {
|
||||
try { const m = e.data ? JSON.parse(e.data) : null; if (m && m.detail) { close(); reject(new Error(m.detail)); return; } } catch {}
|
||||
if (gotFinal) { close(); resolve(); return; }
|
||||
close();
|
||||
reject(new Error('Transcribe stream dropped before emitting any segments. Likely ASR backend failed to load — check backend log + Settings → Models.'));
|
||||
});
|
||||
}), [setDubSegments, setDubTranscript, setSpeakerClones]);
|
||||
|
||||
// ── SSE: wait for prep pipeline ──
|
||||
const _waitForPrep = useCallback((taskId, ctrl) => new Promise((resolve, reject) => {
|
||||
const evt = new EventSource(tasksStreamUrl(taskId));
|
||||
const close = () => { try { evt.close(); } catch {} };
|
||||
const onAbort = () => { close(); reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); };
|
||||
ctrl.signal.addEventListener('abort', onAbort, { once: true });
|
||||
let lastData = null;
|
||||
evt.onmessage = (e) => {
|
||||
if (!e.data) return;
|
||||
let m;
|
||||
try { m = JSON.parse(e.data); } catch { return; }
|
||||
lastData = m;
|
||||
switch (m.type) {
|
||||
case 'download_start': setDubPrepStage('download'); break;
|
||||
case 'download_done': if (m.filename) setDubFilename(m.filename); break;
|
||||
case 'extract_start': setDubPrepStage('extract'); break;
|
||||
case 'extract_done':
|
||||
if (m.job_id) setDubJobId(m.job_id);
|
||||
if (typeof m.duration === 'number') setDubDuration(m.duration);
|
||||
if (m.filename) setDubFilename(m.filename);
|
||||
break;
|
||||
case 'demucs_start': setDubPrepStage('demucs'); break;
|
||||
case 'demucs_done': break;
|
||||
case 'scene_start': setDubPrepStage('scene'); break;
|
||||
case 'scene_done': break;
|
||||
case 'cached': setDubPrepStage('cached'); break;
|
||||
case 'ready': close(); ctrl.signal.removeEventListener('abort', onAbort); resolve(m); return;
|
||||
case 'error': close(); ctrl.signal.removeEventListener('abort', onAbort); reject(new Error(`${m.stage || 'prep'}: ${m.error || 'unknown error'}`)); return;
|
||||
case 'cancelled': close(); ctrl.signal.removeEventListener('abort', onAbort); reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); return;
|
||||
default: break;
|
||||
}
|
||||
};
|
||||
evt.onerror = () => {
|
||||
if (evt.readyState === EventSource.CLOSED) {
|
||||
close(); ctrl.signal.removeEventListener('abort', onAbort);
|
||||
if (lastData && lastData.type === 'ready') resolve(lastData);
|
||||
else reject(new Error('prep stream closed unexpectedly'));
|
||||
}
|
||||
};
|
||||
}), [setDubPrepStage, setDubJobId, setDubDuration, setDubFilename]);
|
||||
|
||||
// ── Handlers ──
|
||||
const handleDubUpload = useCallback(async (dubVideoFile) => {
|
||||
if (!dubVideoFile) return;
|
||||
setDubStep('uploading'); setDubError(''); setDubTracks([]); setDubPrepStage('download');
|
||||
const ctrl = new AbortController();
|
||||
dubAbortCtrlRef.current = ctrl;
|
||||
const clientJobId = Math.random().toString(36).slice(2, 10);
|
||||
dubClientJobIdRef.current = clientJobId;
|
||||
setDubJobId(clientJobId);
|
||||
useAppStore.getState().showPill('loading-model', 'Preparing video…', { cancellable: true });
|
||||
try {
|
||||
const data = await dubUpload(dubVideoFile, clientJobId, { signal: ctrl.signal });
|
||||
setDubJobId(data.job_id); if (data.filename) setDubFilename(data.filename);
|
||||
setDubTaskId(data.task_id); setDubPrepStage('extract');
|
||||
useAppStore.getState().showPill('loading-model', 'Extracting audio & scenes…', { cancellable: true });
|
||||
await _waitForPrep(data.task_id, ctrl);
|
||||
setDubStep('transcribing'); setDubPrepStage(null);
|
||||
setTranscribeStart(Date.now()); setDubSegments([]);
|
||||
useAppStore.getState().showPill('transcribing', 'Transcribing audio…', { cancellable: true });
|
||||
await _waitForTranscribe(data.job_id, ctrl);
|
||||
setTranscribeStart(null); setDubStep('editing');
|
||||
useAppStore.getState().completePill('Transcription complete');
|
||||
loadProjects(); loadProfiles();
|
||||
} catch (err) {
|
||||
setDubPrepStage(null);
|
||||
if (err.name === 'AbortError') { toast('Upload cancelled'); setDubStep('idle'); useAppStore.getState().dismissPill(); }
|
||||
else { setDubError(err.message); setDubStep('idle'); toast.error('Upload failed: ' + err.message); useAppStore.getState().errorPill(err.message); }
|
||||
setTranscribeStart(null);
|
||||
} finally { dubAbortCtrlRef.current = null; }
|
||||
}, [setDubStep, setDubError, setDubTracks, setDubPrepStage, setDubJobId, setDubFilename, setDubTaskId, setDubSegments, _waitForPrep, _waitForTranscribe, loadProjects, loadProfiles]);
|
||||
|
||||
const handleDubIngestUrl = useCallback(async (url, opts = {}) => {
|
||||
const clean = (url || '').trim();
|
||||
if (!clean) return;
|
||||
setDubStep('uploading'); setDubError(''); setDubTracks([]); setDubPrepStage('download');
|
||||
const ctrl = new AbortController();
|
||||
dubAbortCtrlRef.current = ctrl;
|
||||
const clientJobId = Math.random().toString(36).slice(2, 10);
|
||||
dubClientJobIdRef.current = clientJobId;
|
||||
setDubJobId(clientJobId);
|
||||
useAppStore.getState().showPill('loading-model', 'Downloading video…', { cancellable: true });
|
||||
try {
|
||||
const data = await dubIngestUrl(clean, clientJobId, { signal: ctrl.signal, fetchSubs: !!opts.fetchSubs, subLangs: opts.subLangs });
|
||||
setDubJobId(data.job_id); setDubTaskId(data.task_id);
|
||||
useAppStore.getState().showPill('loading-model', 'Extracting audio & scenes…', { cancellable: true });
|
||||
await _waitForPrep(data.task_id, ctrl);
|
||||
setDubStep('transcribing'); setDubPrepStage(null);
|
||||
setTranscribeStart(Date.now()); setDubSegments([]);
|
||||
useAppStore.getState().showPill('transcribing', 'Transcribing audio…', { cancellable: true });
|
||||
await _waitForTranscribe(data.job_id, ctrl);
|
||||
setTranscribeStart(null); setDubStep('editing');
|
||||
useAppStore.getState().completePill('Transcription complete');
|
||||
loadProjects(); loadProfiles();
|
||||
toast.success('Ingested ' + clean.slice(0, 60));
|
||||
} catch (err) {
|
||||
setDubPrepStage(null);
|
||||
if (err.name === 'AbortError') { toast('Ingest cancelled'); setDubStep('idle'); useAppStore.getState().dismissPill(); }
|
||||
else { setDubError(err.message); setDubStep('idle'); toast.error('URL ingest failed: ' + err.message); useAppStore.getState().errorPill(err.message); }
|
||||
setTranscribeStart(null);
|
||||
} finally { dubAbortCtrlRef.current = null; }
|
||||
}, [setDubStep, setDubError, setDubTracks, setDubPrepStage, setDubJobId, setDubTaskId, setDubSegments, _waitForPrep, _waitForTranscribe, loadProjects, loadProfiles]);
|
||||
|
||||
const handleDubAbort = useCallback(async () => {
|
||||
const jobId = dubClientJobIdRef.current || dubJobId;
|
||||
if (dubAbortCtrlRef.current) dubAbortCtrlRef.current.abort();
|
||||
if (jobId) await apiDubAbort(jobId);
|
||||
}, [dubJobId]);
|
||||
|
||||
const handleDubRetryTranscribe = useCallback(async () => {
|
||||
if (!dubJobId) return;
|
||||
const ctrl = new AbortController();
|
||||
dubAbortCtrlRef.current = ctrl;
|
||||
setDubError(''); setDubSegments([]); setDubStep('transcribing');
|
||||
setTranscribeStart(Date.now());
|
||||
try {
|
||||
await _waitForTranscribe(dubJobId, ctrl);
|
||||
setTranscribeStart(null); setDubStep('editing'); loadProjects();
|
||||
} catch (err) {
|
||||
setTranscribeStart(null);
|
||||
if (err.name === 'AbortError') { toast('Retry cancelled'); setDubStep('idle'); }
|
||||
else { setDubError(err.message); setDubStep('idle'); toast.error('Transcription failed: ' + err.message); }
|
||||
} finally { dubAbortCtrlRef.current = null; }
|
||||
}, [dubJobId, setDubError, setDubSegments, setDubStep, _waitForTranscribe, loadProjects]);
|
||||
|
||||
const handleCleanupSegments = useCallback(async () => {
|
||||
if (!dubJobId || !dubSegments.length) return;
|
||||
const before = dubSegments.length;
|
||||
try {
|
||||
const data = await dubCleanupSegments(dubJobId);
|
||||
setDubSegments(data.segments || []);
|
||||
const delta = before - (data.after ?? data.segments.length);
|
||||
toast.success(delta > 0 ? `Cleaned ${delta} fragment${delta === 1 ? '' : 's'}` : 'Segments already clean');
|
||||
} catch (err) { toast.error('Clean up failed: ' + err.message); }
|
||||
}, [dubJobId, dubSegments, setDubSegments]);
|
||||
|
||||
const handleTranslateAll = useCallback(async () => {
|
||||
if (!dubSegments.length || !dubLangCode) return;
|
||||
setIsTranslating(true);
|
||||
try {
|
||||
const data = await dubTranslate({
|
||||
segments: dubSegments.map(s => ({
|
||||
id: String(s.id),
|
||||
text: (s.text_original && s.text_original.trim()) ? s.text_original : s.text,
|
||||
target_lang: s.target_lang,
|
||||
direction: s.direction || undefined,
|
||||
slot_seconds: (s.end != null && s.start != null) ? (s.end - s.start) : undefined,
|
||||
})),
|
||||
target_lang: dubLangCode,
|
||||
provider: translateProvider,
|
||||
quality: translateQuality,
|
||||
glossary: glossaryTerms.length
|
||||
? glossaryTerms.map(t => ({ source: t.source, target: t.target, note: t.note || '' }))
|
||||
: undefined,
|
||||
});
|
||||
const translatedMap = {};
|
||||
const errors = [];
|
||||
(data.translated || []).forEach(t => { translatedMap[t.id] = t; if (t.error) errors.push({ id: t.id, error: t.error }); });
|
||||
setDubSegments(dubSegments.map(s => {
|
||||
const hit = translatedMap[s.id];
|
||||
if (!hit) return s;
|
||||
return { ...s, text: (hit.text && hit.text.trim()) ? hit.text : s.text, translate_error: hit.error || undefined, translate_literal: hit.literal || undefined, translate_critique: hit.critique || undefined };
|
||||
}));
|
||||
if (data.cinematic_skipped === 'no-llm-configured') {
|
||||
toast('Cinematic quality needs an LLM — set TRANSLATE_BASE_URL + TRANSLATE_API_KEY (Ollama works locally). Falling back to Fast.', { icon: 'ℹ️', duration: 7000 });
|
||||
}
|
||||
if (errors.length) {
|
||||
const unique = [...new Set(errors.map(e => e.error))];
|
||||
toast.error(`${errors.length}/${data.translated.length} segment${errors.length === 1 ? '' : 's'} failed: ${unique[0].slice(0, 120)}`, { duration: 6000 });
|
||||
} else {
|
||||
const qLabel = data.quality_used === 'cinematic' ? ' (Cinematic)' : '';
|
||||
toast.success(`Translated ${data.translated.length} segment${data.translated.length === 1 ? '' : 's'} → ${data.target_lang}${qLabel}`);
|
||||
}
|
||||
} catch (err) { setDubError('Translation failed: ' + err.message); }
|
||||
setIsTranslating(false);
|
||||
}, [dubSegments, dubLangCode, translateProvider, translateQuality, glossaryTerms, setIsTranslating, setDubSegments, setDubError]);
|
||||
|
||||
const handleDubGenerate = useCallback(async (opts = {}) => {
|
||||
const regenOnly = Array.isArray(opts.regenOnly) && opts.regenOnly.length ? opts.regenOnly : null;
|
||||
const preview = !!opts.preview;
|
||||
setDubStep('generating');
|
||||
setDubProgress({ current: 0, total: dubSegments.length, text: '' });
|
||||
setDubError('');
|
||||
const genLabel = regenOnly ? `Regenerating ${regenOnly.length} segment${regenOnly.length > 1 ? 's' : ''}…` : 'Generating dub…';
|
||||
useAppStore.getState().showPill('generating', genLabel, { cancellable: true });
|
||||
try {
|
||||
const body = {
|
||||
segment_ids: dubSegments.map(s => String(s.id)),
|
||||
regen_only: regenOnly,
|
||||
segments: dubSegments.map(s => {
|
||||
let fin_prof = s.profile_id || '';
|
||||
let fin_inst = s.instruct || '';
|
||||
if (fin_prof.startsWith('preset:')) {
|
||||
const pr = PRESETS.find(p => p.id === fin_prof.replace('preset:', ''));
|
||||
if (pr) { const parts = Object.values(pr.attrs).filter(v => v !== 'Auto'); if (fin_inst.trim()) parts.push(fin_inst.trim()); fin_inst = parts.join(', '); }
|
||||
fin_prof = '';
|
||||
}
|
||||
return { start: s.start, end: s.end, text: s.text, instruct: fin_inst, profile_id: fin_prof, speed: s.speed || undefined, gain: s.gain !== undefined && s.gain !== 1.0 ? s.gain : undefined, target_lang: s.target_lang || undefined, direction: s.direction || undefined };
|
||||
}),
|
||||
language: dubLang === 'Auto' ? 'Auto' : dubLang,
|
||||
language_code: dubLangCode,
|
||||
instruct: dubInstruct,
|
||||
num_step: steps, guidance_scale: cfg, speed,
|
||||
preview,
|
||||
};
|
||||
const data = await dubGenerate(dubJobId, body);
|
||||
setDubTaskId(data.task_id);
|
||||
const streamRes = await fetch(tasksStreamUrl(data.task_id));
|
||||
const reader = streamRes.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let wasCancelled = false;
|
||||
let sawDone = false;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n'); buffer = lines.pop();
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const evt = JSON.parse(line.slice(6));
|
||||
if (evt.type === 'progress') {
|
||||
setDubProgress({ current: evt.current + 1, total: evt.total, text: evt.text });
|
||||
useAppStore.getState().setPillProgress(Math.round(((evt.current + 1) / evt.total) * 100));
|
||||
useAppStore.getState().setPillLabel(`Generating dub… ${evt.current + 1}/${evt.total}`);
|
||||
} else if (evt.type === 'done') {
|
||||
sawDone = true;
|
||||
setDubStep('done');
|
||||
setDubTracks(evt.tracks || []);
|
||||
if (evt.sync_scores) setDubSegments(prev => prev.map((s, idx) => ({ ...s, sync_ratio: evt.sync_scores[idx] })));
|
||||
if (evt.seg_num_step && typeof evt.seg_num_step === 'object') {
|
||||
const previewIds = Object.entries(evt.seg_num_step).filter(([, n]) => typeof n === 'number' && n < steps).map(([id]) => id);
|
||||
setPreviewSegIds(previewIds);
|
||||
}
|
||||
if (evt.seg_hashes && Object.keys(evt.seg_hashes).length > 0) {
|
||||
useAppStore.getState().setLastGenFingerprints?.(evt.seg_hashes);
|
||||
} else {
|
||||
try { const plan = await apiPost('/tools/incremental', { segments: dubSegments.map(s => ({ id: String(s.id), text: s.text, target_lang: s.target_lang, profile_id: s.profile_id, instruct: s.instruct, speed: s.speed, direction: s.direction })) }); useAppStore.getState().setLastGenFingerprints?.(plan.fingerprints || {}); } catch {}
|
||||
}
|
||||
} else if (evt.type === 'cancelled') {
|
||||
wasCancelled = true; setDubStep('editing'); setDubError('Generation aborted.'); toast('Dubbing aborted', { icon: '⏹' });
|
||||
} else if (evt.type === 'error') setDubError(p => p + `\nSeg ${evt.segment}: ${evt.error}`);
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
setDubTaskId(null);
|
||||
if (!wasCancelled) {
|
||||
if (!sawDone) throw new Error('Generation stream ended before completion');
|
||||
if (dubStep !== 'done') setDubStep('done');
|
||||
loadDubHistory(); loadProjects(); playPing();
|
||||
useAppStore.getState().completePill('Dub complete');
|
||||
} else { useAppStore.getState().dismissPill(); }
|
||||
} catch (err) {
|
||||
setDubError(err.message); setDubStep('editing'); setDubTaskId(null);
|
||||
useAppStore.getState().errorPill(err.message);
|
||||
}
|
||||
}, [dubJobId, dubSegments, dubLang, dubLangCode, dubInstruct, steps, cfg, speed, dubStep, setDubStep, setDubProgress, setDubError, setDubTracks, setDubSegments, setDubTaskId, setPreviewSegIds, loadDubHistory, loadProjects]);
|
||||
|
||||
const handleDubStop = useCallback(async () => {
|
||||
if (!dubTaskId) return;
|
||||
const prevStep = dubStep;
|
||||
setDubStep('stopping');
|
||||
try {
|
||||
await tasksCancel(dubTaskId);
|
||||
} catch (e) {
|
||||
setDubStep(prevStep);
|
||||
toast.error('Failed to stop');
|
||||
}
|
||||
}, [dubTaskId, dubStep, setDubStep]);
|
||||
|
||||
return {
|
||||
translateProvider, setTranslateProvider,
|
||||
showTranscript, setShowTranscript,
|
||||
previewAudios, setPreviewAudios,
|
||||
transcribeElapsed,
|
||||
handleDubUpload, handleDubIngestUrl,
|
||||
handleDubAbort, handleDubRetryTranscribe,
|
||||
handleDubStop, handleDubGenerate,
|
||||
handleCleanupSegments, handleTranslateAll,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useAppStore } from '../store';
|
||||
import { createProfile, deleteProfile as apiDeleteProfile, lockProfile, unlockProfile } from '../api/profiles';
|
||||
import { generateSpeech, audioUrlWithCacheBust } from '../api/generate';
|
||||
import { playBlobAudio } from '../utils/media';
|
||||
import { PRESETS } from '../utils/constants';
|
||||
import { askConfirm } from '../utils/dialog';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
/**
|
||||
* Encapsulates voice-profile CRUD, lock/unlock, preview, and save-from-history.
|
||||
*/
|
||||
export default function useProfiles({ loadHistory, loadProfiles }) {
|
||||
const [selectedProfile, setSelectedProfile] = useState(null);
|
||||
const [showSaveProfile, setShowSaveProfile] = useState(false);
|
||||
const [profileName, setProfileName] = useState('');
|
||||
const [previewLoading, setPreviewLoading] = useState(null);
|
||||
const [segmentPreviewLoading, setSegmentPreviewLoading] = useState(null);
|
||||
|
||||
// Voice Preview floating card
|
||||
const [isVoicePreviewOpen, setIsVoicePreviewOpen] = useState(false);
|
||||
const [voicePreviewProfileId, setVoicePreviewProfileId] = useState('');
|
||||
|
||||
const setRefText = useAppStore(s => s.setRefText);
|
||||
const setInstruct = useAppStore(s => s.setInstruct);
|
||||
const setLanguage = useAppStore(s => s.setLanguage);
|
||||
const language = useAppStore(s => s.language);
|
||||
const mode = useAppStore(s => s.mode);
|
||||
const steps = useAppStore(s => s.steps);
|
||||
const cfg = useAppStore(s => s.cfg);
|
||||
const dubLang = useAppStore(s => s.dubLang);
|
||||
const dubSegments = useAppStore(s => s.dubSegments);
|
||||
const text = useAppStore(s => s.text);
|
||||
|
||||
// loadProfiles is provided by useAppData (single source of truth)
|
||||
|
||||
const handleSaveProfile = useCallback(async (refAudio, refText, instruct, language) => {
|
||||
if (!profileName.trim() || !refAudio) return toast.error("Need a name and reference audio");
|
||||
const formData = new FormData();
|
||||
formData.append("name", profileName);
|
||||
const arrBuf = await refAudio.arrayBuffer();
|
||||
const safeBlob = new Blob([arrBuf], { type: refAudio.type });
|
||||
formData.append("ref_audio", safeBlob, refAudio.name || "profile.wav");
|
||||
formData.append("ref_text", refText);
|
||||
formData.append("instruct", instruct);
|
||||
formData.append("language", language);
|
||||
try {
|
||||
await createProfile(formData);
|
||||
setShowSaveProfile(false);
|
||||
setProfileName('');
|
||||
await loadProfiles();
|
||||
} catch (e) { toast.error(e.message); }
|
||||
}, [profileName, loadProfiles]);
|
||||
|
||||
const handleDeleteProfile = useCallback(async (id) => {
|
||||
if (!(await askConfirm('Delete this voice profile?'))) return;
|
||||
await apiDeleteProfile(id);
|
||||
if (selectedProfile === id) setSelectedProfile(null);
|
||||
await loadProfiles();
|
||||
}, [selectedProfile, loadProfiles]);
|
||||
|
||||
const handleSelectProfile = useCallback((profile) => {
|
||||
setSelectedProfile(profile.id);
|
||||
setRefText(profile.ref_text || '');
|
||||
setInstruct(profile.instruct || '');
|
||||
if (profile.language && profile.language !== 'Auto') setLanguage(profile.language);
|
||||
}, [setRefText, setInstruct, setLanguage]);
|
||||
|
||||
const handlePreviewVoice = useCallback(async (proj, e) => {
|
||||
e.stopPropagation();
|
||||
if (previewLoading) return;
|
||||
|
||||
let previewText = "This is a voice preview.";
|
||||
let reqLang = language;
|
||||
|
||||
if (mode === 'dub' && dubSegments.length > 0) {
|
||||
let seg = dubSegments.find(s => s.profile_id === proj.id && s.text.trim().length > 0);
|
||||
if (!seg) seg = dubSegments.find(s => s.text.trim().length > 0);
|
||||
if (seg) previewText = seg.text;
|
||||
reqLang = dubLang;
|
||||
} else if (text.trim() !== '') {
|
||||
previewText = text;
|
||||
}
|
||||
|
||||
setPreviewLoading(proj.id);
|
||||
const toastId = toast.loading(`Synthesizing preview for ${proj.name}...`);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("text", previewText);
|
||||
formData.append("profile_id", proj.id);
|
||||
if (reqLang && reqLang !== 'Auto') formData.append("language", reqLang);
|
||||
formData.append("num_step", steps || 16);
|
||||
const res = await generateSpeech(formData);
|
||||
const blob = await res.blob();
|
||||
toast.success('Preview ready!', { id: toastId });
|
||||
playBlobAudio(blob).catch(() => toast.error('Playback failed', { id: toastId }));
|
||||
await loadHistory();
|
||||
} catch (err) {
|
||||
toast.error('Preview failed: ' + err.message, { id: toastId });
|
||||
} finally {
|
||||
setPreviewLoading(null);
|
||||
}
|
||||
}, [previewLoading, language, mode, dubSegments, dubLang, text, steps, loadHistory]);
|
||||
|
||||
const handleSegmentPreview = useCallback(async (seg, e) => {
|
||||
e.preventDefault();
|
||||
if (segmentPreviewLoading) return;
|
||||
setSegmentPreviewLoading(seg.id);
|
||||
const toastId = toast.loading(`Synthesizing segment...`);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("text", seg.text);
|
||||
|
||||
let fin_prof = seg.profile_id || '';
|
||||
let fin_inst = seg.instruct || '';
|
||||
|
||||
if (fin_prof.startsWith('preset:')) {
|
||||
const pr = PRESETS.find(p => p.id === fin_prof.replace('preset:', ''));
|
||||
if (pr) {
|
||||
const parts = Object.values(pr.attrs).filter(v => v !== 'Auto');
|
||||
if (fin_inst.trim()) parts.push(fin_inst.trim());
|
||||
fin_inst = parts.join(', ');
|
||||
}
|
||||
fin_prof = '';
|
||||
}
|
||||
|
||||
if (fin_prof) formData.append("profile_id", fin_prof);
|
||||
if (fin_inst) formData.append("instruct", fin_inst);
|
||||
const fin_lang = seg.target_lang || dubLang;
|
||||
if (fin_lang !== 'Auto') formData.append("language", fin_lang);
|
||||
|
||||
formData.append("num_step", 8);
|
||||
formData.append("guidance_scale", cfg || 2.0);
|
||||
if (seg.speed && seg.speed !== 1.0) formData.append("speed", seg.speed);
|
||||
|
||||
const res = await generateSpeech(formData);
|
||||
const blob = await res.blob();
|
||||
toast.success('Preview ready!', { id: toastId });
|
||||
playBlobAudio(blob).catch(() => toast.error('Playback failed', { id: toastId }));
|
||||
} catch (err) {
|
||||
toast.error('Preview failed: ' + err.message, { id: toastId });
|
||||
} finally {
|
||||
setSegmentPreviewLoading(null);
|
||||
}
|
||||
}, [segmentPreviewLoading, dubLang, cfg]);
|
||||
|
||||
const handleSaveHistoryAsProfile = useCallback(async (item) => {
|
||||
try {
|
||||
const pName = `Voice ${new Date().toLocaleTimeString('en', {hour:'2-digit', minute:'2-digit'})} — ${(item.mode||'design').toUpperCase()}`;
|
||||
const response = await fetch(audioUrlWithCacheBust(item.audio_path));
|
||||
if (!response.ok) throw new Error("Audio not found");
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], item.audio_path, { type: "audio/wav" });
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("name", pName);
|
||||
formData.append("ref_audio", file);
|
||||
const extractedText = item.text ? (item.text.length > 50 ? item.text.substring(0, 50) : item.text) : "";
|
||||
formData.append("ref_text", extractedText);
|
||||
formData.append("instruct", item.instruct || "");
|
||||
formData.append("language", item.language || "Auto");
|
||||
if (item.seed !== undefined && item.seed !== null) {
|
||||
formData.append("seed", item.seed);
|
||||
}
|
||||
|
||||
await createProfile(formData);
|
||||
toast.success("Voice saved to profiles!");
|
||||
await loadProfiles();
|
||||
} catch (e) {
|
||||
toast.error(e.message || "Failed to save voice profile");
|
||||
}
|
||||
}, [loadProfiles]);
|
||||
|
||||
const handleLockProfile = useCallback(async (profileId, historyId, seed) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("history_id", historyId);
|
||||
if (seed !== null && seed !== undefined) formData.append("seed", seed);
|
||||
await lockProfile(profileId, formData);
|
||||
toast.success("🔒 Voice locked! Identity is now consistent across all generations.");
|
||||
await loadProfiles();
|
||||
} catch (e) {
|
||||
toast.error(e.message || "Failed to lock profile");
|
||||
}
|
||||
}, [loadProfiles]);
|
||||
|
||||
const handleUnlockProfile = useCallback(async (profileId) => {
|
||||
try {
|
||||
await unlockProfile(profileId);
|
||||
toast.success("🎨 Voice unlocked. Generations will vary again.");
|
||||
await loadProfiles();
|
||||
} catch (e) {
|
||||
toast.error(e.message || "Failed to unlock profile");
|
||||
}
|
||||
}, [loadProfiles]);
|
||||
|
||||
return {
|
||||
selectedProfile, setSelectedProfile,
|
||||
showSaveProfile, setShowSaveProfile,
|
||||
profileName, setProfileName,
|
||||
previewLoading, segmentPreviewLoading,
|
||||
isVoicePreviewOpen, setIsVoicePreviewOpen,
|
||||
voicePreviewProfileId, setVoicePreviewProfileId,
|
||||
handleSaveProfile,
|
||||
handleDeleteProfile,
|
||||
handleSelectProfile,
|
||||
handlePreviewVoice,
|
||||
handleSegmentPreview,
|
||||
handleSaveHistoryAsProfile,
|
||||
handleLockProfile,
|
||||
handleUnlockProfile,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { useAppStore } from '../store';
|
||||
import { generateSpeech, audioUrlWithCacheBust } from '../api/generate';
|
||||
import { playBlobAudio, playPing } from '../utils/media';
|
||||
import { probeAudioDuration } from '../utils/format';
|
||||
import { CLONE_MAX_SECONDS, PRESETS } from '../utils/constants';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
/**
|
||||
* Encapsulates TTS generation logic, streaming response handling,
|
||||
* audio ingestion (with trim gate), and preset/tag helpers.
|
||||
*/
|
||||
export default function useTTS({ selectedProfile, setSelectedProfile, loadHistory }) {
|
||||
const text = useAppStore(s => s.text);
|
||||
const setText = useAppStore(s => s.setText);
|
||||
const language = useAppStore(s => s.language);
|
||||
const instruct = useAppStore(s => s.instruct);
|
||||
const refText = useAppStore(s => s.refText);
|
||||
const speed = useAppStore(s => s.speed);
|
||||
const steps = useAppStore(s => s.steps);
|
||||
const cfg = useAppStore(s => s.cfg);
|
||||
const denoise = useAppStore(s => s.denoise);
|
||||
const tShift = useAppStore(s => s.tShift);
|
||||
const posTemp = useAppStore(s => s.posTemp);
|
||||
const classTemp = useAppStore(s => s.classTemp);
|
||||
const layerPenalty = useAppStore(s => s.layerPenalty);
|
||||
const postprocess = useAppStore(s => s.postprocess);
|
||||
const duration = useAppStore(s => s.duration);
|
||||
const vdStates = useAppStore(s => s.vdStates);
|
||||
const mode = useAppStore(s => s.mode);
|
||||
const setSidebarTab = useAppStore(s => s.setSidebarTab);
|
||||
|
||||
const [refAudio, setRefAudio] = useState(null);
|
||||
const [pendingTrimFile, setPendingTrimFile] = useState(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [generationTime, setGenerationTime] = useState(0);
|
||||
const timerRef = useRef(null);
|
||||
const textAreaRef = useRef(null);
|
||||
|
||||
const ingestRefAudio = useCallback(async (file) => {
|
||||
if (!file) { setRefAudio(null); return; }
|
||||
const dur = await probeAudioDuration(file);
|
||||
if (dur && dur > CLONE_MAX_SECONDS) {
|
||||
setPendingTrimFile(file);
|
||||
setSelectedProfile(null);
|
||||
toast(`Audio is ${dur.toFixed(1)}s — trim to ≤${CLONE_MAX_SECONDS}s for best cloning`);
|
||||
return;
|
||||
}
|
||||
setRefAudio(file);
|
||||
setSelectedProfile(null);
|
||||
}, [setSelectedProfile]);
|
||||
|
||||
const insertTag = useCallback((tag) => {
|
||||
if (!textAreaRef.current) return;
|
||||
const start = textAreaRef.current.selectionStart;
|
||||
const end = textAreaRef.current.selectionEnd;
|
||||
setText(text.substring(0, start) + tag + text.substring(end));
|
||||
setTimeout(() => { textAreaRef.current.focus(); textAreaRef.current.setSelectionRange(start + tag.length, start + tag.length); }, 0);
|
||||
}, [text, setText]);
|
||||
|
||||
const applyPreset = useCallback((preset) => {
|
||||
useAppStore.getState().setVdStates(preset.attrs);
|
||||
if (preset.tags && !text.includes(preset.tags.trim())) insertTag(preset.tags);
|
||||
}, [text, insertTag]);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!text.trim()) return toast.error("Please enter text");
|
||||
if (mode === 'clone' && !refAudio && !selectedProfile) return toast.error("Upload an audio or select a voice profile");
|
||||
setIsGenerating(true);
|
||||
setGenerationTime(0);
|
||||
const st = Date.now();
|
||||
timerRef.current = setInterval(() => {
|
||||
const elapsed = ((Date.now() - st) / 1000).toFixed(1);
|
||||
setGenerationTime(prev => {
|
||||
const suffix = /\(\d+%\)$/.exec(String(prev))?.[0];
|
||||
return suffix ? `${elapsed} ${suffix}` : elapsed;
|
||||
});
|
||||
}, 100);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("text", text);
|
||||
if (language !== 'Auto') formData.append("language", language);
|
||||
formData.append("num_step", steps);
|
||||
formData.append("guidance_scale", cfg);
|
||||
formData.append("speed", speed);
|
||||
formData.append("denoise", denoise);
|
||||
formData.append("t_shift", tShift);
|
||||
formData.append("position_temperature", posTemp);
|
||||
formData.append("class_temperature", classTemp);
|
||||
formData.append("layer_penalty_factor", layerPenalty);
|
||||
formData.append("postprocess_output", postprocess);
|
||||
if (duration) formData.append("duration", parseFloat(duration));
|
||||
|
||||
if (mode === 'clone') {
|
||||
if (selectedProfile) {
|
||||
formData.append("profile_id", selectedProfile);
|
||||
} else if (refAudio) {
|
||||
const arrBuf = await refAudio.arrayBuffer();
|
||||
const safeBlob = new Blob([arrBuf], { type: refAudio.type });
|
||||
formData.append("ref_audio", safeBlob, refAudio.name || "audio.wav");
|
||||
formData.append("ref_text", refText);
|
||||
}
|
||||
if (instruct) formData.append("instruct", instruct);
|
||||
} else {
|
||||
const designSeed = Math.floor(Math.random() * 2147483647);
|
||||
formData.append("seed", designSeed);
|
||||
const parts = Object.values(vdStates).filter(v => v !== 'Auto');
|
||||
if (instruct.trim()) parts.push(instruct.trim());
|
||||
const finalInstruct = parts.join(', ');
|
||||
if (finalInstruct) formData.append("instruct", finalInstruct);
|
||||
if (selectedProfile) {
|
||||
formData.append("profile_id", selectedProfile);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await generateSpeech(formData);
|
||||
const reader = response.body.getReader();
|
||||
const chunks = [];
|
||||
let receivedLength = 0;
|
||||
const contentLength = parseInt(response.headers.get('Content-Length') || '0', 10);
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
receivedLength += value.length;
|
||||
if (contentLength > 0) {
|
||||
const pct = Math.round((receivedLength / contentLength) * 100);
|
||||
setGenerationTime(prev => `${prev.toString().split(' ')[0]} (${pct}%)`);
|
||||
}
|
||||
}
|
||||
|
||||
const blob = new Blob(chunks, { type: 'audio/wav' });
|
||||
try { await playBlobAudio(blob); } catch (e) {}
|
||||
|
||||
await loadHistory();
|
||||
setSidebarTab('history');
|
||||
playPing();
|
||||
} catch (err) {
|
||||
toast.error("Error: " + err.message);
|
||||
} finally {
|
||||
clearInterval(timerRef.current);
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}, [text, mode, selectedProfile, refAudio, refText, language, instruct, steps, cfg, speed, denoise, tShift, posTemp, classTemp, layerPenalty, postprocess, duration, vdStates, loadHistory, setSidebarTab]);
|
||||
|
||||
return {
|
||||
refAudio, setRefAudio,
|
||||
pendingTrimFile, setPendingTrimFile,
|
||||
isGenerating, generationTime,
|
||||
textAreaRef,
|
||||
ingestRefAudio,
|
||||
insertTag, applyPreset,
|
||||
handleGenerate,
|
||||
};
|
||||
}
|
||||
@@ -806,6 +806,14 @@ export default function DubTab(props) {
|
||||
value="" onChange={(e) => { const v = e.target.value; if (v === '__clear__') bulkApplyToSelected({ profile_id: '' }); else if (v) bulkApplyToSelected({ profile_id: v }); }}>
|
||||
<option value="">Set voice…</option>
|
||||
<option value="__clear__">⊘ Default</option>
|
||||
{speakerClones && Object.keys(speakerClones).length > 0 && (
|
||||
<optgroup label="From Video">
|
||||
{Object.keys(speakerClones).map(spk => {
|
||||
const autoId = `auto:${(spk || '').toLowerCase().replace(/\s+/g, '_')}`;
|
||||
return <option key={autoId} value={autoId}>🎤 {spk}</option>;
|
||||
})}
|
||||
</optgroup>
|
||||
)}
|
||||
{profiles.filter(p => !p.instruct).length > 0 && (
|
||||
<optgroup label="Clone">
|
||||
{profiles.filter(p => !p.instruct).map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
@@ -842,6 +850,7 @@ export default function DubTab(props) {
|
||||
<DubSegmentTable
|
||||
segments={dubSegments}
|
||||
profiles={profiles}
|
||||
speakerClones={speakerClones}
|
||||
dubStep={dubStep}
|
||||
dubProgress={dubProgress}
|
||||
previewLoadingId={segmentPreviewLoading}
|
||||
|
||||
@@ -174,10 +174,11 @@
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
padding: 6px 10px 8px;
|
||||
padding: 8px 12px 10px;
|
||||
border-color: color-mix(in srgb, #f3a5b6 25%, transparent);
|
||||
border-left-color: #f3a5b6;
|
||||
background: color-mix(in srgb, #f3a5b6 3%, transparent);
|
||||
background: linear-gradient(135deg, color-mix(in srgb, #f3a5b6 4%, transparent), color-mix(in srgb, #d3869b 2%, transparent));
|
||||
box-shadow: 0 0 12px color-mix(in srgb, #f3a5b6 6%, transparent);
|
||||
}
|
||||
.reco-banner__gb {
|
||||
font-size: var(--text-2xs);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { isTauri as _isTauri } from '../utils/media';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
@@ -924,7 +925,7 @@ export function EnginesTab() {
|
||||
}
|
||||
|
||||
|
||||
const isTauri = () => typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||
const isTauri = () => _isTauri;
|
||||
|
||||
// Tauri v2's webview disables native window.confirm/alert — they return
|
||||
// false silently, making Delete/Reinstall buttons appear dead. Route through
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
SetupWizard — premium onboarding flow
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.setup-wizard {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
@@ -6,58 +10,97 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0; /* let flex children shrink */
|
||||
overflow: hidden; /* NO page-level scroll — only embed scrolls */
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Step pills — top bar ────────────────────────────────────────────── */
|
||||
/* ── Slide transition for step content ──────────────────────────────── */
|
||||
@keyframes swiz-enter {
|
||||
from { opacity: 0; transform: translateX(16px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
@keyframes swiz-enter-back {
|
||||
from { opacity: 0; transform: translateX(-16px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
.swiz-slide {
|
||||
animation: swiz-enter 0.3s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* ── Step pills — connected stepper bar ─────────────────────────────── */
|
||||
.setup-wizard__steps {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 12px 0;
|
||||
padding: 8px 12px 0;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
}
|
||||
.setup-wizard__step {
|
||||
padding: 5px 14px;
|
||||
padding: 6px 16px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.74rem;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.4;
|
||||
color: var(--color-fg-muted);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--color-fg-muted, #928374);
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
position: relative;
|
||||
font-weight: 500;
|
||||
}
|
||||
.setup-wizard__step:hover:not(.setup-wizard__step--active) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.setup-wizard__step--active {
|
||||
background: rgba(243, 165, 182, 0.12);
|
||||
border-color: rgba(243, 165, 182, 0.35);
|
||||
background: linear-gradient(135deg, rgba(211, 134, 155, 0.2), rgba(211, 134, 155, 0.1));
|
||||
border-color: rgba(211, 134, 155, 0.4);
|
||||
color: #f3a5b6;
|
||||
font-weight: 600;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(211, 134, 155, 0.15),
|
||||
0 0 12px rgba(211, 134, 155, 0.1);
|
||||
}
|
||||
.setup-wizard__step--done {
|
||||
color: #8ec07c;
|
||||
border-color: rgba(142, 192, 124, 0.35);
|
||||
border-color: rgba(142, 192, 124, 0.3);
|
||||
background: rgba(142, 192, 124, 0.06);
|
||||
}
|
||||
|
||||
/* ── Hero — horizontal single-line: logo | title · subtitle ──────────── */
|
||||
/* Connector lines between pills */
|
||||
.setup-wizard__step-connector {
|
||||
width: 20px;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.setup-wizard__step-connector--done {
|
||||
background: rgba(142, 192, 124, 0.3);
|
||||
}
|
||||
|
||||
/* ── Hero — horizontal single-line ───────────────────────────────────── */
|
||||
.setup-wizard__hero {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px 6px;
|
||||
padding: 6px 12px 4px;
|
||||
flex-shrink: 0;
|
||||
max-width: 760px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
.setup-wizard__logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
filter: drop-shadow(0 0 8px rgba(211, 134, 155, 0.3));
|
||||
}
|
||||
.setup-wizard__hero-text {
|
||||
display: flex;
|
||||
@@ -68,10 +111,10 @@
|
||||
}
|
||||
.setup-wizard__hero h1 {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
font-size: 1.2rem;
|
||||
font-family: var(--font-display, var(--font-sans));
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -83,6 +126,7 @@
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ── Embed panel — the ONLY scrollable region ────────────────────────── */
|
||||
@@ -90,31 +134,23 @@
|
||||
padding: 4px 0;
|
||||
margin-top: 4px;
|
||||
flex: 1 1 0;
|
||||
min-height: 0; /* critical for flex child scroll */
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ── Nav bar — pinned to bottom, always visible above LogsFooter ─────── */
|
||||
/* ── Nav bar — pinned to bottom ──────────────────────────────────────── */
|
||||
.setup-wizard__nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
padding: 10px 0 6px;
|
||||
padding: 8px 0 6px;
|
||||
background: var(--color-bg, #1d2021);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.setup-wizard--centered {
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Card / checklist rows ───────────────────────────────────────────── */
|
||||
.setup-wizard__card {
|
||||
display: flex;
|
||||
@@ -154,60 +190,169 @@
|
||||
|
||||
.setup-wizard__warn { color: var(--color-warn, #fabd2f); font-weight: 600; }
|
||||
|
||||
/* ── Welcome step cards ──────────────────────────────────────────────── */
|
||||
/* ── Welcome step — glassmorphism cards with stagger ─────────────────── */
|
||||
.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 {
|
||||
.setup-wizard__welcome-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@keyframes swiz-card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.swiz-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);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
animation: swiz-card-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.setup-wizard__welcome-card strong {
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.3;
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
.swiz-welcome-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: linear-gradient(to bottom, rgba(211, 134, 155, 0.5), rgba(211, 134, 155, 0.1));
|
||||
border-radius: 3px 0 0 3px;
|
||||
}
|
||||
.setup-wizard__welcome-card p {
|
||||
margin: 0;
|
||||
color: var(--color-fg-muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
.swiz-welcome-card:nth-child(1) { animation-delay: 0s; }
|
||||
.swiz-welcome-card:nth-child(2) { animation-delay: 0.08s; }
|
||||
.swiz-welcome-card:nth-child(3) { animation-delay: 0.16s; }
|
||||
|
||||
.swiz-welcome-card:hover {
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
.setup-wizard__welcome-num {
|
||||
|
||||
.swiz-welcome-card__icon {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
background: rgba(211, 134, 155, 0.12);
|
||||
border-radius: 8px;
|
||||
background: rgba(211, 134, 155, 0.1);
|
||||
color: #d3869b;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.setup-wizard__welcome-note {
|
||||
.swiz-welcome-card__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.swiz-welcome-card__title {
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 2px;
|
||||
display: block;
|
||||
}
|
||||
.swiz-welcome-card__desc {
|
||||
margin: 0;
|
||||
color: var(--color-fg-muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.swiz-welcome-note {
|
||||
margin: 0;
|
||||
color: var(--color-fg-subtle);
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
animation: swiz-card-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) 0.28s both;
|
||||
}
|
||||
|
||||
/* ── Preflight checklist ─────────────────────────────────────────────── */
|
||||
.swiz-checklist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.swiz-check-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.swiz-check-header__label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-fg-muted);
|
||||
}
|
||||
|
||||
@keyframes swiz-check-in {
|
||||
from { opacity: 0; transform: translateX(-8px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.swiz-check-row {
|
||||
animation: swiz-check-in 0.25s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
.swiz-check-row:nth-child(2) { animation-delay: 0.04s; }
|
||||
.swiz-check-row:nth-child(3) { animation-delay: 0.08s; }
|
||||
.swiz-check-row:nth-child(4) { animation-delay: 0.12s; }
|
||||
.swiz-check-row:nth-child(5) { animation-delay: 0.16s; }
|
||||
.swiz-check-row:nth-child(6) { animation-delay: 0.20s; }
|
||||
|
||||
.swiz-check-icon {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.swiz-check-icon--pass {
|
||||
background: rgba(142, 192, 124, 0.12);
|
||||
color: #8ec07c;
|
||||
}
|
||||
.swiz-check-icon--warn {
|
||||
background: rgba(250, 189, 47, 0.12);
|
||||
color: #fabd2f;
|
||||
}
|
||||
.swiz-check-icon--fail {
|
||||
background: rgba(251, 73, 52, 0.12);
|
||||
color: #fb4934;
|
||||
}
|
||||
|
||||
.swiz-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
color: var(--color-fg-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.swiz-loading .spinner { animation: spin 1s linear infinite; }
|
||||
|
||||
/* ── Model list ──────────────────────────────────────────────────────── */
|
||||
.setup-wizard__models {
|
||||
list-style: none;
|
||||
@@ -266,7 +411,7 @@
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
/* ── Inline HF token (compact, lives in models-toolbar) ──────────────── */
|
||||
/* ── Inline HF token ─────────────────────────────────────────────────── */
|
||||
.models-toolbar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -336,26 +481,76 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── Footnote — pinned to bottom ─────────────────────────────────────── */
|
||||
/* ── Footnote — polished ─────────────────────────────────────────────── */
|
||||
.setup-wizard__footnote {
|
||||
color: var(--color-fg-muted);
|
||||
font-size: 0.68rem;
|
||||
color: var(--color-fg-subtle, #665c54);
|
||||
font-size: 0.66rem;
|
||||
margin: 0;
|
||||
padding: 6px 0 8px;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.setup-wizard__footnote code {
|
||||
font-size: 0.62rem;
|
||||
color: var(--chrome-fg-dim, #665c54);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.setup-wizard__footnote-link {
|
||||
color: var(--chrome-accent, #d3869b);
|
||||
font-size: 0.64rem;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.setup-wizard__footnote-link:hover {
|
||||
opacity: 1;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.setup-wizard__footnote code { font-size: 0.64rem; color: var(--chrome-fg-dim, #665c54); }
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────────────────────── */
|
||||
/* ── Missing models indicator ────────────────────────────────────────── */
|
||||
.swiz-missing {
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
background: rgba(251, 73, 52, 0.06);
|
||||
border: 1px solid rgba(251, 73, 52, 0.15);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.swiz-status-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
color: var(--color-fg-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.swiz-status-loading .spinner { animation: spin 1s linear infinite; }
|
||||
|
||||
/* ── Responsive ──────────────────────────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
.setup-wizard {
|
||||
padding: 0 14px;
|
||||
}
|
||||
.setup-wizard__steps { gap: 4px; padding: 8px 8px 0; }
|
||||
.setup-wizard__steps { gap: 3px; padding: 8px 8px 0; }
|
||||
.setup-wizard__step { padding: 4px 10px; font-size: 0.7rem; }
|
||||
.setup-wizard__step-connector { width: 10px; }
|
||||
.setup-wizard__hero { gap: 8px; padding: 6px 8px 4px; }
|
||||
.setup-wizard__hero h1 { font-size: 1rem; }
|
||||
.setup-wizard__sub { font-size: 0.72rem; }
|
||||
.swiz-welcome-card { padding: 10px 14px; gap: 10px; }
|
||||
.swiz-welcome-card__icon { width: 26px; height: 26px; }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { CheckCircle, Loader, ArrowRight, AlertTriangle, XCircle, RefreshCw } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
CheckCircle, Loader, ArrowRight, AlertTriangle, XCircle,
|
||||
RefreshCw, Monitor, Download, Cog, FolderOpen,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../ui';
|
||||
import { useSetupStatus, usePreflight } from '../api/hooks';
|
||||
import { ModelStoreTab, EnginesTab } from './Settings';
|
||||
@@ -15,16 +18,53 @@ const doubleClickMaximize = async () => {
|
||||
} catch { /* non-tauri preview — ignore */ }
|
||||
};
|
||||
|
||||
/** Shorten an absolute path for display: /Users/foo/.cache/x → ~/.cache/x */
|
||||
function shortenPath(p) {
|
||||
if (!p) return '~/.cache/huggingface';
|
||||
try {
|
||||
const home = p.match(/^(\/Users\/[^/]+|\/home\/[^/]+|C:\\Users\\[^\\]+)/)?.[0];
|
||||
if (home) return p.replace(home, '~');
|
||||
} catch { /* fallthrough */ }
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Open a path in the OS file manager (Tauri only, no-op on web). */
|
||||
async function revealPath(path) {
|
||||
try {
|
||||
if (!('__TAURI_INTERNALS__' in window)) return;
|
||||
const { revealItemInDir } = await import('@tauri-apps/plugin-opener');
|
||||
await revealItemInDir(path);
|
||||
} catch { /* ignore — probably web preview */ }
|
||||
}
|
||||
|
||||
const CHECK_ICON = {
|
||||
pass: <CheckCircle size={14} color="#8ec07c" />,
|
||||
warn: <AlertTriangle size={14} color="#fabd2f" />,
|
||||
fail: <XCircle size={14} color="#fb4934" />,
|
||||
pass: <CheckCircle size={13} />,
|
||||
warn: <AlertTriangle size={13} />,
|
||||
fail: <XCircle size={13} />,
|
||||
};
|
||||
|
||||
/**
|
||||
* Pre-flight panel — renders the /setup/preflight result as a pass/warn/fail
|
||||
* list. Wizard blocks forward-nav on any fail; warns pass through.
|
||||
*/
|
||||
/* ── Welcome step cards ────────────────────────────────────────────────── */
|
||||
|
||||
const WELCOME_CARDS = [
|
||||
{
|
||||
icon: <Monitor size={16} />,
|
||||
title: 'System check',
|
||||
desc: 'Probe RAM, disk, GPU, ffmpeg, and network. Blockers are flagged upfront so you know before downloading.',
|
||||
},
|
||||
{
|
||||
icon: <Download size={16} />,
|
||||
title: 'Install models',
|
||||
desc: 'Download ~5 GB of weights — TTS + Whisper. Required models first, optional ones later.',
|
||||
},
|
||||
{
|
||||
icon: <Cog size={16} />,
|
||||
title: 'Pick engines',
|
||||
desc: 'Choose TTS / ASR / LLM backends. Defaults work out of the box — customize anytime in Settings.',
|
||||
},
|
||||
];
|
||||
|
||||
/* ── Preflight panel ───────────────────────────────────────────────────── */
|
||||
|
||||
function PreflightPanel({ report, loading, onRecheck }) {
|
||||
if (loading && !report) {
|
||||
return (
|
||||
@@ -43,8 +83,10 @@ function PreflightPanel({ report, loading, onRecheck }) {
|
||||
</Button>
|
||||
</div>
|
||||
{report.checks.map((c) => (
|
||||
<div key={c.id} className="setup-wizard__row" style={{ alignItems: 'flex-start', padding: '6px 2px' }}>
|
||||
<span className="swiz-check-icon">{CHECK_ICON[c.status] || null}</span>
|
||||
<div key={c.id} className="setup-wizard__row swiz-check-row" style={{ alignItems: 'flex-start', padding: '8px 4px' }}>
|
||||
<span className={`swiz-check-icon swiz-check-icon--${c.status}`}>
|
||||
{CHECK_ICON[c.status] || null}
|
||||
</span>
|
||||
<div className="setup-wizard__row-body">
|
||||
<span className="setup-wizard__row-title">{c.label}</span>
|
||||
<span className="setup-wizard__muted" style={{ whiteSpace: 'normal' }}>{c.detail}</span>
|
||||
@@ -64,13 +106,45 @@ function PreflightPanel({ report, loading, onRecheck }) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Stepper nav with connectors ───────────────────────────────────────── */
|
||||
|
||||
const STEP_LABELS = ['Welcome', 'System check', 'Install models', 'Pick engines'];
|
||||
|
||||
function StepperNav({ step, onStep }) {
|
||||
return (
|
||||
<div className="setup-wizard__steps" data-tauri-drag-region>
|
||||
{STEP_LABELS.map((label, i) => (
|
||||
<React.Fragment key={label}>
|
||||
{i > 0 && (
|
||||
<span className={`setup-wizard__step-connector${step > i - 1 ? ' setup-wizard__step-connector--done' : ''}`} />
|
||||
)}
|
||||
<button
|
||||
className={[
|
||||
'setup-wizard__step',
|
||||
step === i ? 'setup-wizard__step--active' : '',
|
||||
step > i ? 'setup-wizard__step--done' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => onStep(i)}
|
||||
type="button"
|
||||
aria-current={step === i ? 'step' : undefined}
|
||||
aria-label={`Step ${i + 1}: ${label}${step > i ? ' (completed)' : ''}`}
|
||||
>
|
||||
{step > i ? '✓ ' : `${i + 1}. `}{label}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main wizard component ─────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* First-run / "no models installed" gate.
|
||||
*
|
||||
* Flow:
|
||||
* 0. Welcome — hero + explainer + "continue"
|
||||
* 1. System — /setup/preflight results (OS, RAM, disk, GPU driver,
|
||||
* ffmpeg, network). Blocks on any fail.
|
||||
* 1. System — /setup/preflight results
|
||||
* 2. Models — ModelStoreTab, unlocks on models_ready
|
||||
* 3. Engines — EnginesTab + "Enter studio"
|
||||
*/
|
||||
@@ -84,8 +158,7 @@ export default function SetupWizard({ onReady }) {
|
||||
const pre = preQuery.data ?? null;
|
||||
const preLoading = preQuery.isLoading;
|
||||
|
||||
// Poll setup status every 4s while on Models step so "Finish" unlocks
|
||||
// as soon as downloads complete.
|
||||
// Poll setup status every 4s while on Models step
|
||||
useEffect(() => {
|
||||
if (step !== 2) return;
|
||||
const iv = setInterval(() => setupQuery.refetch(), 4000);
|
||||
@@ -97,26 +170,11 @@ export default function SetupWizard({ onReady }) {
|
||||
const modelsReady = !!status?.models_ready;
|
||||
const preflightOk = !!pre?.ok;
|
||||
|
||||
const cachePath = status?.hf_cache_dir || '~/.cache/huggingface';
|
||||
|
||||
return (
|
||||
<div className="setup-wizard">
|
||||
<div className="setup-wizard__steps" data-tauri-drag-region>
|
||||
{['Welcome', 'System check', 'Install models', 'Pick engines'].map((label, i) => (
|
||||
<button
|
||||
key={label}
|
||||
className={[
|
||||
'setup-wizard__step',
|
||||
step === i ? 'setup-wizard__step--active' : '',
|
||||
step > i ? 'setup-wizard__step--done' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => setStep(i)}
|
||||
type="button"
|
||||
aria-current={step === i ? 'step' : undefined}
|
||||
aria-label={`Step ${i + 1}: ${label}${step > i ? ' (completed)' : ''}`}
|
||||
>
|
||||
{step > i ? '✓ ' : `${i + 1}. `}{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<StepperNav step={step} onStep={setStep} />
|
||||
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
@@ -134,33 +192,21 @@ export default function SetupWizard({ onReady }) {
|
||||
|
||||
{/* 0. Welcome */}
|
||||
{step === 0 && (
|
||||
<>
|
||||
<div className="swiz-slide" key="step-0">
|
||||
<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>
|
||||
{WELCOME_CARDS.map((card, i) => (
|
||||
<div className="swiz-welcome-card" key={i}>
|
||||
<div className="swiz-welcome-card__icon">{card.icon}</div>
|
||||
<div className="swiz-welcome-card__body">
|
||||
<span className="swiz-welcome-card__title">{card.title}</span>
|
||||
<p className="swiz-welcome-card__desc">{card.desc}</p>
|
||||
</div>
|
||||
</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)]">
|
||||
<p className="swiz-welcome-note">
|
||||
First run takes 5–10 minutes to download. After that, every launch is instant and fully offline.
|
||||
</p>
|
||||
</div>
|
||||
@@ -175,12 +221,12 @@ export default function SetupWizard({ onReady }) {
|
||||
Get started
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 1. System check */}
|
||||
{step === 1 && (
|
||||
<>
|
||||
<div className="swiz-slide" key="step-1">
|
||||
<div className="setup-wizard__embed">
|
||||
<PreflightPanel report={pre} loading={preLoading} onRecheck={recheckPreflight} />
|
||||
</div>
|
||||
@@ -198,12 +244,12 @@ export default function SetupWizard({ onReady }) {
|
||||
: 'Resolve blockers to continue'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 2. Models */}
|
||||
{step === 2 && (
|
||||
<>
|
||||
<div className="swiz-slide" key="step-2">
|
||||
<div className="setup-wizard__embed">
|
||||
<ModelStoreTab info={null} modelBadge={null} />
|
||||
{!modelsReady && status?.missing?.length > 0 && (
|
||||
@@ -227,12 +273,12 @@ export default function SetupWizard({ onReady }) {
|
||||
: 'Waiting for required models…'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. Engines */}
|
||||
{step === 3 && (
|
||||
<>
|
||||
<div className="swiz-slide" key="step-3">
|
||||
<div className="setup-wizard__embed">
|
||||
<EnginesTab />
|
||||
</div>
|
||||
@@ -246,7 +292,7 @@ export default function SetupWizard({ onReady }) {
|
||||
Enter studio
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!status && step > 1 && (
|
||||
@@ -256,8 +302,19 @@ export default function SetupWizard({ onReady }) {
|
||||
)}
|
||||
|
||||
<p className="setup-wizard__footnote">
|
||||
Downloads come from <code>huggingface.co</code>. Cache:{' '}
|
||||
<code>{status?.hf_cache_dir || '~/.cache/huggingface'}</code>
|
||||
Downloads from <code>huggingface.co</code>
|
||||
<span style={{ margin: '0 2px' }}>·</span>
|
||||
Cache: <code>{shortenPath(cachePath)}</code>
|
||||
{'__TAURI_INTERNALS__' in window && cachePath && (
|
||||
<button
|
||||
className="setup-wizard__footnote-link"
|
||||
onClick={() => revealPath(cachePath)}
|
||||
title="Open in Finder"
|
||||
>
|
||||
<FolderOpen size={10} style={{ verticalAlign: '-1px', marginRight: 2 }} />
|
||||
Open
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import './VoiceGallery.css';
|
||||
import { askConfirm } from '../utils/dialog';
|
||||
|
||||
// Check if running in Tauri
|
||||
const isTauri = window.__TAURI__ != null || window.location.protocol === 'tauri:';
|
||||
import { isTauri } from '../utils/media';
|
||||
|
||||
const CATEGORY_ICONS = {
|
||||
disney: Film,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
describe('Zustand store', () => {
|
||||
it('useAppStore initialises with default mode', async () => {
|
||||
const { useAppStore } = await import('../store');
|
||||
const { result } = renderHook(() => useAppStore(s => s.mode));
|
||||
// Default mode should be a string (launchpad, design, clone, or dub)
|
||||
expect(typeof result.current).toBe('string');
|
||||
expect(result.current.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('setMode updates mode', async () => {
|
||||
const { useAppStore } = await import('../store');
|
||||
const { result, rerender } = renderHook(() => ({
|
||||
mode: useAppStore(s => s.mode),
|
||||
setMode: useAppStore(s => s.setMode),
|
||||
}));
|
||||
result.current.setMode('dub');
|
||||
rerender();
|
||||
expect(result.current.mode).toBe('dub');
|
||||
});
|
||||
|
||||
it('setText updates text', async () => {
|
||||
const { useAppStore } = await import('../store');
|
||||
const { result, rerender } = renderHook(() => ({
|
||||
text: useAppStore(s => s.text),
|
||||
setText: useAppStore(s => s.setText),
|
||||
}));
|
||||
result.current.setText('hello world');
|
||||
rerender();
|
||||
expect(result.current.text).toBe('hello world');
|
||||
});
|
||||
|
||||
it('dubSlice initialises with idle step', async () => {
|
||||
const { useAppStore } = await import('../store');
|
||||
const { result } = renderHook(() => useAppStore(s => s.dubStep));
|
||||
expect(result.current).toBe('idle');
|
||||
});
|
||||
|
||||
it('pill slice starts at idle', async () => {
|
||||
const { useAppStore } = await import('../store');
|
||||
const { result } = renderHook(() => useAppStore(s => s.stage));
|
||||
expect(result.current).toBe('idle');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
+21
-12
@@ -42,24 +42,33 @@
|
||||
|
||||
/* ── Variants ────────────────────────────────────────────────────── */
|
||||
|
||||
/* primary — flat chrome accent, no gradient / shimmer */
|
||||
/* primary — solid gradient fill with glow */
|
||||
.ui-btn--primary {
|
||||
background: var(--chrome-accent-bg);
|
||||
color: var(--chrome-accent);
|
||||
border-color: var(--chrome-accent-border);
|
||||
box-shadow: none;
|
||||
background: linear-gradient(135deg, var(--chrome-accent, #d3869b), color-mix(in srgb, var(--chrome-accent, #d3869b) 70%, #c07090));
|
||||
color: #1d2021;
|
||||
border-color: transparent;
|
||||
font-weight: 600;
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--chrome-accent, #d3869b) 30%, transparent),
|
||||
0 1px 4px color-mix(in srgb, var(--chrome-accent, #d3869b) 15%, transparent);
|
||||
transition:
|
||||
background var(--dur-fast, 0.15s) var(--ease-out, ease-out),
|
||||
box-shadow var(--dur-fast, 0.15s) var(--ease-out, ease-out),
|
||||
transform var(--dur-fast, 0.15s) var(--ease-out, ease-out);
|
||||
}
|
||||
.ui-btn--primary::before { display: none; }
|
||||
.ui-btn--primary:hover:not(:disabled) {
|
||||
filter: none;
|
||||
background: color-mix(in srgb, var(--chrome-accent) 22%, transparent);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
filter: brightness(1.1);
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--chrome-accent, #d3869b) 45%, transparent),
|
||||
0 4px 12px color-mix(in srgb, var(--chrome-accent, #d3869b) 25%, transparent);
|
||||
}
|
||||
.ui-btn--primary:active:not(:disabled) {
|
||||
transform: none;
|
||||
filter: none;
|
||||
box-shadow: none;
|
||||
transform: scale(0.98);
|
||||
filter: brightness(0.95);
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--chrome-accent, #d3869b) 30%, transparent);
|
||||
}
|
||||
|
||||
/* subtle — default neutral surface action */
|
||||
|
||||
@@ -19,9 +19,11 @@ export default function Progress({
|
||||
className = '',
|
||||
...rest
|
||||
}) {
|
||||
const indeterminate = value == null;
|
||||
const isInvalid = value != null && (!Number.isFinite(value) || Number.isNaN(value));
|
||||
const safeValue = isInvalid ? null : value;
|
||||
const indeterminate = safeValue == null;
|
||||
const showShimmer = shimmer ?? !indeterminate;
|
||||
const clamped = indeterminate ? null : Math.max(0, Math.min(100, value));
|
||||
const clamped = indeterminate ? null : Math.max(0, Math.min(100, safeValue));
|
||||
|
||||
return (
|
||||
<RadixProgress.Root
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const isTauri = typeof window !== 'undefined' && !!(window.__TAURI_INTERNALS__ || window.__TAURI__);
|
||||
import { isTauri } from './media';
|
||||
|
||||
export async function askConfirm(message, title = 'Confirm') {
|
||||
if (isTauri) {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Stub Tauri globals before import so isTauri is false
|
||||
delete globalThis.window?.__TAURI_INTERNALS__;
|
||||
delete globalThis.window?.__TAURI__;
|
||||
|
||||
describe('media utils', () => {
|
||||
it('isTauri is false in jsdom', async () => {
|
||||
const { isTauri } = await import('../utils/media');
|
||||
expect(isTauri).toBe(false);
|
||||
});
|
||||
|
||||
it('playPing does not throw in jsdom', async () => {
|
||||
const { playPing } = await import('../utils/media');
|
||||
// Should be a no-op (no AudioContext in jsdom), not an error
|
||||
expect(() => playPing()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('format utils', () => {
|
||||
it('formatTime returns m:ss.d', async () => {
|
||||
const { formatTime } = await import('../utils/format');
|
||||
expect(formatTime(0)).toBe('0:00.0');
|
||||
expect(formatTime(61)).toBe('1:01.0');
|
||||
expect(formatTime(3661)).toBe('61:01.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('constants', () => {
|
||||
it('POPULAR_LANGS is non-empty', async () => {
|
||||
const { POPULAR_LANGS } = await import('../utils/constants');
|
||||
expect(POPULAR_LANGS.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('CLONE_MAX_SECONDS is a positive number', async () => {
|
||||
const { CLONE_MAX_SECONDS } = await import('../utils/constants');
|
||||
expect(CLONE_MAX_SECONDS).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('PRESETS array has entries with attrs', async () => {
|
||||
const { PRESETS } = await import('../utils/constants');
|
||||
expect(PRESETS.length).toBeGreaterThan(0);
|
||||
expect(PRESETS[0]).toHaveProperty('attrs');
|
||||
expect(PRESETS[0]).toHaveProperty('id');
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@
|
||||
"jsx": "react-jsx",
|
||||
"resolveJsonModule": true,
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"checkJs": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
@@ -27,4 +27,12 @@ export default defineConfig({
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.js'],
|
||||
include: ['src/**/*.test.{js,jsx,ts,tsx}'],
|
||||
css: false,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -186,6 +186,22 @@ class OmniVoice(PreTrainedModel):
|
||||
_supports_flash_attn_2 = True
|
||||
config_class = OmniVoiceConfig
|
||||
|
||||
# ── Desktop-bundle safety overrides ─────────────────────────────────
|
||||
# transformers ≥4.52 calls `_can_set_experts_implementation()` and
|
||||
# `_can_set_attn_implementation()` during __init__, which open the
|
||||
# class's source file via `inspect.getsourcefile()`/`open(class_file)`.
|
||||
# In a Tauri desktop bundle the module __file__ path doesn't exist on
|
||||
# disk, causing FileNotFoundError. OmniVoice doesn't use MoE experts,
|
||||
# so we can safely return False for experts. For attention, return True
|
||||
# (we DO support flex_attn / flash_attn_2).
|
||||
@classmethod
|
||||
def _can_set_experts_implementation(cls) -> bool:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _can_set_attn_implementation(cls) -> bool:
|
||||
return True
|
||||
|
||||
def __init__(self, config: OmniVoiceConfig, llm: Optional[PreTrainedModel] = None):
|
||||
super().__init__(config)
|
||||
|
||||
|
||||
@@ -160,4 +160,5 @@ dev = [
|
||||
"httpx>=0.28.1",
|
||||
"pytest>=9.0.3",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
"pytest-cov>=6.0",
|
||||
]
|
||||
|
||||
@@ -154,7 +154,7 @@ fi
|
||||
# ── Find and launch the app ────────────────────────────────────────────────
|
||||
if [ "$PLATFORM" = "macos" ]; then
|
||||
APP_BUNDLE="${TAURI_DIR}/target/debug/bundle/macos/${APP_NAME}.app"
|
||||
BINARY="${TAURI_DIR}/target/debug/app"
|
||||
BINARY="${TAURI_DIR}/target/debug/omnivoice-studio"
|
||||
|
||||
if [ -d "$APP_BUNDLE" ]; then
|
||||
echo ""
|
||||
@@ -173,7 +173,7 @@ if [ "$PLATFORM" = "macos" ]; then
|
||||
else
|
||||
# Linux: prefer AppImage, fall back to raw binary
|
||||
APPIMAGE=$(find "${TAURI_DIR}/target/debug/bundle/appimage" -name "*.AppImage" -type f 2>/dev/null | head -1)
|
||||
BINARY="${TAURI_DIR}/target/debug/app"
|
||||
BINARY="${TAURI_DIR}/target/debug/omnivoice-studio"
|
||||
|
||||
if [ -n "$APPIMAGE" ] && [ -f "$APPIMAGE" ]; then
|
||||
echo ""
|
||||
|
||||
@@ -137,7 +137,7 @@ fi
|
||||
# ── Phase 2: Build ────────────────────────────────────────────────────────
|
||||
header "Phase 2: Build"
|
||||
|
||||
BINARY="${TAURI_DIR}/target/debug/app"
|
||||
BINARY="${TAURI_DIR}/target/debug/omnivoice-studio"
|
||||
|
||||
if [ "$SKIP_BUILD" = false ]; then
|
||||
info "Building debug bundle (this takes 1-3 min)..."
|
||||
|
||||
@@ -820,6 +820,110 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/e4/649c8d4f7f1709b6dbfc474358aa1bba02f67bcd52e2fec291a5014006cd/coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480", size = 219795, upload-time = "2026-05-10T17:59:48.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/8d/46692d24b3f395d4cbf17bfcc57136b4f2f9c0c0df864b0bddfc1d71a014/coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4", size = 220299, upload-time = "2026-05-10T17:59:49.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/c2/a40f5cb295bbcbb697a76947a56081c494c61950366294ee426ffe261099/coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7", size = 250721, upload-time = "2026-05-10T17:59:51.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/35/202235eb5c3c14c212462cd91d61b7386bf8fc44bc7a77f4742d2a69174b/coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed", size = 252633, upload-time = "2026-05-10T17:59:53.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/80/5f596e8995785124ee191c42535664c5e62c65995b66f4ca21e28ae04c81/coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980", size = 254743, upload-time = "2026-05-10T17:59:55.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/6d/0d178825be2350f0adb27984d0aa7cf84bbdab201f6fb926b535d23a8f5f/coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0", size = 256700, upload-time = "2026-05-10T17:59:56.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/5b/9e549c2f6e9dfea472adadba06c294e64735dabc2dd19015fac082095013/coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742", size = 250854, upload-time = "2026-05-10T17:59:57.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/1c/b94f9f5f36396021ee2f62c5834b12e6a3d31f0bed5d6fc6d1c3caec087c/coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5", size = 252433, upload-time = "2026-05-10T17:59:59.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/cb/d192cd8e1345eccabc32016f2d39072ecd10cb4f4b983ed8d0ebdeaf00dc/coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327", size = 250494, upload-time = "2026-05-10T18:00:01.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/c5/aac9f460a41d835dbddef1d377f105f6ac2311d0f3c1588e9f51046d8813/coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d", size = 254261, upload-time = "2026-05-10T18:00:03.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/aa/7af7c0081980a9cb3d289c5a435a4b7657dcecbd128e25c580e6a50389b5/coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20", size = 250216, upload-time = "2026-05-10T18:00:05.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/60/a4257538ce2f6b978aeb51870d6c4208c510928a03db7e0339bb625dccb7/coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c", size = 251125, upload-time = "2026-05-10T18:00:06.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/ab/f91af47642ec1aa53490e835a95847168d9c77fc39aa58527604c051e145/coverage-7.14.0-cp311-cp311-win32.whl", hash = "sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3", size = 222300, upload-time = "2026-05-10T18:00:08.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/f0/a71ddbd874431e7a7cd96071f0c331cfbbad07704833c765d24ffbab8a67/coverage-7.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1", size = 223241, upload-time = "2026-05-10T18:00:10.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/6e/d9d312a5151a96cd110efee32efc3fc97b01ebd86203fe618ccb29cf4c92/coverage-7.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627", size = 221908, upload-time = "2026-05-10T18:00:12.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
toml = [
|
||||
{ name = "tomli", marker = "python_full_version <= '3.11'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crcmod"
|
||||
version = "1.7"
|
||||
@@ -2971,6 +3075,7 @@ dev = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-cov" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
@@ -3025,6 +3130,7 @@ dev = [
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "pytest", specifier = ">=9.0.3" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
||||
{ name = "pytest-cov", specifier = ">=6.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4015,6 +4121,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "7.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage", extra = ["toml"] },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
@@ -5382,6 +5502,60 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomlkit"
|
||||
version = "0.13.3"
|
||||
|
||||
Reference in New Issue
Block a user