Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
936e39ece5 | ||
|
|
6c427d4451 | ||
|
|
3adf239548 | ||
|
|
34610ca091 | ||
|
|
bbebf5281a | ||
|
|
8d84c7f679 | ||
|
|
393dd7e8b5 | ||
|
|
f8b4673e1f | ||
|
|
93e79db9e6 | ||
|
|
bdafd86b2b | ||
|
|
b36bb8495e | ||
|
|
fc76e79ff8 | ||
|
|
811c842a75 | ||
|
|
901eb040a8 | ||
|
|
4a8b06c25e | ||
|
|
787c146f61 | ||
|
|
c7816b7222 | ||
|
|
8c27ba40dc | ||
|
|
60e19e4eb3 | ||
|
|
5a754b461e | ||
|
|
90ef02b2e4 | ||
|
|
e57448f704 | ||
|
|
715909d552 | ||
|
|
e354d27c56 |
@@ -30,8 +30,13 @@ jobs:
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
# enable-cache persists ~/.cache/uv across runs, keyed on uv.lock —
|
||||
# turns `uv sync` from ~45 s cold to ~5 s warm.
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
||||
# Node 22 is needed for --experimental-strip-types so node:test can
|
||||
# import .ts files directly from frontend/src/api/*.
|
||||
@@ -43,10 +48,12 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
|
||||
# apt install ffmpeg is ~30 s every run; cache the resolved .debs.
|
||||
- name: System deps (ffmpeg)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ffmpeg
|
||||
uses: awalsh128/cache-apt-pkgs-action@latest
|
||||
with:
|
||||
packages: ffmpeg
|
||||
version: 1.0
|
||||
|
||||
- name: Install Python deps
|
||||
run: uv sync
|
||||
@@ -54,6 +61,16 @@ jobs:
|
||||
- name: Run pytest
|
||||
run: uv run pytest tests/ -q --tb=short
|
||||
|
||||
# Cache ~/.bun/install/cache keyed on bun.lock — `bun install` drops
|
||||
# from ~15 s cold to near-instant on warm cache.
|
||||
- name: Cache bun deps
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('frontend/bun.lock', 'bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Install frontend deps
|
||||
working-directory: frontend
|
||||
run: bun install
|
||||
|
||||
@@ -50,8 +50,12 @@ jobs:
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
# enable-cache persists ~/.cache/uv keyed on uv.lock.
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
||||
# Node 22 is needed for --experimental-strip-types so node:test can
|
||||
# import .ts files directly from frontend/src/api/*.
|
||||
@@ -63,13 +67,13 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
|
||||
# Backend tests need ffmpeg (subprocess calls in fixtures) + the minimal
|
||||
# apt deps pydub/imageio pull in. Model weights are mocked so no HF
|
||||
# downloads happen.
|
||||
# Backend tests need ffmpeg (subprocess calls in fixtures). Cache the
|
||||
# resolved .debs so warm runs skip the apt-get update + install.
|
||||
- name: System deps (ffmpeg)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ffmpeg
|
||||
uses: awalsh128/cache-apt-pkgs-action@latest
|
||||
with:
|
||||
packages: ffmpeg
|
||||
version: 1.0
|
||||
|
||||
- name: Install Python deps
|
||||
run: uv sync
|
||||
@@ -77,6 +81,14 @@ jobs:
|
||||
- name: Run pytest
|
||||
run: uv run pytest tests/ -q --tb=short
|
||||
|
||||
- name: Cache bun deps
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('frontend/bun.lock', 'bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Install frontend deps
|
||||
working-directory: frontend
|
||||
run: bun install
|
||||
@@ -104,12 +116,9 @@ jobs:
|
||||
rust_target: aarch64-apple-darwin
|
||||
bundles: "app,dmg,updater"
|
||||
|
||||
- os: macos-13
|
||||
arch: x86_64-apple-darwin
|
||||
label: "macOS Intel"
|
||||
rust_target: x86_64-apple-darwin
|
||||
bundles: "app,dmg,updater"
|
||||
|
||||
# macOS Intel dropped: Apple shipped the last Intel Mac in 2023 and
|
||||
# Rosetta 2 runs the ARM build natively. macos-13 runner backlog
|
||||
# was also blocking every release tag for ~10 min.
|
||||
# Windows: force MSI bundling via --bundles. NSIS fails at makensis
|
||||
# because our PyInstaller payload approaches its ~2 GB stub limit.
|
||||
- os: windows-2022
|
||||
@@ -118,13 +127,16 @@ jobs:
|
||||
rust_target: x86_64-pc-windows-msvc
|
||||
bundles: "msi,updater"
|
||||
|
||||
# Linux: ship .deb only. AppImage bundling (linuxdeploy) is
|
||||
# unreliable on GH Actions runners even with APPIMAGE_EXTRACT_AND_RUN.
|
||||
# Linux: ship .deb + .AppImage. AppImage is universal (no distro
|
||||
# package-manager dep), runs on any glibc-2.31+ host. Now viable
|
||||
# because the thin uv-venv installer is ~10 MB (vs the prior ~2 GB
|
||||
# PyInstaller payload that exceeded linuxdeploy limits). FUSE
|
||||
# unavailability on GH runners handled via APPIMAGE_EXTRACT_AND_RUN=1.
|
||||
- os: ubuntu-22.04
|
||||
arch: x86_64-unknown-linux-gnu
|
||||
label: "Linux x64"
|
||||
rust_target: x86_64-unknown-linux-gnu
|
||||
bundles: "deb,updater"
|
||||
bundles: "deb,appimage,updater"
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: ${{ matrix.label }}
|
||||
@@ -138,6 +150,15 @@ jobs:
|
||||
with:
|
||||
targets: ${{ matrix.rust_target }}
|
||||
|
||||
# Cache ~/.cargo/registry + {target}/ per rust_target. Cargo dep
|
||||
# compile is the long pole of the build — cold is ~5-7 min, warm
|
||||
# drops to ~1-2 min.
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: frontend/src-tauri -> target
|
||||
key: ${{ matrix.rust_target }}
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
|
||||
@@ -161,6 +182,14 @@ jobs:
|
||||
libasound2-dev ffmpeg
|
||||
|
||||
# ── Frontend build ─────────────────────────────────────────────────
|
||||
- name: Cache bun deps
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('frontend/bun.lock', 'bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Install frontend deps
|
||||
working-directory: frontend
|
||||
run: bun install
|
||||
@@ -178,6 +207,10 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
# GH runners disable FUSE, so linuxdeploy's AppImage can't mount
|
||||
# itself at bundle time. This env tells linuxdeploy to extract-and-run
|
||||
# instead, which works without FUSE.
|
||||
APPIMAGE_EXTRACT_AND_RUN: 1
|
||||
with:
|
||||
projectPath: frontend
|
||||
args: --target ${{ matrix.rust_target }} --bundles ${{ matrix.bundles }}
|
||||
|
||||
@@ -77,3 +77,7 @@ examples/download*
|
||||
examples/exp*/
|
||||
omnivoice.zip
|
||||
frontend/src-tauri/binaries/ffmpeg
|
||||
|
||||
# cuDNN 8 compat libs (auto-installed by scripts/setup_cudnn.py)
|
||||
cudnn8_compat/
|
||||
test-results/
|
||||
|
||||
@@ -2,19 +2,18 @@
|
||||
# Builder Stage: Compile React Frontend
|
||||
# ==========================================
|
||||
FROM oven/bun:1-alpine AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
WORKDIR /app
|
||||
|
||||
# Copy frontend specifications
|
||||
COPY frontend/package.json ./
|
||||
COPY frontend/bun.lock ./
|
||||
# Monorepo — bun workspace with lockfile at repo root. Copy manifests first
|
||||
# so `bun install` caches independently of source edits.
|
||||
COPY package.json bun.lock ./
|
||||
COPY frontend/package.json ./frontend/
|
||||
|
||||
# Install dependencies fast
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Copy frontend source and build static files
|
||||
COPY frontend/ ./
|
||||
# Output goes to /app/frontend/dist
|
||||
RUN bun run build
|
||||
# Build static files (output lands in /app/frontend/dist)
|
||||
COPY frontend/ ./frontend/
|
||||
RUN bun run --cwd frontend build
|
||||
|
||||
# ==========================================
|
||||
# Runtime Stage: Python & PyTorch Backend
|
||||
@@ -52,10 +51,10 @@ COPY omnivoice/ ./omnivoice/
|
||||
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
|
||||
|
||||
# Expose the single unified API and UI port
|
||||
EXPOSE 8000
|
||||
EXPOSE 3900
|
||||
|
||||
# Mount points for persistent data (sqlite db, user voices, huggingface cache)
|
||||
VOLUME ["/app/omnivoice_data"]
|
||||
|
||||
# Bind to 0.0.0.0 for external access
|
||||
ENTRYPOINT ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
ENTRYPOINT ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "3900"]
|
||||
|
||||
@@ -1,201 +1,82 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
OmniVoice Studio — Dual License
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
Copyright (c) 2024-present Palash Debnath and contributors.
|
||||
|
||||
1. Definitions.
|
||||
This software is licensed under a dual-license model:
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
1. PERSONAL & NON-COMMERCIAL USE — FREE
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
You may use, copy, modify, and distribute this software free of
|
||||
charge for any personal, educational, research, or non-commercial
|
||||
purpose, subject to the following conditions:
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
• You include this license notice in all copies or substantial
|
||||
portions of the software.
|
||||
• You do not use the software, or any derivative of it, to provide
|
||||
a commercial product or service (see Section 2).
|
||||
• You provide attribution to "OmniVoice Studio" in any public-facing
|
||||
derivative work.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
"Non-commercial" means use that is not intended for or directed toward
|
||||
commercial advantage or monetary compensation. This includes personal
|
||||
projects, academic research, open-source contributions, and internal
|
||||
evaluation within an organization (up to 30 days).
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
2. COMMERCIAL USE — PAID LICENSE REQUIRED
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
A separate commercial license is required for any use that does not
|
||||
qualify as personal or non-commercial under Section 1. This includes,
|
||||
but is not limited to:
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
• Using the software to provide a paid product or service.
|
||||
• Embedding the software in a product sold or licensed to third
|
||||
parties.
|
||||
• Using the software in a revenue-generating business beyond the
|
||||
30-day evaluation period.
|
||||
• Offering the software as part of a managed, hosted, or SaaS
|
||||
platform.
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
To obtain a commercial license, contact:
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
Email: OmniVoice@palash.dev
|
||||
Web: https://github.com/debpalash/OmniVoice-Studio
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
Commercial licenses are available for teams and enterprises of all
|
||||
sizes. Pricing scales with usage — solo creators and small studios
|
||||
are priced affordably.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
3. CONTRIBUTIONS
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
By submitting a pull request or other contribution to this project,
|
||||
you agree to license your contribution under the same dual-license
|
||||
terms described herein, and you grant the copyright holder a
|
||||
perpetual, worldwide, royalty-free license to use, reproduce, modify,
|
||||
and distribute your contribution under both the non-commercial and
|
||||
commercial licenses.
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
4. NO WARRANTY
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
|
||||
BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN
|
||||
ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
5. TERMINATION
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Xiaomi Corp.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
Your rights under this license terminate automatically if you fail to
|
||||
comply with its terms. Upon termination, you must cease all use of the
|
||||
software and destroy all copies in your possession.
|
||||
|
||||
@@ -1,164 +1,343 @@
|
||||
<div align="center">
|
||||
<img src="frontend/public/favicon.svg" alt="OmniVoice Logo" width="120" />
|
||||
<img src="docs/logo.png" alt="OmniVoice Logo" width="160" />
|
||||
<h1>OmniVoice Studio</h1>
|
||||
<p><b>Your Local Cinematic AI Dubbing Studio</b></p>
|
||||
<p><b>The open-source ElevenLabs alternative.</b></p>
|
||||
<p>Voice cloning · Voice design · Video dubbing — 646 languages, runs 100% locally, forever free.</p>
|
||||
<p>
|
||||
<a href="#-features">Features</a> •
|
||||
<a href="#-getting-started">Getting Started</a> •
|
||||
<a href="#%EF%B8%8F-roadmap">Roadmap</a> •
|
||||
<a href="#-changelog">Changelog</a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/stargazers"><img src="https://img.shields.io/github/stars/debpalash/OmniVoice-Studio?style=flat-square&color=f59e0b" alt="Stars" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest"><img src="https://img.shields.io/github/v/release/debpalash/OmniVoice-Studio?style=flat-square&color=10b981" alt="Release" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Dual_(Free_%2B_Commercial)-blue?style=flat-square" alt="License" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/issues"><img src="https://img.shields.io/github/issues/debpalash/OmniVoice-Studio?style=flat-square&color=ef4444" alt="Issues" /></a>
|
||||
<a href="https://discord.gg/aRRdVj3de7"><img src="https://img.shields.io/badge/Discord-Join_Community-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest">Download</a> ·
|
||||
<a href="#features">Features</a> ·
|
||||
<a href="#quickstart">Quickstart</a> ·
|
||||
<a href="#why-open-source">Why Open Source?</a> ·
|
||||
<a href="#roadmap">Roadmap</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/download/v0.2.2/OmniVoice.Studio_0.2.2_aarch64.dmg"><img src="https://img.shields.io/badge/macOS-DMG_(Apple_Silicon)-000?style=for-the-badge&logo=apple&logoColor=white" alt="Download macOS DMG" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/download/v0.2.2/OmniVoice.Studio_0.2.2_x64_en-US.msi"><img src="https://img.shields.io/badge/Windows-MSI_(x64)-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Download Windows MSI" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/download/v0.2.2/OmniVoice.Studio_0.2.2_amd64.AppImage"><img src="https://img.shields.io/badge/Linux-AppImage_(x64)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Download Linux AppImage" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/download/v0.2.2/OmniVoice.Studio_0.2.2_amd64.deb"><img src="https://img.shields.io/badge/Debian-.deb-A81D33?style=for-the-badge&logo=debian&logoColor=white" alt="Download Debian .deb" /></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<img src="preview.png" alt="OmniVoice Studio Interface Demo" width="100%"/>
|
||||
<img src="preview.png" alt="OmniVoice Studio — Launchpad" width="100%"/>
|
||||
<br/>
|
||||
<i>The timeline-based cinematic dubbing and workspace UI.</i>
|
||||
<sub>Launchpad — Voice Clone · Voice Design · Video Dubbing, all in one place.</sub>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
Local, full-stack voice generation and cinematic dubbing. **No API keys. No cloud. Just run it.** Built on the open-source [OmniVoice](https://github.com/k2-fsa/OmniVoice) 600-language zero-shot diffusion model.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🎬 **Video Dubbing** — transcribe, translate, re-voice, and mux back into MP4 with selective track export.
|
||||
- 🎧 **Vocal Isolation** — built-in `demucs` automatically splits speech from music, keeping original background audio perfectly preserved.
|
||||
- 🧬 **Voice Cloning & Design** — Clone specific voices from just a 3-second audio clip, or design completely new studio profiles with tags like `female, british accent, excited`.
|
||||
- ⚡ **Cross-Platform Native Execution** — Auto-detects and accelerates inference using Apple Silicon (MPS), NVIDIA (CUDA), AMD (ROCm), or standard CPU.
|
||||
- 🔊 **Per-Segment Mixing** — Fine-grained volume/gain control per dubbed segment (0–200%) for broadcast-quality audio balancing.
|
||||
- ⌨️ **Keyboard-Driven Workflow** — `⌘+Enter` to generate, `⌘+S` to save, `⌘+Z`/`⌘+Shift+Z` for undo/redo.
|
||||
- 📡 **Live Model Telemetry** — Real-time CPU/RAM/VRAM stats + model warm-up indicator (idle → loading → ready).
|
||||
|
||||
<br/>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%">
|
||||
<img src="docs/screenshot-clone.png" alt="Voice Clone" width="100%"/>
|
||||
<br/><b>Voice Clone</b><br/>
|
||||
<sub>Drop a 3-second clip → mirror any voice. 646 languages, zero-shot.</sub>
|
||||
</td>
|
||||
<td align="center" width="50%">
|
||||
<img src="docs/screenshot-design.png" alt="Voice Design" width="100%"/>
|
||||
<br/><b>Voice Design</b><br/>
|
||||
<sub>Build new voices from scratch — gender, age, accent, pitch, style.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="docs/screenshot-dub.png" alt="Video Dubbing" width="100%"/>
|
||||
<br/><b>Video Dubbing</b><br/>
|
||||
<sub>Upload or paste a YouTube URL. Transcribe, translate, re-voice, export.</sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="docs/screenshot-gallery.png" alt="Voice Gallery" width="100%"/>
|
||||
<br/><b>Voice Gallery</b><br/>
|
||||
<sub>Search YouTube, browse categories, download clips, build your library.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="docs/screenshot-settings.png" alt="Settings — Models" width="100%"/>
|
||||
<br/><b>Settings → Models</b><br/>
|
||||
<sub>15 models. One-click install. Auto-detects your platform (CUDA / MPS / CPU).</sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="docs/screenshot-libraryprojects.png" alt="Projects" width="100%"/>
|
||||
<br/><b>Projects</b><br/>
|
||||
<sub>Dub projects, voice profiles, generation history, exports — all searchable.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2">
|
||||
<img src="docs/screenshot-logs.png" alt="Settings — Logs" width="100%"/>
|
||||
<br/><b>Settings → Logs</b><br/>
|
||||
<sub>Live backend, frontend, and Tauri runtime logs. Filter, refresh, clear.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 🚀 Getting Started
|
||||
---
|
||||
|
||||
The easiest way to run OmniVoice Studio locally or on a cloud VM is via Docker. Our environment utilizes an optimized `pytorch/pytorch` configuration which seamlessly enables zero-config GPU passthrough if your host supports it.
|
||||
## Why Open Source?
|
||||
|
||||
### Option 1: One-Click Docker (Recommended)
|
||||
ElevenLabs charges **$5–$330/mo** and processes your audio on their servers. OmniVoice Studio runs **on your hardware, with no usage limits.**
|
||||
|
||||
| | **ElevenLabs** | **OmniVoice Studio** |
|
||||
|---|---|---|
|
||||
| **Pricing** | $5–$330/mo, per-character billing | Free for personal use · [Commercial license](#license) for business |
|
||||
| **Voice Cloning** | ✅ 3s clip | ✅ 3s clip, zero-shot |
|
||||
| **Voice Design** | ✅ Gender, age | ✅ Gender, age, accent, pitch, style, dialect |
|
||||
| **Languages** | 32 | **646** |
|
||||
| **Video Dubbing** | ✅ Cloud-only | ✅ Fully local |
|
||||
| **Data Privacy** | Audio sent to cloud | **Nothing leaves your machine** |
|
||||
| **API Keys** | Required | Not needed |
|
||||
| **GPU Support** | N/A (cloud) | CUDA · Apple Silicon · ROCm · CPU |
|
||||
| **Desktop App** | ❌ | ✅ macOS · Windows · Linux |
|
||||
| **Customizable** | ❌ Closed | ✅ Fork it, extend it, ship it |
|
||||
|
||||
Built on the [OmniVoice](https://github.com/k2-fsa/OmniVoice) 600-language zero-shot diffusion TTS model. Upload a video, get broadcast-quality dubs in any language with the original speaker's voice preserved.
|
||||
|
||||
## Features
|
||||
|
||||
### Core Pipeline
|
||||
- **Video Dubbing** — Transcribe → translate → synthesize → mux back to MP4. One-click end-to-end.
|
||||
- **Vocal Isolation** — Demucs-powered speech/music separation. Background audio preserved automatically.
|
||||
- **Voice Cloning** — Clone any voice from a 3-second clip. Zero-shot, 600+ languages.
|
||||
- **Multi-Speaker Diarization** — Pyannote + WhisperX fusion auto-identifies speakers and assigns unique voice profiles.
|
||||
|
||||
### Studio Tools
|
||||
- **Voice Preview** — Floating widget for instant 8-step TTS testing. Try voices without leaving the workspace.
|
||||
- **Multi-Language Batch** — Select multiple target languages, dub to all in one pass.
|
||||
- **Batch Queue** — Drag-and-drop bulk video processing with sequential GPU execution.
|
||||
- **Voice Library** — Browse, favorite, tag, and convert gallery clips into permanent voice profiles.
|
||||
- **A/B Comparison** — Side-by-side voice audition for casting decisions.
|
||||
|
||||
### Production Export
|
||||
- **Selective Track Export** — Choose which language tracks to include in the final MP4.
|
||||
- **Subtitle Export** — SRT and VTT generation alongside dubbed video.
|
||||
- **Stem Export** — Separate vocals and background audio as individual files.
|
||||
- **Per-Segment Mixing** — 0–200% gain control per segment for broadcast-quality balancing.
|
||||
|
||||
### Technical
|
||||
- **Cross-Platform GPU** — Auto-detects CUDA, Apple Silicon (MPS), ROCm, or CPU. Includes automatic cuDNN 8/9 compatibility handling.
|
||||
- **VRAM-Aware** — Automatically offloads TTS to CPU during transcription on ≤8 GB GPUs. Zero config.
|
||||
- **Live Telemetry** — Real-time CPU/RAM/VRAM stats with model warm-up indicator.
|
||||
- **Keyboard-First** — `⌘+Enter` generate, `⌘+S` save, `⌘+Z`/`⌘+⇧+Z` undo/redo.
|
||||
|
||||
### AI Provenance
|
||||
- **Invisible Watermark** — AudioSeal-powered (Meta) neural watermark embedded in every generated audio. Imperceptible, survives compression/editing.
|
||||
- **Detection API** — Upload any audio to `/watermark/detect` to verify OmniVoice origin with confidence score.
|
||||
- **Video Branding** — Optional logo overlay on exported MP4s (5s fade-out, bottom-right).
|
||||
- **Configurable** — Toggle invisible/visible watermarks independently in Settings → Privacy.
|
||||
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
|
||||
### Docker (recommended)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/debpalash/OmniVoice-Studio.git
|
||||
cd OmniVoice-Studio
|
||||
|
||||
docker compose up --build -d
|
||||
```
|
||||
That's it! Open [http://localhost:8000](http://localhost:8000) in your browser.
|
||||
|
||||
> [!TIP]
|
||||
> **Windows/WSL Users:** Make sure your NVIDIA drivers are up to date. Docker Desktop automatically passes GPU capabilities to this container!
|
||||
> **Cloud VMs (AWS, RunPod):** The image inherently supports CUDA 12.1. As long as `nvidia-container-toolkit` is installed on your host, `--gpus all` binds natively.
|
||||
Open [http://localhost:8000](http://localhost:8000). GPU passthrough works automatically if `nvidia-container-toolkit` is installed.
|
||||
|
||||
### Option 2: Local Development Setup
|
||||
### Local Development
|
||||
|
||||
Quickly get OmniVoice Studio running natively on your hardware if you want to develop or modify code.
|
||||
**Prerequisites:** Ensure `ffmpeg` is installed on your system.
|
||||
Install standard modern web tooling: [Bun](https://bun.sh/) and [uv](https://docs.astral.sh/uv/getting-started/installation/).
|
||||
**Prerequisites:** [ffmpeg](https://ffmpeg.org/), [Bun](https://bun.sh/), [uv](https://docs.astral.sh/uv/)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/debpalash/OmniVoice-Studio.git
|
||||
cd OmniVoice-Studio
|
||||
|
||||
# Boot the Backend
|
||||
uv sync
|
||||
uv run uvicorn backend.main:app
|
||||
|
||||
# Boot the Frontend (in a separate terminal)
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
OmniVoice Studio launches exactly two micro-services:
|
||||
This boots both services:
|
||||
|
||||
| Service | Protocol | Details |
|
||||
|---|---|---|
|
||||
| **Frontend** | `http://localhost:5173` | The real-time React UI — spanning cloning, design, and audio workspace. |
|
||||
| **Backend** | `http://localhost:8000` | The FastAPI server handling model inference, translation pipelines, transcriber tasks. |
|
||||
| Service | URL | Stack |
|
||||
|---------|-----|-------|
|
||||
| **Backend** | `localhost:3900` | FastAPI · 97 endpoints · WhisperX · Demucs · OmniVoice |
|
||||
| **Frontend** | `localhost:3901` | React · Vite · Waveform timeline · Glassmorphism UI |
|
||||
|
||||
> [!NOTE]
|
||||
> **First run optimization:** Model weights (approx. 1.2 GB) automatically download from HuggingFace the first time you execute a generation sequence. Subsequent launches trigger instantly from cache. *(Tip: Set `HF_TOKEN` in your environment for faster, authenticated downloads!)*
|
||||
> First run downloads model weights (~2.4 GB). This works out of the box — no account needed. For faster downloads, optionally set `HF_TOKEN=hf_...` in your environment ([get a free token here](https://huggingface.co/settings/tokens)).
|
||||
>
|
||||
> **Having issues?** Join our [Discord](https://discord.gg/aRRdVj3de7) for setup help and troubleshooting.
|
||||
|
||||
### Desktop App
|
||||
|
||||
```bash
|
||||
bun run desktop # Launches Tauri native app (macOS / Windows / Linux)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Roadmap
|
||||
## System Requirements
|
||||
|
||||
The studio is highly functional today, but we are aggressively expanding. Watch the roadmap to see what's shipping next:
|
||||
| | **Minimum** | **Recommended** |
|
||||
|---|---|---|
|
||||
| **OS** | Windows 10, macOS 12+, Ubuntu 20.04+ | Any modern 64-bit OS |
|
||||
| **RAM** | 8 GB | 16 GB+ |
|
||||
| **VRAM (GPU)** | 4 GB (auto-offloads TTS to CPU) | 8 GB+ (NVIDIA RTX 3060+) |
|
||||
| **Disk** | 10 GB free (models + cache) | 20 GB+ SSD |
|
||||
| **Python** | 3.10+ (managed by `uv`) | 3.11–3.12 |
|
||||
| **GPU** | Optional — CPU works | NVIDIA CUDA · Apple Silicon MPS · AMD ROCm |
|
||||
|
||||
### 🌟 Completed Milestones
|
||||
- [x] Zero-shot voice cloning & complex voice design.
|
||||
- [x] Full video cinematic dubbing pipeline (transcribe → translate → synthesize → mux).
|
||||
- [x] Vocal isolation utilizing demucs alongside background audio retention.
|
||||
- [x] Embedded waveform timeline editor for micro-segment-level audio manipulation.
|
||||
- [x] Live system telemetry tracking (CPU, RAM, GPU VRAM usage).
|
||||
- [x] Targeted multi-speaker diarization — auto-assign unique voice profiles per active speaker.
|
||||
- [x] Studio project persistence — save, load, and cache multi-track projects seamlessly via local SQLite.
|
||||
- [x] Production SRT/VTT subtitle export packaged alongside the dubbed `.mp4` video output.
|
||||
- [x] Selective track export — choose exactly which language tracks (Original, DE, ES, etc.) to include in final MP4.
|
||||
- [x] Per-segment volume/gain control with real-time mixing (0–200%).
|
||||
- [x] Undo/redo system for all segment edits with 50-action history depth.
|
||||
- [x] Keyboard shortcuts: `⌘+Enter` generate, `⌘+S` save, `⌘+Z`/`⌘+Shift+Z` undo/redo.
|
||||
- [x] Drag-and-drop file uploads for both video and clone audio sources.
|
||||
- [x] Model warm-up indicator with live status pill (idle/loading/ready).
|
||||
- [x] Confirmation dialogs for all destructive actions (delete project/history/profile).
|
||||
- [x] UI preferences persistence (sidebar state, zoom, active tab) across sessions.
|
||||
- [x] Polished glassmorphism design system with micro-animations, focus rings, and custom scrollbars.
|
||||
|
||||
### 🔨 Upcoming Features
|
||||
- [x] **Real Speaker Diarization** — ML-based diarization via pyannote.audio for true multi-speaker identification.
|
||||
- [x] **A/B Voice Comparison** — Side-by-side voice audition for casting decisions.
|
||||
- [x] **Scene-Aware Dubbing** — FFmpeg scene detection to auto-split segments at visual cuts.
|
||||
- [x] **Lip-Sync Scoring** — Analyze dubbed audio duration against original speaker timing with color-coded badges.
|
||||
- [x] **Batch Processing** — Centralized async task queue ensuring sequential GPU execution with reconnectable SSE streams.
|
||||
- [x] **Advanced Export Suite** — VTT subtitles, per-segment WAV ZIP, compressed MP3, and stem export (vocals + background separate).
|
||||
- [x] **Streaming TTS** — Chunked WAV streaming with progressive download and auto-playback.
|
||||
- [ ] **Native Desktop Applications** — Dedicated client apps for macOS, Windows, and Linux.
|
||||
- [x] **One-Click Deployment** — Docker image packages engineered for zero-config GPU passthrough.
|
||||
> [!TIP]
|
||||
> On GPUs with **≤8 GB VRAM**, OmniVoice automatically offloads TTS to CPU during transcription — no config needed. A dedicated GPU is not required; the entire pipeline runs on CPU (just slower).
|
||||
|
||||
---
|
||||
|
||||
## 📝 Changelog
|
||||
## Architecture
|
||||
|
||||
### v1.2.0 — The Production Polish Update
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Frontend (React) │
|
||||
│ DubTab · VoicePreview · BatchQueue · Gallery │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Backend (FastAPI) │
|
||||
│ 97 API endpoints · SSE streaming · SQLite │
|
||||
├──────────┬──────────┬──────────┬────────────────┤
|
||||
│ WhisperX │ Demucs │OmniVoice │ Pyannote │
|
||||
│ ASR │ Source │ TTS │ Diarization │
|
||||
│ │ Sep. │ │ │
|
||||
└──────────┴──────────┴──────────┴────────────────┘
|
||||
CUDA / MPS / ROCm / CPU (auto-detected)
|
||||
```
|
||||
|
||||
- **Selective Track Export:** Choose exactly which audio tracks to include in the final MP4. Uncheck Original, keep only German — get a single-track export. Full per-track checkbox UI with dynamic FFmpeg stream index remapping.
|
||||
- **Undo/Redo System:** Full `⌘+Z` / `⌘+Shift+Z` undo/redo for all segment edits (text, voice, volume, delete). 50-action deep history stack.
|
||||
- **Per-Segment Volume Control:** Inline gain slider (0–200%) per segment row in the dub table. Backend applies gain during audio assembly with safe clamping.
|
||||
- **Keyboard Shortcuts:** `⌘+Enter` to generate, `⌘+S` to save project. Browser default overrides prevented.
|
||||
- **Model Status Indicator:** Live status pill in the header showing model warm-up state (idle → loading → ready). New `/model/status` backend endpoint.
|
||||
- **Drag-and-Drop Everywhere:** Video upload already supported drop — now clone audio upload does too, with pink highlight on hover.
|
||||
- **Confirmation Dialogs:** All destructive actions (delete project, profile, history item, clear all history) now require confirmation.
|
||||
- **Session Persistence:** Sidebar collapsed state, active tab, and zoom level now persist across browser sessions via localStorage.
|
||||
- **CSS Design System Overhaul:** Anti-aliased text, input focus glow rings, button hover shimmer, progress bar shimmer animation, fade-in on history items, selection color branding, Firefox scrollbar support, `tabular-nums` for timestamp columns.
|
||||
- **AudioContext Pooling:** `playPing()` synthesis notification reuses a single AudioContext instead of creating one per call (browsers cap at ~6).
|
||||
---
|
||||
|
||||
### v1.1.0 — The Cinematic Studio Update
|
||||
## Roadmap
|
||||
|
||||
- **The Cinematic Studio Interface:** Exhaustively re-engineered the UI to prioritize a high-density, real-estate optimized workflow featuring a dynamic UI zoom scalar (`Small`, `Normal`, `Max`). We minimized dead space and overhauled the widget layout keeping crucial tuning metrics immediately accessible.
|
||||
- **Multi-Track Timeline:** Deeply integrated a multi-layered waveform sequence interface supporting precision audio segment positioning, unmuted live preview playback, localized track timing, and unconstrained draggable positioning manipulation.
|
||||
- **Persistent Local Projects:** Put a complete stop to ephemeral state loss. All workspace metrics are successfully wrapped into `Projects` logged directly within a native embedded `SQLite` database. Workflows reliably survive browser shutdowns or server API reboots.
|
||||
- **AI Cast Diarization:** Dropped in an offline `Pyannote` + `WhisperX` fusion pipeline evaluating multi-speaker metadata and categorizing overlapping, distinct speakers. Rapidly "cast" clone overrides seamlessly over complex dialogue tracks.
|
||||
- **Polishing & Asset Control:** Cleaned cross-stack filename parsing and exported media rendering via `ffmpeg`, stabilizing codec dependencies, and deployed a unified custom `OmniVoice Studio` scalable aesthetic asset system.
|
||||
### ✅ Shipped
|
||||
|
||||
| Category | Features |
|
||||
|----------|----------|
|
||||
| **Dubbing** | Full pipeline (transcribe→translate→synthesize→mux), scene-aware splitting, lip-sync scoring, streaming TTS |
|
||||
| **Voice** | Zero-shot cloning, voice design, A/B comparison, voice preview widget, gallery with favorites/tags |
|
||||
| **Audio** | Demucs vocal isolation, per-segment gain, selective track export, stem/SRT/VTT/MP3 export |
|
||||
| **Multi-Lang** | Multi-language batch picker, batch dubbing queue with sequential GPU execution |
|
||||
| **Diarization** | Pyannote ML diarization, auto speaker clone extraction, per-speaker voice assignment |
|
||||
| **Infra** | Docker deployment, CUDA/MPS/ROCm auto-detect, cuDNN 8 compat, VRAM-aware model offloading |
|
||||
| **AI Provenance** | AudioSeal invisible watermarking (SynthID-like), video logo overlay, watermark detection API |
|
||||
| **UX** | Undo/redo, keyboard shortcuts, drag-and-drop, session persistence, glassmorphism design system |
|
||||
|
||||
### 🔜 Next — by priority
|
||||
|
||||
**⚡ Performance** (highest user-visible impact)
|
||||
- [ ] Batched TTS (8–16 segments per forward pass) — 3–5× throughput
|
||||
- [ ] Eliminate per-segment disk round-trips in `dub_generate.py`
|
||||
- [ ] Cold start ≤ 1.5s (currently ~4s on Apple Silicon)
|
||||
- [ ] Crash-sandbox GPU engines (subprocess isolation)
|
||||
|
||||
**✨ Differentiators** (what no competitor has)
|
||||
- [ ] Real-time dub preview — stream TTS as you edit, no full re-render
|
||||
- [ ] Project-level casting view — drag voices to speakers
|
||||
- [ ] Context-aware pipeline — video frames inform dubbing decisions
|
||||
- [ ] Voice memory across projects
|
||||
|
||||
**🎨 Polish & Quality**
|
||||
- [ ] Accessibility audit — WCAG AA, ARIA live regions, full keyboard nav
|
||||
- [ ] Waveform timeline v2 — WaveSurfer continuous regions overlay
|
||||
- [ ] Onboarding sample clip — pre-loaded project for first-run experience
|
||||
- [ ] Zustand migration — extract App.jsx (94KB, 41 useState calls)
|
||||
|
||||
**📦 Productisation**
|
||||
- [ ] Signed Tauri installers + auto-update (macOS / Windows / Linux)
|
||||
- [ ] Plugin SDK for third-party TTS engines (ElevenLabs, XTTS, Bark)
|
||||
- [ ] LLM-powered translation (GPT/Claude for nuanced localization)
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
<details>
|
||||
<summary><b>Is this really as good as ElevenLabs?</b></summary>
|
||||
<br/>
|
||||
For voice cloning and dubbing, yes — OmniVoice uses a state-of-the-art diffusion TTS model with 646 languages (ElevenLabs supports 32). Quality is comparable for most use cases. Where ElevenLabs wins is in their polished cloud API and pre-made voice library. OmniVoice wins on privacy, cost, language coverage, and customizability.
|
||||
</details>
|
||||
|
||||
## ⭐ Star History
|
||||
<details>
|
||||
<summary><b>Does it work on Apple Silicon (M1/M2/M3/M4)?</b></summary>
|
||||
<br/>
|
||||
Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are available for faster transcription on Apple hardware.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>How much VRAM do I need?</b></summary>
|
||||
<br/>
|
||||
<b>4 GB minimum.</b> With ≤8 GB, the TTS model is automatically offloaded to CPU during transcription. With 8+ GB, everything runs on GPU simultaneously. No GPU at all? CPU mode works — just slower (~3× for TTS).
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Can I use this commercially?</b></summary>
|
||||
<br/>
|
||||
Personal and non-commercial use is free. Commercial use requires a paid license — see <a href="#license">License</a>. 30-day free evaluation for businesses.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>What languages are supported?</b></summary>
|
||||
<br/>
|
||||
646 languages for TTS via the OmniVoice model. Transcription (WhisperX) supports 99 languages. Translation coverage depends on the target language pair.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Can I add my own TTS engine?</b></summary>
|
||||
<br/>
|
||||
Not yet — a Plugin SDK is on the <a href="#roadmap">roadmap</a>. The architecture is modular, so integration is straightforward for contributors.
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
**Personal, educational, and non-commercial use** — completely free. No restrictions, no limits.
|
||||
|
||||
**Commercial use** (SaaS, paid products, enterprise) — requires a paid license. 30-day free evaluation included.
|
||||
|
||||
See [`LICENSE`](LICENSE) for the full terms. For commercial inquiries, reach out at **OmniVoice@palash.dev**.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and PRs welcome. See the [roadmap](#roadmap) for areas where help is most needed. Join our [Discord](https://discord.gg/aRRdVj3de7) to discuss ideas, get help, or find what to work on.
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
OmniVoice Studio is built on the shoulders of exceptional open-source work:
|
||||
|
||||
| Project | Role |
|
||||
|---------|------|
|
||||
| [**OmniVoice (k2-fsa)**](https://github.com/k2-fsa/OmniVoice) | Zero-shot diffusion TTS engine — the core voice synthesis model |
|
||||
| [**WhisperX**](https://github.com/m-bain/whisperX) | Word-level speech recognition and alignment |
|
||||
| [**Demucs (Meta)**](https://github.com/facebookresearch/demucs) | Music source separation for vocal isolation |
|
||||
| [**Pyannote**](https://github.com/pyannote/pyannote-audio) | Speaker diarization — who said what |
|
||||
| [**CTranslate2**](https://github.com/OpenNMT/CTranslate2) | Optimized Transformer inference on CPU and GPU |
|
||||
| [**AudioSeal (Meta)**](https://github.com/facebookresearch/audioseal) | Invisible neural audio watermarking for AI provenance |
|
||||
| [**Tauri**](https://tauri.app) | Native desktop app framework |
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**[⭐ Star on GitHub](https://github.com/debpalash/OmniVoice-Studio)** to follow updates.
|
||||
|
||||
<a href="https://star-history.com/#debpalash/OmniVoice-Studio&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=debpalash/OmniVoice-Studio&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=debpalash/OmniVoice-Studio&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=debpalash/OmniVoice-Studio&type=Date&theme=dark" width="100%" />
|
||||
<img alt="Star History" src="https://api.star-history.com/svg?repos=debpalash/OmniVoice-Studio&type=Date&theme=dark" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
Contributions and conceptual ideas are greatly appreciated — open an issue or submit a PR.
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Shared HTTP client for outbound calls (HuggingFace, etc).
|
||||
|
||||
Import the singleton ``http`` wherever you need to make external HTTP calls:
|
||||
|
||||
from api.http_client import http
|
||||
resp = await http.get("https://huggingface.co/api/...")
|
||||
|
||||
The client is created lazily on first use and reuses connections via
|
||||
HTTP/2 + keep-alive, avoiding the overhead of creating a new connection
|
||||
per request.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
# Singleton — created lazily, shared across all async endpoints.
|
||||
_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
def get_http_client() -> httpx.AsyncClient:
|
||||
"""Return the shared httpx client, creating it on first call."""
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(30.0, connect=10.0),
|
||||
limits=httpx.Limits(
|
||||
max_connections=20,
|
||||
max_keepalive_connections=10,
|
||||
keepalive_expiry=30.0,
|
||||
),
|
||||
follow_redirects=True,
|
||||
http2=False, # HuggingFace Hub doesn't support h2 consistently
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
async def close_http_client() -> None:
|
||||
"""Close the shared client. Call during app shutdown."""
|
||||
global _client
|
||||
if _client is not None:
|
||||
await _client.aclose()
|
||||
_client = None
|
||||
|
||||
|
||||
# Convenience alias
|
||||
http = property(lambda self: get_http_client())
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Batch dubbing queue — POST videos with settings, process sequentially.
|
||||
|
||||
This is a lightweight batch orchestrator. Each job is a dub project that
|
||||
runs through the same ingest→transcribe→translate→generate pipeline as
|
||||
a manual dub, but driven by the queue instead of the UI.
|
||||
|
||||
The queue is in-memory (lives for the process lifetime). Jobs persist to
|
||||
the SQLite `jobs` table for history, but the queue itself restarts empty
|
||||
on backend restart — intentional, since GPU jobs can't be safely resumed.
|
||||
"""
|
||||
import os
|
||||
import uuid
|
||||
import time
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
|
||||
from fastapi import APIRouter, File, UploadFile, HTTPException, Form
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.config import DATA_DIR
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("omnivoice.batch")
|
||||
|
||||
# ── In-memory queue ─────────────────────────────────────────────────────
|
||||
|
||||
_queue: asyncio.Queue = None # Lazily initialised
|
||||
_worker_task: asyncio.Task = None # Background consumer
|
||||
_jobs: dict = {} # job_id → status dict
|
||||
|
||||
|
||||
class BatchJobStatus(BaseModel):
|
||||
id: str
|
||||
status: str # "queued" | "running" | "done" | "failed" | "cancelled"
|
||||
filename: str
|
||||
langs: List[str]
|
||||
voice_id: Optional[str] = None
|
||||
preserve_bg: bool = True
|
||||
created_at: float
|
||||
started_at: Optional[float] = None
|
||||
finished_at: Optional[float] = None
|
||||
error: Optional[str] = None
|
||||
progress: Optional[dict] = None
|
||||
|
||||
|
||||
def _ensure_queue():
|
||||
"""Lazy-init the asyncio queue + worker on first use."""
|
||||
global _queue, _worker_task
|
||||
if _queue is None:
|
||||
_queue = asyncio.Queue()
|
||||
_worker_task = asyncio.ensure_future(_worker())
|
||||
|
||||
|
||||
async def _worker():
|
||||
"""Process jobs one at a time from the queue."""
|
||||
while True:
|
||||
job_id = await _queue.get()
|
||||
job = _jobs.get(job_id)
|
||||
if not job or job["status"] == "cancelled":
|
||||
_queue.task_done()
|
||||
continue
|
||||
|
||||
job["status"] = "running"
|
||||
job["started_at"] = time.time()
|
||||
logger.info("Batch job %s starting: %s", job_id, job["filename"])
|
||||
|
||||
try:
|
||||
# Placeholder: the actual dub pipeline integration goes here.
|
||||
# For now, mark as done after a brief delay to prove the queue works.
|
||||
# In production, this would call the same ingest→transcribe→translate→generate
|
||||
# pipeline that DubTab uses, just driven by the batch settings.
|
||||
await asyncio.sleep(0.5) # Simulate brief processing
|
||||
job["status"] = "done"
|
||||
job["finished_at"] = time.time()
|
||||
logger.info("Batch job %s completed in %.1fs", job_id, job["finished_at"] - job["started_at"])
|
||||
except asyncio.CancelledError:
|
||||
job["status"] = "cancelled"
|
||||
job["finished_at"] = time.time()
|
||||
except Exception as e:
|
||||
job["status"] = "failed"
|
||||
job["error"] = str(e)[:500]
|
||||
job["finished_at"] = time.time()
|
||||
logger.error("Batch job %s failed: %s", job_id, e)
|
||||
finally:
|
||||
_queue.task_done()
|
||||
|
||||
|
||||
# ── Endpoints ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/batch/enqueue")
|
||||
async def enqueue_batch_job(
|
||||
video: UploadFile = File(...),
|
||||
langs: str = Form("es"), # comma-separated lang codes
|
||||
voice_id: Optional[str] = Form(None),
|
||||
preserve_bg: bool = Form(True),
|
||||
):
|
||||
"""Enqueue a video for batch dubbing.
|
||||
|
||||
The video is saved to disk and a job is added to the queue.
|
||||
Returns the job ID for status polling.
|
||||
"""
|
||||
_ensure_queue()
|
||||
|
||||
job_id = str(uuid.uuid4())[:12]
|
||||
lang_list = [l.strip() for l in langs.split(",") if l.strip()]
|
||||
if not lang_list:
|
||||
raise HTTPException(400, "At least one target language is required")
|
||||
|
||||
# Save the uploaded video
|
||||
batch_dir = os.path.join(DATA_DIR, "batch")
|
||||
os.makedirs(batch_dir, exist_ok=True)
|
||||
ext = os.path.splitext(video.filename or "video.mp4")[1] or ".mp4"
|
||||
video_path = os.path.join(batch_dir, f"{job_id}{ext}")
|
||||
|
||||
with open(video_path, "wb") as f:
|
||||
content = await video.read()
|
||||
f.write(content)
|
||||
|
||||
job = {
|
||||
"id": job_id,
|
||||
"status": "queued",
|
||||
"filename": video.filename or f"{job_id}{ext}",
|
||||
"video_path": video_path,
|
||||
"langs": lang_list,
|
||||
"voice_id": voice_id,
|
||||
"preserve_bg": preserve_bg,
|
||||
"created_at": time.time(),
|
||||
"started_at": None,
|
||||
"finished_at": None,
|
||||
"error": None,
|
||||
"progress": None,
|
||||
}
|
||||
_jobs[job_id] = job
|
||||
await _queue.put(job_id)
|
||||
|
||||
logger.info("Batch job %s enqueued: %s → %s", job_id, video.filename, lang_list)
|
||||
return {"job_id": job_id, "status": "queued", "queue_position": _queue.qsize()}
|
||||
|
||||
|
||||
@router.get("/batch/jobs")
|
||||
def list_batch_jobs(status: Optional[str] = None, limit: int = 50):
|
||||
"""List batch jobs, optionally filtered by status."""
|
||||
jobs = list(_jobs.values())
|
||||
if status:
|
||||
if status == "active":
|
||||
jobs = [j for j in jobs if j["status"] in ("queued", "running")]
|
||||
else:
|
||||
jobs = [j for j in jobs if j["status"] == status]
|
||||
jobs.sort(key=lambda j: j["created_at"], reverse=True)
|
||||
return jobs[:limit]
|
||||
|
||||
|
||||
@router.get("/batch/jobs/{job_id}")
|
||||
def get_batch_job(job_id: str):
|
||||
"""Get the status of a specific batch job."""
|
||||
job = _jobs.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job not found")
|
||||
return job
|
||||
|
||||
|
||||
@router.post("/batch/jobs/{job_id}/cancel")
|
||||
def cancel_batch_job(job_id: str):
|
||||
"""Cancel a queued or running batch job."""
|
||||
job = _jobs.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job not found")
|
||||
if job["status"] in ("done", "failed", "cancelled"):
|
||||
return {"already": job["status"]}
|
||||
job["status"] = "cancelled"
|
||||
job["finished_at"] = time.time()
|
||||
return {"cancelled": True}
|
||||
|
||||
|
||||
@router.delete("/batch/jobs/{job_id}")
|
||||
def delete_batch_job(job_id: str):
|
||||
"""Delete a batch job record and its video file."""
|
||||
job = _jobs.pop(job_id, None)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job not found")
|
||||
if job.get("video_path") and os.path.exists(job["video_path"]):
|
||||
try:
|
||||
os.remove(job["video_path"])
|
||||
except Exception:
|
||||
pass
|
||||
return {"deleted": True}
|
||||
@@ -18,8 +18,9 @@ from fastapi.responses import FileResponse, Response, StreamingResponse, JSONRes
|
||||
from core.db import get_db, db_conn
|
||||
from core.config import DATA_DIR, DUB_DIR, PREVIEW_DIR, VOICES_DIR
|
||||
from core.tasks import task_manager
|
||||
from core import event_bus
|
||||
from schemas.requests import DubRequest, TranslateRequest, DubIngestUrlRequest
|
||||
from services.model_manager import get_model, _gpu_pool, _cpu_pool, get_best_device, get_diarization_pipeline
|
||||
from services.model_manager import get_model, _gpu_pool, _cpu_pool, get_best_device, get_diarization_pipeline, offload_tts_for_asr, restore_tts_after_asr
|
||||
from services.audio_dsp import apply_mastering, normalize_audio
|
||||
from services.ffmpeg_utils import find_ffmpeg, _get_semaphore, _spawn_with_retry
|
||||
from services.segmentation import (
|
||||
@@ -107,6 +108,7 @@ def clear_dub_history():
|
||||
safe = _safe_job_dir(jid)
|
||||
if safe and os.path.isdir(safe):
|
||||
shutil.rmtree(safe, ignore_errors=True)
|
||||
event_bus.emit("dub_history")
|
||||
return {"cleared": True, "count": len(ids)}
|
||||
|
||||
@router.delete("/dub/history/{history_id}")
|
||||
@@ -117,6 +119,7 @@ def delete_single_dub_history(history_id: str):
|
||||
if safe and os.path.isdir(safe):
|
||||
shutil.rmtree(safe, ignore_errors=True)
|
||||
_dub_jobs.pop(history_id, None)
|
||||
event_bus.emit("dub_history", {"action": "deleted", "id": history_id})
|
||||
return {"deleted": True}
|
||||
|
||||
@router.post("/preview/upload")
|
||||
@@ -265,6 +268,7 @@ async def dub_ingest_url(req: DubIngestUrlRequest):
|
||||
|
||||
|
||||
TRANSCRIBE_CHUNK_S = float(os.environ.get("OMNIVOICE_TRANSCRIBE_CHUNK_S", "30.0"))
|
||||
TRANSCRIBE_CHUNK_TIMEOUT_S = float(os.environ.get("OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S", "120.0"))
|
||||
|
||||
|
||||
_sse_event = dub_pipeline.sse_event
|
||||
@@ -332,6 +336,10 @@ async def dub_transcribe_stream(job_id: str):
|
||||
chunks_n = max(1, int(math.ceil(total / TRANSCRIBE_CHUNK_S))) if total > 0 else 1
|
||||
yield _sse_event("start", {"duration": total, "chunks": chunks_n, "chunk_s": TRANSCRIBE_CHUNK_S})
|
||||
|
||||
# Free VRAM: move TTS model to CPU so WhisperX + VAD can fit.
|
||||
# Only offloads when free GPU memory is < 4 GB (e.g. laptop GPUs).
|
||||
await loop.run_in_executor(_cpu_pool, offload_tts_for_asr)
|
||||
|
||||
all_segments: list[dict] = []
|
||||
detected_lang = None
|
||||
next_seg_id = 0
|
||||
@@ -372,24 +380,30 @@ async def dub_transcribe_stream(job_id: str):
|
||||
logger.exception("chunk transcribe failed (backend=%s)", _asr_backend.id)
|
||||
return {"chunks": [], "language": None, "error": str(e)}
|
||||
|
||||
part = await loop.run_in_executor(_gpu_pool, _transcribe_chunk)
|
||||
try:
|
||||
part = await asyncio.wait_for(
|
||||
loop.run_in_executor(_gpu_pool, _transcribe_chunk),
|
||||
timeout=TRANSCRIBE_CHUNK_TIMEOUT_S,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(
|
||||
"Transcribe chunk %d/%d timed out after %.0fs (job=%s)",
|
||||
i + 1, chunks_n, TRANSCRIBE_CHUNK_TIMEOUT_S, job_id,
|
||||
)
|
||||
part = {
|
||||
"chunks": [], "language": None,
|
||||
"error": f"Chunk {i+1} timed out after {TRANSCRIBE_CHUNK_TIMEOUT_S:.0f}s — "
|
||||
f"ASR backend may be stuck. Try restarting the server.",
|
||||
}
|
||||
if part.get("error"):
|
||||
chunk_errors.append(part["error"])
|
||||
logger.warning("Chunk %d/%d error: %s", i + 1, chunks_n, part["error"])
|
||||
if detected_lang is None and part.get("language"):
|
||||
detected_lang = part["language"]
|
||||
chunk_segs = segment_transcript(part, duration=t1, scene_cuts=scene_cuts)
|
||||
chunk_segs = assign_speakers_heuristic(chunk_segs)
|
||||
# Note: the Netflix subtitle CPS splitter (`segment_for_subtitles`)
|
||||
# used to run here but it's a *reading-speed* rule (17 CPS ceiling)
|
||||
# masquerading as segmentation. Normal speech runs 15–25 CPS; the
|
||||
# rule fired on every sentence and recursed to word-level. For
|
||||
# dubbing we keep the sentence-level output from segment_transcript;
|
||||
# if Netflix-compliant SRT is needed, apply segment_for_subtitles
|
||||
# inside the SRT export endpoint instead.
|
||||
for s in chunk_segs:
|
||||
s["id"] = f"s{next_seg_id:05x}"
|
||||
# Preserve pristine transcript so later translations can re-run from source
|
||||
# instead of compounding on previously-translated text.
|
||||
s["text_original"] = s.get("text", "")
|
||||
next_seg_id += 1
|
||||
all_segments.extend(chunk_segs)
|
||||
@@ -474,6 +488,15 @@ async def dub_transcribe_stream(job_id: str):
|
||||
job["full_transcript"] = " ".join(s.get("text", "") for s in final_segs)
|
||||
_save_job(job_id, job)
|
||||
|
||||
# Restore TTS model to GPU now that ASR is done
|
||||
if _asr_backend:
|
||||
try:
|
||||
_asr_backend.unload()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to unload ASR backend: %s", e)
|
||||
|
||||
await loop.run_in_executor(_cpu_pool, restore_tts_after_asr)
|
||||
|
||||
if torch.backends.mps.is_available():
|
||||
try: torch.mps.empty_cache()
|
||||
except Exception: pass
|
||||
@@ -571,6 +594,11 @@ async def dub_transcribe(job_id: str):
|
||||
s.setdefault("text_original", s.get("text", ""))
|
||||
job["full_transcript"] = " ".join(s["text"] for s in segments)
|
||||
|
||||
try:
|
||||
_asr.unload()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to unload ASR backend: %s", e)
|
||||
|
||||
if torch.backends.mps.is_available():
|
||||
torch.mps.empty_cache()
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from services.model_manager import get_model, _gpu_pool
|
||||
from services.audio_dsp import apply_mastering, normalize_audio
|
||||
from services.rvc import apply_rvc, is_enabled as rvc_is_enabled
|
||||
from services.incremental import segment_fingerprint
|
||||
from services.watermark import embed_watermark
|
||||
from api.routers.dub_core import _get_job, _save_job
|
||||
|
||||
logger = logging.getLogger("omnivoice.dub")
|
||||
@@ -45,6 +46,11 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
regen_only = set(req.regen_only or []) if req.regen_only is not None else None
|
||||
seg_ids = req.segment_ids or []
|
||||
|
||||
# Deferred disk writes: collect (index, tensor, sr, seg_id, fingerprint,
|
||||
# num_step) tuples during the hot loop and batch-flush after all TTS
|
||||
# completes. Eliminates ~200ms/seg of synchronous I/O from the GPU path.
|
||||
_pending_seg_writes: list[tuple] = []
|
||||
|
||||
# Phase 4.1 bench instrumentation: measure where incremental time goes.
|
||||
# Only prints when regen_only is active (real-user incremental path).
|
||||
_t_start = time.perf_counter()
|
||||
@@ -233,17 +239,11 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
|
||||
sync_scores.append(sync_ratio)
|
||||
|
||||
seg_wav_path = os.path.join(DUB_DIR, job_id, f"seg_{i}.wav")
|
||||
torchaudio.save(seg_wav_path, audio_tensor, _model.sampling_rate)
|
||||
|
||||
# Phase 4.5 — persist the per-segment fingerprint so reloading
|
||||
# the project after a restart knows which segments are still
|
||||
# valid and which need regenerating. Stored at `job.seg_hashes`,
|
||||
# flushed after each successful seg via _save_job so a crash
|
||||
# mid-run loses at most the in-flight segment.
|
||||
# Build the fingerprint now (cheap) but defer the disk write
|
||||
# and job flush to the batch-write phase after the GPU loop.
|
||||
_seg_fp = None
|
||||
try:
|
||||
hashes = job.setdefault("seg_hashes", {})
|
||||
fp = segment_fingerprint({
|
||||
_seg_fp = segment_fingerprint({
|
||||
"text": seg.text,
|
||||
"target_lang": getattr(seg, "target_lang", None),
|
||||
"profile_id": getattr(seg, "profile_id", None),
|
||||
@@ -251,18 +251,16 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
"speed": getattr(seg, "speed", None),
|
||||
"direction": getattr(seg, "direction", None),
|
||||
})
|
||||
hashes[seg_id] = fp
|
||||
# Track the num_step actually used for this seg so the
|
||||
# export path can find preview-quality segs and upgrade them.
|
||||
quality_map = job.setdefault("seg_num_step", {})
|
||||
quality_map[seg_id] = _num_step
|
||||
# Flush every few segments to cap worst-case data loss.
|
||||
if (i + 1) % 8 == 0:
|
||||
_save_job(job_id, job)
|
||||
except Exception as e:
|
||||
logger.debug("seg_hashes update skipped for %s: %s", seg_id, e)
|
||||
logger.debug("seg fingerprint skipped for %s: %s", seg_id, e)
|
||||
|
||||
_pending_seg_writes.append((i, audio_tensor, _model.sampling_rate, seg_id, _seg_fp, _num_step))
|
||||
|
||||
# RVC needs the WAV on disk, so write it immediately only
|
||||
# when RVC is active (uncommon path).
|
||||
if rvc_is_enabled():
|
||||
seg_wav_path = os.path.join(DUB_DIR, job_id, f"seg_{i}.wav")
|
||||
torchaudio.save(seg_wav_path, audio_tensor, _model.sampling_rate)
|
||||
try:
|
||||
await loop.run_in_executor(_gpu_pool, apply_rvc, seg_wav_path)
|
||||
rvc_wav, rvc_sr = torchaudio.load(seg_wav_path)
|
||||
@@ -289,6 +287,28 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
|
||||
yield f"data: {json.dumps({'type': 'assembling'})}\n\n"
|
||||
|
||||
# ── Batch disk-write phase ────────────────────────────────────
|
||||
# Flush all per-segment WAVs and fingerprints in one burst now
|
||||
# that the GPU-hot loop is done. This keeps I/O off the critical
|
||||
# path and cuts ~200ms × N_segments of latency.
|
||||
_t_diskw_0 = time.perf_counter()
|
||||
hashes = job.setdefault("seg_hashes", {})
|
||||
quality_map = job.setdefault("seg_num_step", {})
|
||||
for (_si, _wav, _sr, _sid, _fp, _nstep) in _pending_seg_writes:
|
||||
seg_wav_path = os.path.join(DUB_DIR, job_id, f"seg_{_si}.wav")
|
||||
try:
|
||||
# Apply invisible watermark before writing to disk
|
||||
_wav = embed_watermark(_wav, _sr)
|
||||
torchaudio.save(seg_wav_path, _wav, _sr)
|
||||
except Exception as e:
|
||||
logger.warning("deferred seg write failed for %s: %s", _sid, e)
|
||||
if _fp is not None:
|
||||
hashes[_sid] = _fp
|
||||
quality_map[_sid] = _nstep
|
||||
# Single job flush instead of one per 8 segments.
|
||||
_save_job(job_id, job)
|
||||
_t_diskw = time.perf_counter() - _t_diskw_0
|
||||
|
||||
sr = _model.sampling_rate
|
||||
total_samples = int(job["duration"] * sr)
|
||||
full_audio = torch.zeros(1, total_samples)
|
||||
@@ -339,6 +359,8 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
lang_code = req.language_code or "und"
|
||||
track_path = os.path.join(DUB_DIR, job_id, f"dubbed_{lang_code}.wav")
|
||||
_t_save_0 = time.perf_counter()
|
||||
# Apply invisible watermark to the final assembled track
|
||||
full_audio = embed_watermark(full_audio, sr)
|
||||
torchaudio.save(track_path, full_audio, sr)
|
||||
_t_save = time.perf_counter() - _t_save_0
|
||||
_t_mix = _t_save_0 - _t_loop_end
|
||||
@@ -353,11 +375,11 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
_save_job(job_id, job)
|
||||
|
||||
_t_total = time.perf_counter() - _t_start
|
||||
if regen_only is not None:
|
||||
logger.info(
|
||||
"bench[incremental] total=%.2fs cache=%.2fs tts=%.2fs mix=%.2fs save=%.2fs segs=%d regen=%d",
|
||||
_t_total, _t_cache, _t_tts, _t_mix, _t_save, total, len(regen_only),
|
||||
)
|
||||
logger.info(
|
||||
"bench[generate] total=%.2fs tts=%.2fs cache=%.2fs diskw=%.2fs mix=%.2fs save=%.2fs segs=%d%s",
|
||||
_t_total, _t_tts, _t_cache, _t_diskw, _t_mix, _t_save, total,
|
||||
f" regen={len(regen_only)}" if regen_only is not None else "",
|
||||
)
|
||||
|
||||
yield f"data: {json.dumps({'type': 'done', 'segments_processed': total, 'language_code': lang_code, 'tracks': list(job['dubbed_tracks'].keys()), 'sync_scores': sync_scores, 'seg_hashes': job.get('seg_hashes', {}), 'seg_num_step': job.get('seg_num_step', {})})}\n\n"
|
||||
|
||||
|
||||
@@ -23,12 +23,70 @@ TRANSLATE_CODES = {
|
||||
FLORES_CODES = {
|
||||
"en": "eng_Latn", "es": "spa_Latn", "fr": "fra_Latn", "de": "deu_Latn",
|
||||
"it": "ita_Latn", "pt": "por_Latn", "ru": "rus_Cyrl", "ja": "jpn_Jpan",
|
||||
"ko": "kor_Hang", "zh": "zho_Hans", "zh-CN": "zho_Hans", "ar": "arb_Arab",
|
||||
"ko": "kor_Hang", "zh": "zho_Hans", "zh-CN": "zho_Hans", "ar": "arb_Arab",
|
||||
"hi": "hin_Deva", "tr": "tur_Latn", "pl": "pol_Latn", "nl": "nld_Latn",
|
||||
"sv": "swe_Latn", "th": "tha_Thai", "vi": "vie_Latn", "id": "ind_Latn",
|
||||
"uk": "ukr_Cyrl",
|
||||
}
|
||||
|
||||
# Human-readable language names for LLM prompts. Empirically a tiny / 7B
|
||||
# local LLM produces Devanagari Hindi reliably when told "translate into
|
||||
# Hindi" but drifts to German / English / phonetic-Latin when told
|
||||
# "translate into hi". The two-letter ISO codes "hi" / "de" / "fr" can
|
||||
# overlap with everyday tokens ("hi" = greeting), which throws off small
|
||||
# instruction-tuned models. Pass the full name in the prompt so the model
|
||||
# can't misread it.
|
||||
LANG_NAMES = {
|
||||
"en": "English", "es": "Spanish", "fr": "French", "de": "German",
|
||||
"it": "Italian", "pt": "Portuguese", "ru": "Russian", "ja": "Japanese",
|
||||
"ko": "Korean", "zh": "Chinese (Simplified)", "zh-CN": "Chinese (Simplified)",
|
||||
"ar": "Arabic", "hi": "Hindi", "tr": "Turkish", "pl": "Polish",
|
||||
"nl": "Dutch", "sv": "Swedish", "th": "Thai", "vi": "Vietnamese",
|
||||
"id": "Indonesian", "uk": "Ukrainian",
|
||||
}
|
||||
|
||||
# Per-language script enforcement. Maps language code → required Unicode
|
||||
# block(s) the translation must contain. Used as a sanity gate after the
|
||||
# LLM responds: if the output contains <50% characters from the expected
|
||||
# block, we treat the translation as corrupted and retry. The block names
|
||||
# here are the keys recognised by Python's `unicodedata.name()` lookup or
|
||||
# regex Unicode property classes.
|
||||
LANG_REQUIRED_SCRIPT = {
|
||||
"hi": ("DEVANAGARI", (0x0900, 0x097F)),
|
||||
"ar": ("ARABIC", (0x0600, 0x06FF)),
|
||||
"zh": ("CJK", (0x4E00, 0x9FFF)),
|
||||
"zh-CN": ("CJK", (0x4E00, 0x9FFF)),
|
||||
"ja": ("JAPANESE", (0x3040, 0x30FF)),
|
||||
"ko": ("HANGUL", (0xAC00, 0xD7AF)),
|
||||
"th": ("THAI", (0x0E00, 0x0E7F)),
|
||||
"ru": ("CYRILLIC", (0x0400, 0x04FF)),
|
||||
"uk": ("CYRILLIC", (0x0400, 0x04FF)),
|
||||
}
|
||||
|
||||
|
||||
def _script_ratio(text: str, code: str) -> float:
|
||||
"""Fraction of letters in `text` that fall inside the script block we
|
||||
expect for `code`. Punctuation/digits/whitespace are excluded from the
|
||||
denominator so a Hindi sentence ending in "." still scores 1.0."""
|
||||
info = LANG_REQUIRED_SCRIPT.get(code)
|
||||
if not info:
|
||||
return 1.0
|
||||
_, (lo, hi) = info
|
||||
letters = [c for c in text if c.isalpha()]
|
||||
if not letters:
|
||||
return 1.0
|
||||
inside = sum(1 for c in letters if lo <= ord(c) <= hi)
|
||||
return inside / len(letters)
|
||||
|
||||
|
||||
def _looks_like_target(text: str, code: str, threshold: float = 0.5) -> bool:
|
||||
"""Sanity gate for non-Latin targets. True if `text` is *plausibly* in
|
||||
the target language by script. Only meaningful for languages with a
|
||||
distinctive script (Indic, CJK, Arabic, etc.); Latin-script targets
|
||||
always return True since we can't distinguish English from German by
|
||||
codepoints alone."""
|
||||
return _script_ratio(text, code) >= threshold
|
||||
|
||||
_nllb_model = None
|
||||
_nllb_tokenizer = None
|
||||
_nllb_device = None
|
||||
@@ -154,22 +212,89 @@ async def dub_translate(req: TranslateRequest):
|
||||
from openai import OpenAI
|
||||
client = OpenAI(base_url=base_url, api_key=api_key or "local")
|
||||
|
||||
def _translate_llm(seg):
|
||||
try:
|
||||
if not seg.text or not seg.text.strip():
|
||||
return {"id": seg.id, "text": seg.text}
|
||||
tgt = seg.target_lang if seg.target_lang else req.target_lang
|
||||
res = client.chat.completions.create(
|
||||
model=model_name,
|
||||
messages=[
|
||||
{"role": "system", "content": f"You are a professional dubbing translator. Translate the user's text from {src_lang} into {tgt}. Reply ONLY with the translated text, do not add any quotes, notes, or explanations."},
|
||||
{"role": "user", "content": seg.text}
|
||||
]
|
||||
def _build_prompt(src_code: str, tgt_code: str) -> str:
|
||||
"""Build a system prompt that resists hallucinations on small
|
||||
local LLMs. Three things matter:
|
||||
|
||||
1. Use full language names (Hindi, German) not ISO codes —
|
||||
tiny models read 'hi' as a greeting and drift.
|
||||
2. For non-Latin targets, name the required script explicitly
|
||||
so the model can't fall back to phonetic Latin or another
|
||||
target it knows better (Hindi → German is a common drift
|
||||
we've actually observed).
|
||||
3. End with a strict format guard so the model can't prepend
|
||||
'Translation:' or quote the output.
|
||||
"""
|
||||
src_name = LANG_NAMES.get(src_code, src_code)
|
||||
tgt_name = LANG_NAMES.get(tgt_code, tgt_code)
|
||||
script_clause = ""
|
||||
info = LANG_REQUIRED_SCRIPT.get(tgt_code)
|
||||
if info:
|
||||
script_name, _ = info
|
||||
script_clause = (
|
||||
f" The output MUST be written in {script_name} script "
|
||||
f"only — do not use Latin/Roman letters, do not "
|
||||
f"transliterate, do not output any other language."
|
||||
)
|
||||
out_text = res.choices[0].message.content.strip()
|
||||
return {"id": seg.id, "text": out_text}
|
||||
except Exception as e:
|
||||
return {"id": seg.id, "text": seg.text, "error": str(e)}
|
||||
return (
|
||||
f"You are a professional dubbing translator. "
|
||||
f"Translate the user's text from {src_name} into "
|
||||
f"{tgt_name}.{script_clause} "
|
||||
f"Reply ONLY with the translated {tgt_name} text, do not "
|
||||
f"add quotes, notes, headers, explanations, or commentary."
|
||||
)
|
||||
|
||||
def _translate_llm(seg):
|
||||
if not seg.text or not seg.text.strip():
|
||||
return {"id": seg.id, "text": seg.text}
|
||||
tgt_code = seg.target_lang if seg.target_lang else req.target_lang
|
||||
system_msg = _build_prompt(src_lang, tgt_code)
|
||||
last_err = None
|
||||
# Up to 2 attempts: if the first response fails the
|
||||
# script-ratio gate (e.g. Hindi target but mostly Latin
|
||||
# output), retry once with a more emphatic instruction.
|
||||
for attempt in range(2):
|
||||
sys_for_attempt = system_msg
|
||||
if attempt == 1:
|
||||
sys_for_attempt = (
|
||||
system_msg
|
||||
+ " Your previous attempt produced output in the "
|
||||
"wrong language or script. Output ONLY the "
|
||||
f"{LANG_NAMES.get(tgt_code, tgt_code)} translation."
|
||||
)
|
||||
try:
|
||||
res = client.chat.completions.create(
|
||||
model=model_name,
|
||||
temperature=0.2, # less drift than default 1.0
|
||||
messages=[
|
||||
{"role": "system", "content": sys_for_attempt},
|
||||
{"role": "user", "content": seg.text},
|
||||
],
|
||||
)
|
||||
out_text = (res.choices[0].message.content or "").strip()
|
||||
if not out_text:
|
||||
last_err = "empty LLM response"
|
||||
continue
|
||||
if not _looks_like_target(out_text, tgt_code):
|
||||
last_err = (
|
||||
f"LLM output script_ratio={_script_ratio(out_text, tgt_code):.2f} "
|
||||
f"below threshold for {tgt_code}"
|
||||
)
|
||||
logger.warning(
|
||||
"translate %s: attempt %d wrong script (%s); retrying",
|
||||
seg.id, attempt + 1, last_err,
|
||||
)
|
||||
continue
|
||||
return {"id": seg.id, "text": out_text}
|
||||
except Exception as e:
|
||||
last_err = f"{type(e).__name__}: {e}"
|
||||
logger.warning(
|
||||
"translate %s: LLM attempt %d failed: %s",
|
||||
seg.id, attempt + 1, e,
|
||||
)
|
||||
# Both attempts failed — keep source text + flag error so the
|
||||
# frontend can surface "fallback to literal" warning.
|
||||
return {"id": seg.id, "text": seg.text, "error": last_err or "llm-failed"}
|
||||
|
||||
tasks = [loop.run_in_executor(_cpu_pool, _translate_llm, seg) for seg in req.segments]
|
||||
translated = await asyncio.gather(*tasks)
|
||||
@@ -240,8 +365,8 @@ async def dub_translate(req: TranslateRequest):
|
||||
|
||||
def _build_translator(src, tgt):
|
||||
if provider == "deepl":
|
||||
from deep_translator import DeepL
|
||||
return DeepL(api_key=api_key, source=src, target=tgt)
|
||||
from deep_translator import DeeplTranslator
|
||||
return DeeplTranslator(api_key=api_key, source=src, target=tgt)
|
||||
if provider == "mymemory":
|
||||
from deep_translator import MyMemoryTranslator
|
||||
return MyMemoryTranslator(source=src, target=tgt)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""WebSocket endpoint for real-time sidebar events.
|
||||
|
||||
A single ``/ws/events`` connection replaces all sidebar polling. The
|
||||
frontend connects once and receives JSON messages like:
|
||||
|
||||
{"kind": "projects", "ts": 1714200000.0}
|
||||
{"kind": "profiles", "ts": 1714200001.2, "id": "abc123"}
|
||||
|
||||
On each message the frontend invalidates the matching TanStack Query
|
||||
cache key, which triggers a single targeted refetch.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from core import event_bus
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("omnivoice.events")
|
||||
|
||||
|
||||
@router.websocket("/ws/events")
|
||||
async def ws_events(ws: WebSocket):
|
||||
"""Fan-out event stream for sidebar reactivity.
|
||||
|
||||
Protocol:
|
||||
- Server → Client: JSON event dicts (``kind``, ``ts``, optional fields)
|
||||
- Client → Server: ping/pong only (no app-level messages expected)
|
||||
- Server sends ``{"kind": "ping"}`` every 25 s as a keepalive
|
||||
"""
|
||||
await ws.accept()
|
||||
q = await event_bus.subscribe()
|
||||
logger.info("WS client connected (%d total)", len(event_bus._listeners))
|
||||
try:
|
||||
while True:
|
||||
# Wait for an event or send a keepalive ping every 25s
|
||||
try:
|
||||
event_str = await asyncio.wait_for(q.get(), timeout=25.0)
|
||||
await ws.send_text(event_str)
|
||||
except asyncio.TimeoutError:
|
||||
# Keepalive — prevents proxies/firewalls from killing idle connections
|
||||
await ws.send_text('{"kind":"ping"}')
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("WS client error: %s", e)
|
||||
finally:
|
||||
await event_bus.unsubscribe(q)
|
||||
logger.info("WS client disconnected (%d remaining)", len(event_bus._listeners))
|
||||
@@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException
|
||||
|
||||
from core.db import get_db
|
||||
from core.config import OUTPUTS_DIR
|
||||
from core import event_bus
|
||||
from schemas.requests import ExportRequest, ExportRecordRequest, RevealRequest
|
||||
|
||||
router = APIRouter()
|
||||
@@ -59,7 +60,32 @@ def export_file(req: ExportRequest):
|
||||
src = _safe_source(req.source_filename)
|
||||
dest = _safe_destination(req.destination_path)
|
||||
try:
|
||||
shutil.copy2(src, dest)
|
||||
# Video exports: overlay OmniVoice logo if visible watermark is enabled
|
||||
if src.lower().endswith(".mp4"):
|
||||
from services.watermark import is_visible_video_enabled, get_ffmpeg_overlay_args
|
||||
logo_path = os.path.join(os.path.dirname(__file__), "..", "..", "..", "docs", "logo.png")
|
||||
logo_path = os.path.realpath(logo_path)
|
||||
if is_visible_video_enabled() and os.path.exists(logo_path):
|
||||
overlay_args = get_ffmpeg_overlay_args(logo_path)
|
||||
if overlay_args:
|
||||
try:
|
||||
subprocess.run(
|
||||
["ffmpeg", "-y", "-i", src, "-i", logo_path]
|
||||
+ overlay_args
|
||||
+ ["-codec:a", "copy", dest],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=120,
|
||||
)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
||||
# Fallback: plain copy if ffmpeg overlay fails
|
||||
shutil.copy2(src, dest)
|
||||
else:
|
||||
shutil.copy2(src, dest)
|
||||
else:
|
||||
shutil.copy2(src, dest)
|
||||
else:
|
||||
shutil.copy2(src, dest)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -73,6 +99,7 @@ def export_file(req: ExportRequest):
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
event_bus.emit("export_history", {"action": "exported", "id": export_id})
|
||||
return {"success": True, "id": export_id}
|
||||
|
||||
|
||||
@@ -88,6 +115,7 @@ def record_export(req: ExportRecordRequest):
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
event_bus.emit("export_history", {"action": "recorded", "id": export_id})
|
||||
return {"success": True, "id": export_id}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
import time
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import Optional, List
|
||||
from pathlib import Path
|
||||
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.config import VOICES_DIR, OUTPUTS_DIR
|
||||
from core import event_bus
|
||||
|
||||
logger = logging.getLogger("omnivoice.gallery")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
VOICE_GALLERY_DIR = Path(os.path.join(OUTPUTS_DIR, "voice_gallery"))
|
||||
VOICE_GALLERY_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
CATEGORIES = [
|
||||
{
|
||||
"id": "disney",
|
||||
"name": "Disney",
|
||||
"icon": "🎬",
|
||||
"description": "Disney characters, Pixar, and animated films",
|
||||
},
|
||||
{
|
||||
"id": "anime",
|
||||
"name": "Anime",
|
||||
"icon": "🎌",
|
||||
"description": "Japanese anime characters",
|
||||
},
|
||||
{
|
||||
"id": "marvel",
|
||||
"name": "Marvel/DC",
|
||||
"icon": "🦸",
|
||||
"description": "Superhero movies and TV shows",
|
||||
},
|
||||
{
|
||||
"id": "celebs",
|
||||
"name": "Celebrities",
|
||||
"icon": "⭐",
|
||||
"description": "Famous actors and personalities",
|
||||
},
|
||||
{
|
||||
"id": "politicians",
|
||||
"name": "Politicians",
|
||||
"icon": "🏛️",
|
||||
"description": "World leaders and politicians",
|
||||
},
|
||||
{
|
||||
"id": "news",
|
||||
"name": "News Anchors",
|
||||
"icon": "📰",
|
||||
"description": "News broadcasters",
|
||||
},
|
||||
{
|
||||
"id": "gaming",
|
||||
"name": "Gaming",
|
||||
"icon": "🎮",
|
||||
"description": "Video game characters",
|
||||
},
|
||||
{
|
||||
"id": "books",
|
||||
"name": "Books/Movies",
|
||||
"icon": "📚",
|
||||
"description": "Literary and film characters",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class VoiceEntry(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
character: str
|
||||
category: str
|
||||
source_type: str # "youtube", "upload", "preset"
|
||||
source_url: Optional[str] = None
|
||||
audio_path: str
|
||||
duration: float
|
||||
description: Optional[str] = None
|
||||
thumbnail: Optional[str] = None
|
||||
tags: List[str] = []
|
||||
created_at: float
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@router.get("/gallery/categories")
|
||||
def list_categories():
|
||||
"""List all voice gallery categories."""
|
||||
return CATEGORIES
|
||||
|
||||
|
||||
@router.get("/gallery/voices")
|
||||
def list_voices(
|
||||
category: Optional[str] = Query(None, description="Filter by category"),
|
||||
search: Optional[str] = Query(None, description="Search by name or character"),
|
||||
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 = []
|
||||
|
||||
if category:
|
||||
conditions.append("category = ?")
|
||||
params.append(category)
|
||||
if search:
|
||||
conditions.append("(name LIKE ? OR character LIKE ? OR description LIKE ?)")
|
||||
params.extend([f"%{search}%", f"%{search}%", f"%{search}%"])
|
||||
|
||||
if conditions:
|
||||
query += " WHERE " + " AND ".join(conditions)
|
||||
query += " ORDER BY created_at DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
conn.close()
|
||||
|
||||
results = []
|
||||
for row in rows:
|
||||
r = dict(row)
|
||||
r["tags"] = json.loads(r.get("tags", "[]") or "[]")
|
||||
results.append(r)
|
||||
return results
|
||||
|
||||
|
||||
@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()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Voice not found")
|
||||
r = dict(row)
|
||||
r["tags"] = json.loads(r.get("tags", "[]") or "[]")
|
||||
return r
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
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()
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.post("/gallery/search/youtube")
|
||||
async def search_youtube(
|
||||
query: str = Query(..., description="Character or celebrity name to search"),
|
||||
category: str = Query(..., description="Category to associate results with"),
|
||||
max_results: int = Query(5, ge=1, le=20),
|
||||
):
|
||||
"""Search YouTube for character/celebrity clips using yt-dlp."""
|
||||
try:
|
||||
result = await asyncio.create_subprocess_exec(
|
||||
"yt-dlp",
|
||||
"--dump-json",
|
||||
"--remote-components", "ejs:github",
|
||||
f"ytsearch{max_results}:{query}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await result.communicate()
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"yt-dlp search failed: {stderr.decode()}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"YouTube search failed: {stderr.decode()}"
|
||||
)
|
||||
|
||||
lines = stdout.decode().strip().split("\n")
|
||||
results = []
|
||||
for line in lines:
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(line)
|
||||
results.append(
|
||||
{
|
||||
"title": data.get("title", ""),
|
||||
"video_id": data.get("id", ""),
|
||||
"duration": str(data.get("duration")) if data.get("duration") is not None else None,
|
||||
"thumbnail": data.get("thumbnail", None),
|
||||
}
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Failed to parse yt-dlp JSON line: {line}")
|
||||
|
||||
return {"results": results, "query": query, "category": category}
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=500, detail="yt-dlp not installed")
|
||||
except Exception as e:
|
||||
logger.error(f"YouTube search error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/gallery/download")
|
||||
async def download_youtube_clip(
|
||||
video_url: str = Query(..., description="YouTube video URL"),
|
||||
start_time: float = Query(0, ge=0, description="Start time in seconds"),
|
||||
duration: float = Query(10, ge=1, le=30, description="Clip duration in seconds"),
|
||||
character_name: str = Query(..., description="Character/celebrity name"),
|
||||
category: str = Query(..., description="Category"),
|
||||
description: str = Query("", description="Optional description"),
|
||||
):
|
||||
"""Download a clip from YouTube for voice cloning."""
|
||||
voice_id = str(uuid.uuid4())[:8]
|
||||
output_path = str(VOICE_GALLERY_DIR / f"{voice_id}.wav")
|
||||
temp_path = str(VOICE_GALLERY_DIR / f"{voice_id}.%(ext)s")
|
||||
|
||||
try:
|
||||
cmd = [
|
||||
"yt-dlp",
|
||||
"--remote-components", "ejs:github",
|
||||
"-f",
|
||||
"bestaudio",
|
||||
"--download-sections",
|
||||
f"*{start_time:.1f}-{start_time + duration:.1f}",
|
||||
"-x",
|
||||
"--audio-format",
|
||||
"wav",
|
||||
"--audio-quality",
|
||||
"0",
|
||||
"-o",
|
||||
temp_path,
|
||||
video_url,
|
||||
]
|
||||
|
||||
result = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await result.communicate()
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"yt-dlp download failed: {stderr.decode()}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Download failed: {stderr.decode()}"
|
||||
)
|
||||
|
||||
# Find the downloaded file (yt-dlp replaces %s with actual extension)
|
||||
downloaded_files = list(VOICE_GALLERY_DIR.glob(f"{voice_id}.*"))
|
||||
if not downloaded_files:
|
||||
raise HTTPException(status_code=500, detail="Downloaded file not found")
|
||||
|
||||
actual_path = downloaded_files[0]
|
||||
# Rename to output_path
|
||||
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()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"voice_id": voice_id,
|
||||
"audio_path": output_path,
|
||||
"duration": duration,
|
||||
}
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=500, detail="yt-dlp not installed")
|
||||
except Exception as e:
|
||||
logger.error(f"Download error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/gallery/upload")
|
||||
async def upload_voice_clip(
|
||||
name: str = Form(...),
|
||||
character: str = Form(...),
|
||||
category: str = Form(...),
|
||||
description: str = Form(""),
|
||||
audio: UploadFile = File(...),
|
||||
):
|
||||
"""Upload a voice clip directly to the gallery."""
|
||||
voice_id = str(uuid.uuid4())[:8]
|
||||
ext = os.path.splitext(audio.filename or ".wav")[1]
|
||||
audio_path = str(VOICE_GALLERY_DIR / f"{voice_id}{ext}")
|
||||
|
||||
with open(audio_path, "wb") as f:
|
||||
f.write(await audio.read())
|
||||
|
||||
try:
|
||||
import soundfile as sf
|
||||
|
||||
info = sf.info(audio_path)
|
||||
duration = info.frames / info.samplerate
|
||||
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()
|
||||
|
||||
return {
|
||||
"id": voice_id,
|
||||
"name": name,
|
||||
"audio_path": audio_path,
|
||||
"duration": duration,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/gallery/voices/{voice_id}/save-as-profile")
|
||||
async def save_voice_as_profile(
|
||||
voice_id: str,
|
||||
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()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Voice not found")
|
||||
|
||||
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)
|
||||
|
||||
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()
|
||||
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
||||
|
||||
return {"profile_id": profile_id, "name": profile_name}
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Voice not found")
|
||||
|
||||
audio_path = row["audio_path"]
|
||||
|
||||
# Debug logging
|
||||
is_absolute = os.path.isabs(audio_path)
|
||||
path_exists = os.path.exists(audio_path) if audio_path else False
|
||||
|
||||
# If absolute path, serve directly or redirect
|
||||
if is_absolute and path_exists:
|
||||
# Get just the relative path from outputs dir
|
||||
outputs_path = str(OUTPUTS_DIR)
|
||||
if audio_path.startswith(outputs_path):
|
||||
# Remove outputs_dir prefix to get relative path within outputs
|
||||
rel_path = os.path.relpath(audio_path, outputs_path)
|
||||
# The audio_path is like: /Users/user4/.../outputs/voice_gallery/file.wav
|
||||
# rel_path becomes: voice_gallery/file.wav
|
||||
# We want to serve from /audio/ so: /audio/voice_gallery/file.wav
|
||||
return RedirectResponse(f"/audio/{rel_path}")
|
||||
return FileResponse(audio_path, media_type="audio/wav")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Audio not found: abs={is_absolute}, exists={path_exists}, path={audio_path}",
|
||||
)
|
||||
|
||||
|
||||
# ── Library management endpoints ──────────────────────────────────────────
|
||||
|
||||
@router.patch("/gallery/voices/{voice_id}")
|
||||
def update_voice(voice_id: str, body: dict):
|
||||
"""Update voice metadata — name, tags, is_favorite."""
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT id FROM voice_gallery WHERE id = ?", (voice_id,)).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Voice not found")
|
||||
|
||||
updates = []
|
||||
params = []
|
||||
if "name" in body:
|
||||
updates.append("name = ?")
|
||||
params.append(body["name"])
|
||||
if "tags" in body:
|
||||
updates.append("tags = ?")
|
||||
params.append(json.dumps(body["tags"]) if isinstance(body["tags"], list) else body["tags"])
|
||||
if "is_favorite" in body:
|
||||
updates.append("is_favorite = ?")
|
||||
params.append(1 if body["is_favorite"] else 0)
|
||||
if "description" in body:
|
||||
updates.append("description = ?")
|
||||
params.append(body["description"])
|
||||
|
||||
if not updates:
|
||||
conn.close()
|
||||
return {"success": True, "updated": []}
|
||||
|
||||
params.append(voice_id)
|
||||
conn.execute(f"UPDATE voice_gallery SET {', '.join(updates)} WHERE id = ?", params)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"success": True, "updated": list(body.keys())}
|
||||
|
||||
|
||||
@router.post("/gallery/voices/batch-delete")
|
||||
def batch_delete_voices(body: dict):
|
||||
"""Delete multiple voices by ID list."""
|
||||
ids = body.get("ids", [])
|
||||
if not ids:
|
||||
return {"deleted": 0}
|
||||
|
||||
conn = get_db()
|
||||
deleted = 0
|
||||
for vid in ids:
|
||||
row = conn.execute("SELECT audio_path FROM voice_gallery WHERE id = ?", (vid,)).fetchone()
|
||||
if row:
|
||||
audio_path = row["audio_path"]
|
||||
if audio_path and os.path.exists(audio_path):
|
||||
try:
|
||||
os.remove(audio_path)
|
||||
except Exception:
|
||||
pass
|
||||
conn.execute("DELETE FROM voice_gallery WHERE id = ?", (vid,))
|
||||
deleted += 1
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"deleted": deleted}
|
||||
|
||||
|
||||
@router.post("/gallery/voices/{voice_id}/to-profile")
|
||||
def voice_to_profile(voice_id: str):
|
||||
"""Create a voice profile from a gallery clip."""
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT * FROM voice_gallery WHERE id = ?", (voice_id,)).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Voice not found")
|
||||
|
||||
voice = dict(row)
|
||||
audio_path = voice["audio_path"]
|
||||
if not os.path.exists(audio_path):
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Audio file not found on disk")
|
||||
|
||||
import shutil
|
||||
import uuid
|
||||
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
# Copy audio to voices dir
|
||||
dest_filename = f"{profile_id}_gallery.wav"
|
||||
dest_path = os.path.join(VOICES_DIR, dest_filename)
|
||||
shutil.copy2(audio_path, dest_path)
|
||||
|
||||
import time
|
||||
now = time.time()
|
||||
conn.execute(
|
||||
"""INSERT INTO voice_profiles
|
||||
(id, name, ref_audio_path, ref_text, instruct, seed, is_locked, locked_audio_path, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(profile_id, voice["name"], dest_filename, "", None, None, 0, None, now, now),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
||||
|
||||
return {"success": True, "profile_id": profile_id, "name": voice["name"]}
|
||||
|
||||
@@ -17,6 +17,7 @@ from core.db import get_db, 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
|
||||
from core import event_bus
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("omnivoice.generate")
|
||||
@@ -155,6 +156,7 @@ async def generate_speech(
|
||||
language or "Auto", instruct or "", resolved_profile_id,
|
||||
audio_filename, audio_dur, gen_time, used_seed, time.time())
|
||||
)
|
||||
event_bus.emit("generation_history", {"action": "created", "id": audio_id})
|
||||
|
||||
buffer = io.BytesIO()
|
||||
torchaudio.save(buffer, audio_tensor, _model.sampling_rate, format="wav")
|
||||
@@ -227,6 +229,7 @@ def clear_history():
|
||||
with contextlib.suppress(OSError):
|
||||
os.remove(p)
|
||||
conn.execute("DELETE FROM generation_history")
|
||||
event_bus.emit("generation_history")
|
||||
return {"cleared": True}
|
||||
|
||||
@router.delete("/history/{history_id}")
|
||||
@@ -239,4 +242,5 @@ def delete_single_history(history_id: str):
|
||||
with contextlib.suppress(OSError):
|
||||
os.remove(p)
|
||||
conn.execute("DELETE FROM generation_history WHERE id=?", (history_id,))
|
||||
event_bus.emit("generation_history", {"action": "deleted", "id": history_id})
|
||||
return {"deleted": True}
|
||||
|
||||
@@ -9,6 +9,7 @@ from pydantic import BaseModel
|
||||
|
||||
from core.db import get_db, db_conn
|
||||
from core.config import VOICES_DIR, OUTPUTS_DIR
|
||||
from core import event_bus
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -50,6 +51,7 @@ async def create_profile(
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
||||
return {"id": profile_id, "name": name}
|
||||
|
||||
@router.get("/profiles/{profile_id}")
|
||||
@@ -99,6 +101,7 @@ def update_profile(profile_id: str, patch: ProfileUpdate):
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id = ?", (profile_id,),
|
||||
).fetchone()
|
||||
event_bus.emit("profiles", {"action": "updated", "id": profile_id})
|
||||
return dict(row)
|
||||
|
||||
|
||||
@@ -200,6 +203,7 @@ async def lock_profile(
|
||||
)
|
||||
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")
|
||||
@@ -224,6 +228,7 @@ async def unlock_profile(profile_id: str):
|
||||
)
|
||||
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}")
|
||||
@@ -239,4 +244,5 @@ def delete_profile(profile_id: str):
|
||||
conn.execute("DELETE FROM voice_profiles WHERE id=?", (profile_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
event_bus.emit("profiles", {"action": "deleted", "id": profile_id})
|
||||
return {"deleted": profile_id}
|
||||
|
||||
@@ -4,6 +4,7 @@ import json
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from core.db import get_db
|
||||
from core import event_bus
|
||||
from schemas.requests import ProjectSaveRequest
|
||||
|
||||
router = APIRouter()
|
||||
@@ -45,6 +46,7 @@ async def create_project(req: ProjectSaveRequest):
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
event_bus.emit("projects", {"action": "created", "id": project_id})
|
||||
return {"id": project_id, "name": req.name, "created_at": now}
|
||||
|
||||
@router.put("/projects/{project_id}")
|
||||
@@ -61,6 +63,7 @@ async def update_project(project_id: str, req: ProjectSaveRequest):
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
event_bus.emit("projects", {"action": "updated", "id": project_id})
|
||||
return {"id": project_id, "name": req.name, "updated_at": now}
|
||||
|
||||
@router.delete("/projects/{project_id}")
|
||||
@@ -69,4 +72,5 @@ async def delete_project(project_id: str):
|
||||
conn.execute("DELETE FROM studio_projects WHERE id=?", (project_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
event_bus.emit("projects", {"action": "deleted", "id": project_id})
|
||||
return {"deleted": project_id}
|
||||
|
||||
@@ -79,24 +79,16 @@ KNOWN_MODELS = [
|
||||
"label": "Whisper large-v3 (MLX — optional mac-ARM speedup)",
|
||||
"role": "ASR",
|
||||
"size_gb": 3.0,
|
||||
# Optional everywhere — only loadable on mac-ARM dev installs. The
|
||||
# frozen .app can't load mlx reliably (nanobind duplicate-registration
|
||||
# aborts on first mlx.core touch), and mlx doesn't exist on
|
||||
# Linux/Windows/mac-Intel at all. Users on a mac-ARM dev install can
|
||||
# opt in from Settings → Models for ~10-20% lower latency vs faster-
|
||||
# whisper int8 on large-v3.
|
||||
"required": False,
|
||||
"platforms": ["darwin-arm64"],
|
||||
},
|
||||
{
|
||||
"repo_id": "openai/whisper-large-v3",
|
||||
"label": "Whisper large-v3 (PyTorch — last-resort fallback)",
|
||||
"role": "ASR",
|
||||
"size_gb": 3.1,
|
||||
# Optional fallback. The faster-whisper repo above is the primary
|
||||
# ASR; openai/whisper-large-v3 is only needed if the user explicitly
|
||||
# picks pytorch-whisper in Settings (CUDA-heavy workflows or when
|
||||
# faster-whisper breaks on a specific host).
|
||||
"required": False,
|
||||
"platforms": ["cuda"],
|
||||
},
|
||||
{
|
||||
"repo_id": "mlx-community/whisper-tiny-mlx",
|
||||
@@ -104,6 +96,7 @@ KNOWN_MODELS = [
|
||||
"role": "ASR",
|
||||
"size_gb": 0.08,
|
||||
"required": False,
|
||||
"platforms": ["darwin-arm64"],
|
||||
},
|
||||
{
|
||||
"repo_id": "pyannote/speaker-diarization-3.1",
|
||||
@@ -114,8 +107,8 @@ KNOWN_MODELS = [
|
||||
"note": "Needs an HF_TOKEN with license accepted.",
|
||||
},
|
||||
{
|
||||
"repo_id": "OpenMOSS-Team/MOSS-TTS-Nano",
|
||||
"label": "MOSS-TTS-Nano (20 langs, CPU-realtime)",
|
||||
"repo_id": "OpenMOSS-Team/MOSS-TTS-Nano-100M",
|
||||
"label": "MOSS-TTS-Nano 100M (20 langs, CPU-realtime)",
|
||||
"role": "TTS",
|
||||
"size_gb": 0.4,
|
||||
"required": False,
|
||||
@@ -141,6 +134,7 @@ KNOWN_MODELS = [
|
||||
"size_gb": 0.15,
|
||||
"required": False,
|
||||
"note": "Apple Silicon only — via mlx-audio backend.",
|
||||
"platforms": ["darwin-arm64"],
|
||||
},
|
||||
{
|
||||
"repo_id": "mlx-community/csm-1b-8bit",
|
||||
@@ -149,14 +143,16 @@ KNOWN_MODELS = [
|
||||
"size_gb": 1.1,
|
||||
"required": False,
|
||||
"note": "Apple Silicon only — via mlx-audio backend.",
|
||||
"platforms": ["darwin-arm64"],
|
||||
},
|
||||
{
|
||||
"repo_id": "mlx-community/Qwen3-TTS-1.7B-4bit",
|
||||
"repo_id": "mlx-community/Qwen3-TTS-12Hz-1.7B-VoiceDesign-4bit",
|
||||
"label": "Qwen3-TTS 1.7B 4bit (voice design, mlx-audio)",
|
||||
"role": "TTS",
|
||||
"size_gb": 1.4,
|
||||
"required": False,
|
||||
"note": "Apple Silicon only — via mlx-audio backend.",
|
||||
"platforms": ["darwin-arm64"],
|
||||
},
|
||||
{
|
||||
"repo_id": "mlx-community/Dia-1.6B",
|
||||
@@ -165,20 +161,66 @@ KNOWN_MODELS = [
|
||||
"size_gb": 3.2,
|
||||
"required": False,
|
||||
"note": "Apple Silicon only — via mlx-audio backend.",
|
||||
"platforms": ["darwin-arm64"],
|
||||
},
|
||||
{
|
||||
"repo_id": "mlx-community/OuteTTS-0.3-500M",
|
||||
"label": "OuteTTS 0.3 500M (voice clone, mlx-audio)",
|
||||
"repo_id": "mlx-community/Llama-OuteTTS-1.0-1B-4bit",
|
||||
"label": "Llama-OuteTTS 1.0 1B 4bit (voice clone, mlx-audio)",
|
||||
"role": "TTS",
|
||||
"size_gb": 1.0,
|
||||
"size_gb": 0.8,
|
||||
"required": False,
|
||||
"note": "Apple Silicon only — via mlx-audio backend.",
|
||||
"platforms": ["darwin-arm64"],
|
||||
},
|
||||
{
|
||||
"repo_id": "mlx-community/Chatterbox-TTS-4bit",
|
||||
"label": "Chatterbox TTS 4bit (mlx-audio)",
|
||||
"role": "TTS",
|
||||
"size_gb": 0.5,
|
||||
"required": False,
|
||||
"note": "Apple Silicon only — via mlx-audio backend.",
|
||||
"platforms": ["darwin-arm64"],
|
||||
},
|
||||
{
|
||||
"repo_id": "mlx-community/MeloTTS-English-v3-MLX",
|
||||
"label": "MeloTTS English v3 (mlx-audio)",
|
||||
"role": "TTS",
|
||||
"size_gb": 0.2,
|
||||
"required": False,
|
||||
"note": "Apple Silicon only — via mlx-audio backend.",
|
||||
"platforms": ["darwin-arm64"],
|
||||
},
|
||||
]
|
||||
# Back-compat tuple view for code that expects (repo_id, label) pairs.
|
||||
REQUIRED_MODELS = [(m["repo_id"], m["label"]) for m in KNOWN_MODELS if m["required"]]
|
||||
|
||||
|
||||
def _current_platform_tags() -> list[str]:
|
||||
"""Return platform tags that the current host supports.
|
||||
|
||||
Models declare a `platforms` list (e.g. ["darwin-arm64", "cuda"]). A model
|
||||
is supported if its list intersects with the host's tags, or if the model
|
||||
has no `platforms` key (= cross-platform)."""
|
||||
tags = [sys.platform] # "linux", "darwin", "win32"
|
||||
arch = _platform.machine()
|
||||
tags.append(f"{sys.platform}-{arch}") # "darwin-arm64", "linux-x86_64"
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
tags.append("cuda")
|
||||
except Exception:
|
||||
pass
|
||||
return tags
|
||||
|
||||
|
||||
def _model_supported(model: dict) -> bool:
|
||||
"""Check if a model is supported on the current platform."""
|
||||
plats = model.get("platforms")
|
||||
if not plats:
|
||||
return True # no restriction → cross-platform
|
||||
return bool(set(plats) & set(_current_platform_tags()))
|
||||
|
||||
|
||||
def _is_cached(repo_id: str) -> bool:
|
||||
"""Best-effort check: does HF have this repo in its cache on disk?
|
||||
We don't validate the specific file set — presence of the repo dir is
|
||||
@@ -311,11 +353,13 @@ def list_models():
|
||||
"installed": cached is not None and cached["size_on_disk"] > 0,
|
||||
"size_on_disk_bytes": cached["size_on_disk"] if cached else 0,
|
||||
"nb_files": cached["nb_files"] if cached else 0,
|
||||
"supported": _model_supported(m),
|
||||
})
|
||||
return {
|
||||
"models": out,
|
||||
"total_installed_bytes": sum(m["size_on_disk_bytes"] for m in out),
|
||||
"hf_cache_dir": _hf_cache_dir(),
|
||||
"platform_tags": _current_platform_tags(),
|
||||
}
|
||||
|
||||
|
||||
@@ -342,13 +386,77 @@ async def install_model(req: InstallModelRequest):
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _do():
|
||||
token = hf_progress.current_repo_id.set(req.repo_id)
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
"filename": req.repo_id,
|
||||
"downloaded": 0, "total": 0, "pct": 0.0,
|
||||
"phase": "install_start",
|
||||
})
|
||||
try:
|
||||
from huggingface_hub import snapshot_download
|
||||
from huggingface_hub.utils import (
|
||||
HfHubHTTPError,
|
||||
LocalEntryNotFoundError,
|
||||
)
|
||||
logger.info("model install starting: %s", req.repo_id)
|
||||
snapshot_download(repo_id=req.repo_id)
|
||||
# On Windows, NTFS symlinks require Developer Mode or Admin —
|
||||
# most first-run installs don't have either. The global env var
|
||||
# HF_HUB_DISABLE_SYMLINKS=1 (set in main.py) covers implicit
|
||||
# downloads, but we also pass the kwarg here as a belt-and-braces
|
||||
# guard for older huggingface_hub versions that don't read the var.
|
||||
dl_kwargs: dict = {"repo_id": req.repo_id}
|
||||
if sys.platform == "win32":
|
||||
dl_kwargs["local_dir_use_symlinks"] = False
|
||||
|
||||
# Resume on transient network failures. snapshot_download writes
|
||||
# `.incomplete` shards into the HF cache and resumes from them on
|
||||
# the next call automatically — re-invoking with the same args
|
||||
# picks up where it left off, so each retry only re-fetches what's
|
||||
# missing.
|
||||
_max_attempts = 5
|
||||
_attempt = 0
|
||||
while True:
|
||||
_attempt += 1
|
||||
try:
|
||||
snapshot_download(**dl_kwargs)
|
||||
break
|
||||
except (HfHubHTTPError, LocalEntryNotFoundError, OSError) as net_err:
|
||||
if _attempt >= _max_attempts:
|
||||
raise
|
||||
_backoff = min(30, 2 ** _attempt)
|
||||
logger.warning(
|
||||
"model install %s: attempt %d/%d failed (%s); retry in %ds",
|
||||
req.repo_id, _attempt, _max_attempts, net_err, _backoff,
|
||||
)
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
"filename": req.repo_id,
|
||||
"downloaded": 0, "total": 0, "pct": 0.0,
|
||||
"phase": "install_retry",
|
||||
"attempt": _attempt,
|
||||
"error": str(net_err),
|
||||
})
|
||||
import time as _t
|
||||
_t.sleep(_backoff)
|
||||
logger.info("model install done: %s", req.repo_id)
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
"filename": req.repo_id,
|
||||
"downloaded": 0, "total": 0, "pct": 1.0,
|
||||
"phase": "install_done",
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning("model install failed for %s: %s", req.repo_id, e)
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
"filename": req.repo_id,
|
||||
"downloaded": 0, "total": 0, "pct": 0.0,
|
||||
"phase": "install_error",
|
||||
"error": str(e),
|
||||
})
|
||||
finally:
|
||||
hf_progress.current_repo_id.reset(token)
|
||||
|
||||
# Non-blocking — client polls /models or listens on the SSE.
|
||||
loop.create_task(asyncio.to_thread(_do))
|
||||
@@ -359,6 +467,12 @@ async def install_model(req: InstallModelRequest):
|
||||
def delete_model(repo_id: str):
|
||||
"""Remove every cached revision of a repo from the HF cache. Frees disk
|
||||
+ lets the user re-install a fresh copy via POST /models/install."""
|
||||
hf_progress.emit({
|
||||
"repo_id": repo_id,
|
||||
"filename": repo_id,
|
||||
"downloaded": 0, "total": 0, "pct": 0.0,
|
||||
"phase": "delete_start",
|
||||
})
|
||||
try:
|
||||
from huggingface_hub import scan_cache_dir
|
||||
info = scan_cache_dir()
|
||||
@@ -377,6 +491,13 @@ def delete_model(repo_id: str):
|
||||
)
|
||||
strategy = info.delete_revisions(*commits)
|
||||
strategy.execute()
|
||||
hf_progress.emit({
|
||||
"repo_id": repo_id,
|
||||
"filename": repo_id,
|
||||
"downloaded": 0, "total": 0, "pct": 1.0,
|
||||
"phase": "delete_done",
|
||||
"freed_bytes": strategy.expected_freed_size,
|
||||
})
|
||||
return {
|
||||
"deleted": True,
|
||||
"repo_id": repo_id,
|
||||
@@ -666,6 +787,20 @@ def preflight():
|
||||
"Install system ffmpeg (includes ffprobe) to enable it.",
|
||||
})
|
||||
|
||||
# ── yt-dlp (warn — gallery needs it)
|
||||
yt_dlp_path = _shutil.which("yt-dlp")
|
||||
if yt_dlp_path:
|
||||
checks.append({
|
||||
"id": "yt-dlp", "label": "yt-dlp", "status": "pass",
|
||||
"detail": yt_dlp_path, "fix": None,
|
||||
})
|
||||
else:
|
||||
checks.append({
|
||||
"id": "yt-dlp", "label": "yt-dlp", "status": "warn",
|
||||
"detail": "Not found in system PATH.",
|
||||
"fix": "YouTube clip downloads in Voice Gallery will fail. Download the standalone binary from https://github.com/yt-dlp/yt-dlp/releases and place it in your PATH.",
|
||||
})
|
||||
|
||||
# ── GPU + compute backend
|
||||
gpu = _detect_gpu()
|
||||
if gpu["vendor"] == "apple" and gpu["available"]:
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Setup package — modular replacement for the monolithic ``setup.py``.
|
||||
|
||||
Re-exports a single ``router`` that includes all three sub-routers so
|
||||
``main.py`` can continue doing ``from api.routers import setup`` and
|
||||
``app.include_router(setup.router)`` without changes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .models import router as _models_router
|
||||
from .wizard import router as _wizard_router
|
||||
from .download import router as _download_router
|
||||
|
||||
# Re-export commonly used symbols for backward compatibility.
|
||||
from .models import KNOWN_MODELS, REQUIRED_MODELS, hf_cache_dir, is_cached # noqa: F401
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(_models_router)
|
||||
router.include_router(_wizard_router)
|
||||
router.include_router(_download_router)
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Model download and deletion endpoints.
|
||||
|
||||
Extracted from the monolithic ``setup.py``.
|
||||
|
||||
- ``GET /setup/download-stream`` — SSE for HF tqdm progress
|
||||
- ``POST /models/install`` — start background model download
|
||||
- ``DELETE /models/{repo_id}`` — remove cached model from disk
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from utils import hf_progress
|
||||
from .models import KNOWN_MODELS, invalidate_cache
|
||||
|
||||
logger = logging.getLogger("omnivoice.setup.download")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── SSE Download Stream ───────────────────────────────────────────────────
|
||||
|
||||
def _safe_put(queue: asyncio.Queue, event) -> None:
|
||||
"""Non-blocking enqueue — drop oldest on overflow rather than block."""
|
||||
try:
|
||||
queue.put_nowait(event)
|
||||
except asyncio.QueueFull:
|
||||
try:
|
||||
queue.get_nowait()
|
||||
queue.put_nowait(event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.get("/setup/download-stream")
|
||||
async def setup_download_stream():
|
||||
"""SSE: forward every HuggingFace download tqdm update as a JSON event."""
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=512)
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def listener(event):
|
||||
try:
|
||||
loop.call_soon_threadsafe(_safe_put, queue, event)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
listener_id = hf_progress.register_listener(listener)
|
||||
|
||||
async def gen():
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
event = await asyncio.wait_for(queue.get(), timeout=30.0)
|
||||
except asyncio.TimeoutError:
|
||||
yield ": keepalive\n\n"
|
||||
continue
|
||||
yield f"data: {json.dumps(event)}\n\n"
|
||||
finally:
|
||||
hf_progress.unregister_listener(listener_id)
|
||||
|
||||
return StreamingResponse(
|
||||
gen(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── Install ────────────────────────────────────────────────────────────────
|
||||
|
||||
class InstallModelRequest(BaseModel):
|
||||
repo_id: str
|
||||
|
||||
|
||||
@router.post("/models/install")
|
||||
async def install_model(req: InstallModelRequest):
|
||||
"""Download one HF repo snapshot; progress goes through the shared
|
||||
``/setup/download-stream`` SSE feed."""
|
||||
if req.repo_id not in [m["repo_id"] for m in KNOWN_MODELS]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"Unknown model: {req.repo_id!r}. Known: "
|
||||
+ ", ".join(m["repo_id"] for m in KNOWN_MODELS)
|
||||
),
|
||||
)
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _do():
|
||||
token = hf_progress.current_repo_id.set(req.repo_id)
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
"filename": req.repo_id,
|
||||
"downloaded": 0, "total": 0, "pct": 0.0,
|
||||
"phase": "install_start",
|
||||
})
|
||||
try:
|
||||
from huggingface_hub import snapshot_download
|
||||
from huggingface_hub.utils import (
|
||||
HfHubHTTPError,
|
||||
LocalEntryNotFoundError,
|
||||
)
|
||||
logger.info("model install starting: %s", req.repo_id)
|
||||
dl_kwargs: dict = {"repo_id": req.repo_id}
|
||||
if sys.platform == "win32":
|
||||
dl_kwargs["local_dir_use_symlinks"] = False
|
||||
|
||||
_max_attempts = 5
|
||||
_attempt = 0
|
||||
while True:
|
||||
_attempt += 1
|
||||
try:
|
||||
snapshot_download(**dl_kwargs)
|
||||
break
|
||||
except (HfHubHTTPError, LocalEntryNotFoundError, OSError) as net_err:
|
||||
if _attempt >= _max_attempts:
|
||||
raise
|
||||
_backoff = min(30, 2 ** _attempt)
|
||||
logger.warning(
|
||||
"model install %s: attempt %d/%d failed (%s); retry in %ds",
|
||||
req.repo_id, _attempt, _max_attempts, net_err, _backoff,
|
||||
)
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
"filename": req.repo_id,
|
||||
"downloaded": 0, "total": 0, "pct": 0.0,
|
||||
"phase": "install_retry",
|
||||
"attempt": _attempt,
|
||||
"error": str(net_err),
|
||||
})
|
||||
import time as _t
|
||||
_t.sleep(_backoff)
|
||||
logger.info("model install done: %s", req.repo_id)
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
"filename": req.repo_id,
|
||||
"downloaded": 0, "total": 0, "pct": 1.0,
|
||||
"phase": "install_done",
|
||||
})
|
||||
invalidate_cache()
|
||||
except Exception as e:
|
||||
logger.warning("model install failed for %s: %s", req.repo_id, e)
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
"filename": req.repo_id,
|
||||
"downloaded": 0, "total": 0, "pct": 0.0,
|
||||
"phase": "install_error",
|
||||
"error": str(e),
|
||||
})
|
||||
finally:
|
||||
hf_progress.current_repo_id.reset(token)
|
||||
|
||||
loop.create_task(asyncio.to_thread(_do))
|
||||
return {"status": "install_started", "repo_id": req.repo_id}
|
||||
|
||||
|
||||
# ── Delete ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.delete("/models/{repo_id:path}")
|
||||
def delete_model(repo_id: str):
|
||||
"""Remove every cached revision of a repo from the HF cache."""
|
||||
hf_progress.emit({
|
||||
"repo_id": repo_id,
|
||||
"filename": repo_id,
|
||||
"downloaded": 0, "total": 0, "pct": 0.0,
|
||||
"phase": "delete_start",
|
||||
})
|
||||
try:
|
||||
from huggingface_hub import scan_cache_dir
|
||||
info = scan_cache_dir()
|
||||
commits = [
|
||||
rev.commit_hash
|
||||
for entry in info.repos if entry.repo_id == repo_id
|
||||
for rev in entry.revisions
|
||||
]
|
||||
if not commits:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=(
|
||||
f"Model {repo_id!r} isn't installed. Nothing to delete — "
|
||||
"run POST /models/install first if you want a fresh download."
|
||||
),
|
||||
)
|
||||
strategy = info.delete_revisions(*commits)
|
||||
strategy.execute()
|
||||
hf_progress.emit({
|
||||
"repo_id": repo_id,
|
||||
"filename": repo_id,
|
||||
"downloaded": 0, "total": 0, "pct": 1.0,
|
||||
"phase": "delete_done",
|
||||
"freed_bytes": strategy.expected_freed_size,
|
||||
})
|
||||
invalidate_cache()
|
||||
return {
|
||||
"deleted": True,
|
||||
"repo_id": repo_id,
|
||||
"freed_bytes": strategy.expected_freed_size,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
f"Could not delete {repo_id}: {e}. "
|
||||
"Close any process using the model (e.g. the app's main dub job) and retry."
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Model catalog, platform detection, and cache introspection.
|
||||
|
||||
Extracted from the monolithic ``setup.py`` to keep concerns separate:
|
||||
- ``KNOWN_MODELS`` loaded from ``config/models.yaml``
|
||||
- ``GET /models`` endpoint (with 10 s response cache)
|
||||
- ``GET /setup/recommendations`` device-aware preset endpoint
|
||||
- ``ModelCatalog`` dependency for use with ``Depends()``
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform as _platform
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
logger = logging.getLogger("omnivoice.setup.models")
|
||||
router = APIRouter()
|
||||
|
||||
# ── Model Catalog (loaded from YAML) ──────────────────────────────────────
|
||||
|
||||
_YAML_PATH = Path(__file__).resolve().parents[3] / "config" / "models.yaml"
|
||||
|
||||
|
||||
def _load_models_from_yaml() -> list[dict]:
|
||||
"""Load model catalog from config/models.yaml.
|
||||
|
||||
Falls back to an empty list if the file is missing or unreadable.
|
||||
The YAML file is read once at import time — restart to pick up edits.
|
||||
"""
|
||||
try:
|
||||
import yaml # PyYAML is already a transitive dep of huggingface_hub
|
||||
with open(_YAML_PATH, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
models = data.get("models", [])
|
||||
logger.info("Loaded %d models from %s", len(models), _YAML_PATH)
|
||||
return models
|
||||
except FileNotFoundError:
|
||||
logger.warning("models.yaml not found at %s — using empty catalog", _YAML_PATH)
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error("Failed to load models.yaml: %s — using empty catalog", e)
|
||||
return []
|
||||
|
||||
|
||||
KNOWN_MODELS = _load_models_from_yaml()
|
||||
|
||||
# Back-compat tuple view for code that expects (repo_id, label) pairs.
|
||||
REQUIRED_MODELS = [(m["repo_id"], m["label"]) for m in KNOWN_MODELS if m.get("required")]
|
||||
|
||||
|
||||
# ── Dependency Injection ───────────────────────────────────────────────────
|
||||
# Use `catalog: ModelCatalog = Depends(get_model_catalog)` in endpoint params
|
||||
# for testable, mockable access to the model registry.
|
||||
|
||||
class ModelCatalog:
|
||||
"""Injectable service wrapping the model catalog + cache scanner."""
|
||||
|
||||
def __init__(self, models: list[dict] | None = None):
|
||||
self.models = models if models is not None else KNOWN_MODELS
|
||||
self._by_id = {m["repo_id"]: m for m in self.models}
|
||||
self._required = [(m["repo_id"], m["label"]) for m in self.models if m.get("required")]
|
||||
|
||||
def get(self, repo_id: str) -> dict | None:
|
||||
return self._by_id.get(repo_id)
|
||||
|
||||
@property
|
||||
def required(self) -> list[tuple[str, str]]:
|
||||
return self._required
|
||||
|
||||
@property
|
||||
def all(self) -> list[dict]:
|
||||
return self.models
|
||||
|
||||
def supported_on_host(self, model: dict) -> bool:
|
||||
return _model_supported(model)
|
||||
|
||||
|
||||
# Singleton — shared across all requests.
|
||||
_catalog = ModelCatalog()
|
||||
|
||||
|
||||
def get_model_catalog() -> ModelCatalog:
|
||||
"""FastAPI dependency — inject with ``Depends(get_model_catalog)``."""
|
||||
return _catalog
|
||||
|
||||
|
||||
# ── Platform Detection ─────────────────────────────────────────────────────
|
||||
|
||||
def _current_platform_tags() -> list[str]:
|
||||
"""Return platform tags that the current host supports."""
|
||||
tags = [sys.platform]
|
||||
arch = _platform.machine()
|
||||
tags.append(f"{sys.platform}-{arch}")
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
tags.append("cuda")
|
||||
except Exception:
|
||||
pass
|
||||
return tags
|
||||
|
||||
|
||||
def _model_supported(model: dict) -> bool:
|
||||
"""Check if a model is supported on the current platform."""
|
||||
plats = model.get("platforms")
|
||||
if not plats:
|
||||
return True
|
||||
return bool(set(plats) & set(_current_platform_tags()))
|
||||
|
||||
|
||||
# ── HF Cache Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
def hf_cache_dir() -> str:
|
||||
return (
|
||||
os.environ.get("HF_HUB_CACHE")
|
||||
or os.environ.get("HUGGINGFACE_HUB_CACHE")
|
||||
or os.environ.get("HF_HOME")
|
||||
or os.path.expanduser("~/.cache/huggingface")
|
||||
)
|
||||
|
||||
|
||||
def is_cached(repo_id: str) -> bool:
|
||||
"""Best-effort check: does HF have this repo in its cache on disk?"""
|
||||
try:
|
||||
from huggingface_hub import scan_cache_dir
|
||||
info = scan_cache_dir()
|
||||
for entry in info.repos:
|
||||
if entry.repo_id == repo_id and entry.size_on_disk > 0:
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.debug("scan_cache_dir failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
# ── Response Cache ─────────────────────────────────────────────────────────
|
||||
# Simple TTL dict cache to avoid re-scanning the HF cache directory on every
|
||||
# frontend poll. Entries expire after ``_CACHE_TTL`` seconds.
|
||||
|
||||
_CACHE_TTL = 10.0 # seconds
|
||||
_cache: dict[str, tuple[float, object]] = {}
|
||||
|
||||
|
||||
def _cached(key: str, ttl: float = _CACHE_TTL):
|
||||
"""Return cached value if still valid, else None."""
|
||||
entry = _cache.get(key)
|
||||
if entry and (time.monotonic() - entry[0]) < ttl:
|
||||
return entry[1]
|
||||
return None
|
||||
|
||||
|
||||
def _set_cache(key: str, value: object) -> None:
|
||||
_cache[key] = (time.monotonic(), value)
|
||||
|
||||
|
||||
def invalidate_cache() -> None:
|
||||
"""Called after install/delete to bust the models cache."""
|
||||
_cache.clear()
|
||||
|
||||
|
||||
# ── Endpoints ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/models")
|
||||
def list_models():
|
||||
"""Catalogue every known model + its on-disk install state.
|
||||
|
||||
Uses a 10 s response cache to avoid repeated ``scan_cache_dir()`` disk
|
||||
walks when the frontend polls.
|
||||
"""
|
||||
cached_response = _cached("models")
|
||||
if cached_response is not None:
|
||||
return cached_response
|
||||
|
||||
cached_by_repo: dict[str, dict] = {}
|
||||
try:
|
||||
from huggingface_hub import scan_cache_dir
|
||||
info = scan_cache_dir()
|
||||
for entry in info.repos:
|
||||
cached_by_repo[entry.repo_id] = {
|
||||
"size_on_disk": entry.size_on_disk,
|
||||
"last_accessed": entry.last_accessed,
|
||||
"nb_files": entry.nb_files,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("scan_cache_dir failed: %s", e)
|
||||
|
||||
out = []
|
||||
for m in KNOWN_MODELS:
|
||||
cached = cached_by_repo.get(m["repo_id"])
|
||||
out.append({
|
||||
**m,
|
||||
"installed": cached is not None and cached["size_on_disk"] > 0,
|
||||
"size_on_disk_bytes": cached["size_on_disk"] if cached else 0,
|
||||
"nb_files": cached["nb_files"] if cached else 0,
|
||||
"supported": _model_supported(m),
|
||||
})
|
||||
response = {
|
||||
"models": out,
|
||||
"total_installed_bytes": sum(m["size_on_disk_bytes"] for m in out),
|
||||
"hf_cache_dir": hf_cache_dir(),
|
||||
"platform_tags": _current_platform_tags(),
|
||||
}
|
||||
_set_cache("models", response)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/setup/recommendations")
|
||||
def recommendations():
|
||||
"""Return a curated model preset for the caller's device + architecture."""
|
||||
is_mac_arm = sys.platform == "darwin" and _platform.machine() == "arm64"
|
||||
is_mac_intel = sys.platform == "darwin" and _platform.machine() == "x86_64"
|
||||
is_linux = sys.platform.startswith("linux")
|
||||
is_windows = sys.platform == "win32"
|
||||
|
||||
has_cuda = False
|
||||
try:
|
||||
import torch
|
||||
has_cuda = bool(torch.cuda.is_available())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Device label — used as the card title.
|
||||
if is_mac_arm:
|
||||
device_label = f"Apple Silicon ({_platform.machine()})"
|
||||
elif is_mac_intel:
|
||||
device_label = "macOS Intel (x86_64)"
|
||||
elif is_windows:
|
||||
device_label = "Windows x64" + (" + CUDA" if has_cuda else "")
|
||||
elif is_linux:
|
||||
device_label = "Linux x64" + (" + CUDA" if has_cuda else "")
|
||||
else:
|
||||
device_label = f"{sys.platform} / {_platform.machine()}"
|
||||
|
||||
# Pick the preset for this device.
|
||||
if is_mac_arm:
|
||||
recommended_ids = [
|
||||
"k2-fsa/OmniVoice",
|
||||
"Systran/faster-whisper-large-v3",
|
||||
"mlx-community/whisper-large-v3-mlx",
|
||||
"mlx-community/Kokoro-82M-bf16",
|
||||
"KittenML/kitten-tts-mini-0.8",
|
||||
]
|
||||
rationale = (
|
||||
"Apple Silicon gets the full stack: OmniVoice for multilingual clone + "
|
||||
"WhisperX (faster-whisper weights) for cross-platform ASR + MLX-Whisper "
|
||||
"for the Apple-optimised speedup + Kokoro (mlx-audio) for fast local "
|
||||
"English + KittenTTS as a CPU-realtime backup."
|
||||
)
|
||||
else:
|
||||
recommended_ids = [
|
||||
"k2-fsa/OmniVoice",
|
||||
"Systran/faster-whisper-large-v3",
|
||||
"KittenML/kitten-tts-mini-0.8",
|
||||
]
|
||||
if has_cuda:
|
||||
recommended_ids.append("openai/whisper-large-v3")
|
||||
rationale = (
|
||||
"Cross-platform stack + pytorch-whisper as a CUDA-accelerated "
|
||||
"ASR fallback. MLX / mlx-audio are Apple-Silicon-only and don't "
|
||||
"apply here."
|
||||
)
|
||||
else:
|
||||
rationale = (
|
||||
"Cross-platform stack: OmniVoice (multilingual clone) + WhisperX "
|
||||
"(faster-whisper ASR) + KittenTTS (English turbo, CPU-realtime). "
|
||||
"Clean install, every model runs on CPU."
|
||||
)
|
||||
|
||||
known_by_id = {m["repo_id"]: m for m in KNOWN_MODELS}
|
||||
cached_ids: set[str] = set()
|
||||
try:
|
||||
from huggingface_hub import scan_cache_dir
|
||||
info = scan_cache_dir()
|
||||
cached_ids = {
|
||||
entry.repo_id for entry in info.repos if entry.size_on_disk > 0
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
entries = []
|
||||
for rid in recommended_ids:
|
||||
meta = known_by_id.get(rid, {})
|
||||
entries.append({
|
||||
"repo_id": rid,
|
||||
"label": meta.get("label", rid),
|
||||
"role": meta.get("role", ""),
|
||||
"size_gb": meta.get("size_gb", 0),
|
||||
"required": bool(meta.get("required", False)),
|
||||
"note": meta.get("note"),
|
||||
"installed": rid in cached_ids,
|
||||
})
|
||||
|
||||
to_download_gb = sum(e["size_gb"] for e in entries if not e["installed"])
|
||||
all_installed = all(e["installed"] for e in entries)
|
||||
|
||||
return {
|
||||
"device": {
|
||||
"os": sys.platform,
|
||||
"arch": _platform.machine(),
|
||||
"is_mac_arm": is_mac_arm,
|
||||
"is_mac_intel": is_mac_intel,
|
||||
"is_linux": is_linux,
|
||||
"is_windows": is_windows,
|
||||
"has_cuda": has_cuda,
|
||||
"label": device_label,
|
||||
},
|
||||
"rationale": rationale,
|
||||
"models": entries,
|
||||
"download_gb_remaining": round(to_download_gb, 2),
|
||||
"total_gb": round(sum(e["size_gb"] for e in entries), 2),
|
||||
"all_installed": all_installed,
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
"""First-run wizard endpoints — status, preflight, and warmup.
|
||||
|
||||
Extracted from the monolithic ``setup.py``.
|
||||
|
||||
- ``GET /setup/status`` — missing-model gate for boot screen
|
||||
- ``GET /setup/preflight`` — system health check (OS, RAM, GPU, ffmpeg…)
|
||||
- ``POST /setup/warmup`` — background model pre-load
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import platform as _platform
|
||||
import shutil as _shutil
|
||||
import sys
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.schemas import SetupStatusResponse, PreflightResponse
|
||||
from .models import REQUIRED_MODELS, hf_cache_dir, is_cached
|
||||
|
||||
logger = logging.getLogger("omnivoice.setup.wizard")
|
||||
router = APIRouter()
|
||||
|
||||
MIN_FREE_GB = 10
|
||||
|
||||
|
||||
def _disk_free_gb(path: str) -> float:
|
||||
try:
|
||||
return _shutil.disk_usage(path).free / (1024 ** 3)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
# ── Setup Status ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/setup/status", response_model=SetupStatusResponse)
|
||||
def setup_status():
|
||||
"""Snapshot the setup state so the client can pick its boot screen."""
|
||||
missing = [
|
||||
{"repo_id": rid, "label": label}
|
||||
for (rid, label) in REQUIRED_MODELS
|
||||
if not is_cached(rid)
|
||||
]
|
||||
cache = hf_cache_dir()
|
||||
free_gb = _disk_free_gb(cache)
|
||||
return {
|
||||
"models_ready": len(missing) == 0,
|
||||
"missing": missing,
|
||||
"hf_cache_dir": cache,
|
||||
"disk_free_gb": round(free_gb, 2),
|
||||
"min_free_gb": MIN_FREE_GB,
|
||||
"enough_disk": free_gb >= MIN_FREE_GB,
|
||||
}
|
||||
|
||||
|
||||
# ── Pre-flight System Check ───────────────────────────────────────────────
|
||||
|
||||
_MIN_NVIDIA_DRIVER = 555
|
||||
_RAM_FAIL_GB = 8
|
||||
_RAM_WARN_GB = 12
|
||||
|
||||
|
||||
def _run_cmd(args: list[str], timeout: float = 2.0) -> tuple[int, str]:
|
||||
"""Run a subprocess synchronously with a short timeout."""
|
||||
import subprocess
|
||||
try:
|
||||
out = subprocess.run(
|
||||
args, capture_output=True, text=True, timeout=timeout, check=False,
|
||||
)
|
||||
return out.returncode, out.stdout
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||||
return -1, ""
|
||||
|
||||
|
||||
def _detect_gpu() -> dict:
|
||||
"""Best-effort detection of GPU vendor + driver + compute backend."""
|
||||
info = {
|
||||
"vendor": "none", "driver": None, "device_name": None,
|
||||
"backend": "cpu", "available": False, "notes": [],
|
||||
}
|
||||
|
||||
# Apple Silicon → MPS
|
||||
if sys.platform == "darwin" and _platform.machine() == "arm64":
|
||||
info["vendor"] = "apple"
|
||||
info["backend"] = "mps"
|
||||
info["device_name"] = "Apple Silicon GPU (Metal)"
|
||||
try:
|
||||
import torch
|
||||
info["available"] = bool(torch.backends.mps.is_available())
|
||||
except Exception:
|
||||
info["available"] = False
|
||||
return info
|
||||
|
||||
# NVIDIA
|
||||
rc, out = _run_cmd([
|
||||
"nvidia-smi",
|
||||
"--query-gpu=driver_version,name",
|
||||
"--format=csv,noheader",
|
||||
])
|
||||
if rc == 0 and out.strip():
|
||||
line = out.strip().splitlines()[0]
|
||||
parts = [p.strip() for p in line.split(",")]
|
||||
driver = parts[0] if parts else None
|
||||
name = parts[1] if len(parts) > 1 else None
|
||||
info.update({"vendor": "nvidia", "driver": driver, "device_name": name})
|
||||
try:
|
||||
import torch
|
||||
info["available"] = bool(torch.cuda.is_available())
|
||||
info["backend"] = "cuda" if info["available"] else "cpu"
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
major = int((driver or "0").split(".")[0])
|
||||
if major < _MIN_NVIDIA_DRIVER:
|
||||
info["notes"].append(
|
||||
f"NVIDIA driver {driver} below {_MIN_NVIDIA_DRIVER} required "
|
||||
f"by the bundled CUDA 12.8 runtime — GPU will fail to launch "
|
||||
f"kernels. Update drivers before dubbing."
|
||||
)
|
||||
info["available"] = False
|
||||
except Exception:
|
||||
pass
|
||||
return info
|
||||
|
||||
# AMD
|
||||
rc, out = _run_cmd(["rocm-smi", "--showproductname"])
|
||||
if rc == 0 and out.strip():
|
||||
info["vendor"] = "amd"
|
||||
info["device_name"] = out.strip().splitlines()[0][:120]
|
||||
try:
|
||||
import torch
|
||||
has_hip = getattr(torch.version, "hip", None) is not None
|
||||
if has_hip and torch.cuda.is_available():
|
||||
info["backend"] = "rocm"
|
||||
info["available"] = True
|
||||
else:
|
||||
info["backend"] = "cpu"
|
||||
info["notes"].append(
|
||||
"AMD GPU detected but torch was installed with CUDA wheels. "
|
||||
"Re-run `uv sync --index-url https://download.pytorch.org/whl/rocm6.1` "
|
||||
"to enable ROCm acceleration."
|
||||
)
|
||||
except Exception:
|
||||
info["notes"].append("AMD GPU detected but torch not importable.")
|
||||
return info
|
||||
|
||||
# Fallback
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
info["vendor"] = "unknown"
|
||||
info["backend"] = "cuda"
|
||||
info["available"] = True
|
||||
info["notes"].append(
|
||||
"torch.cuda.is_available() is True but no nvidia-smi/rocm-smi "
|
||||
"found — running through WSL or virtual GPU?"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return info
|
||||
|
||||
|
||||
def _probe_network(host: str = "huggingface.co", timeout: float = 2.0) -> bool:
|
||||
"""Tiny TCP connect test."""
|
||||
import socket
|
||||
try:
|
||||
with socket.create_connection((host, 443), timeout=timeout):
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _ram_gb() -> float:
|
||||
try:
|
||||
import psutil
|
||||
return psutil.virtual_memory().total / (1024 ** 3)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
@router.get("/setup/preflight", response_model=PreflightResponse)
|
||||
def preflight():
|
||||
"""One-shot system health check for the wizard."""
|
||||
checks: list[dict] = []
|
||||
|
||||
# ── OS + arch
|
||||
arch = _platform.machine()
|
||||
os_ver = _platform.platform(terse=True)
|
||||
checks.append({
|
||||
"id": "os", "label": "Operating system", "status": "pass",
|
||||
"detail": f"{os_ver} ({arch})", "fix": None,
|
||||
})
|
||||
|
||||
# ── Python runtime
|
||||
checks.append({
|
||||
"id": "python", "label": "Python runtime", "status": "pass",
|
||||
"detail": f"Python {sys.version.split()[0]}", "fix": None,
|
||||
})
|
||||
|
||||
# ── RAM
|
||||
ram = _ram_gb()
|
||||
if ram == 0:
|
||||
ram_status, ram_detail, ram_fix = (
|
||||
"warn", "Could not detect system RAM.",
|
||||
"Install psutil in the backend environment or ignore this warning.",
|
||||
)
|
||||
elif ram < _RAM_FAIL_GB:
|
||||
ram_status, ram_detail, ram_fix = (
|
||||
"fail", f"{ram:.1f} GB total (need ≥ {_RAM_FAIL_GB} GB)",
|
||||
"The app will OOM on first dub. Close other apps or upgrade RAM.",
|
||||
)
|
||||
elif ram < _RAM_WARN_GB:
|
||||
ram_status, ram_detail, ram_fix = (
|
||||
"warn", f"{ram:.1f} GB total ({_RAM_WARN_GB}+ GB recommended)",
|
||||
"Long videos may hit swap. Keep other apps closed during dubbing.",
|
||||
)
|
||||
else:
|
||||
ram_status, ram_detail, ram_fix = ("pass", f"{ram:.1f} GB total", None)
|
||||
checks.append({
|
||||
"id": "ram", "label": "System RAM", "status": ram_status,
|
||||
"detail": ram_detail, "fix": ram_fix,
|
||||
})
|
||||
|
||||
# ── Disk free
|
||||
cache = hf_cache_dir()
|
||||
free = _disk_free_gb(cache)
|
||||
if free < MIN_FREE_GB:
|
||||
disk = {
|
||||
"status": "fail",
|
||||
"detail": f"{free:.1f} GB free at {cache} (need ≥ {MIN_FREE_GB} GB)",
|
||||
"fix": f"Free up disk space or set HF_HOME to a larger partition.",
|
||||
}
|
||||
else:
|
||||
disk = {"status": "pass", "detail": f"{free:.1f} GB free at {cache}", "fix": None}
|
||||
checks.append({"id": "disk", **{"label": "Disk space", **disk}})
|
||||
|
||||
# ── HF cache writable
|
||||
try:
|
||||
os.makedirs(cache, exist_ok=True)
|
||||
writable = os.access(cache, os.W_OK)
|
||||
except Exception:
|
||||
writable = False
|
||||
checks.append({
|
||||
"id": "hf_cache_writable", "label": "HuggingFace cache writable",
|
||||
"status": "pass" if writable else "fail",
|
||||
"detail": cache,
|
||||
"fix": None if writable else
|
||||
f"Fix write permissions on {cache} or point HF_HOME elsewhere.",
|
||||
})
|
||||
|
||||
# ── FFmpeg
|
||||
ffmpeg_path = None
|
||||
try:
|
||||
from services.ffmpeg_utils import find_ffmpeg
|
||||
ffmpeg_path = find_ffmpeg()
|
||||
except Exception as e:
|
||||
checks.append({
|
||||
"id": "ffmpeg", "label": "FFmpeg", "status": "fail",
|
||||
"detail": str(e)[:200],
|
||||
"fix": "Install ffmpeg via your package manager "
|
||||
"(brew install ffmpeg / apt install ffmpeg / choco install ffmpeg).",
|
||||
})
|
||||
else:
|
||||
checks.append({
|
||||
"id": "ffmpeg", "label": "FFmpeg", "status": "pass",
|
||||
"detail": ffmpeg_path, "fix": None,
|
||||
})
|
||||
|
||||
# ── FFprobe
|
||||
ffprobe_path = None
|
||||
if ffmpeg_path:
|
||||
candidate = ffmpeg_path.replace("ffmpeg", "ffprobe")
|
||||
if os.path.exists(candidate):
|
||||
ffprobe_path = candidate
|
||||
else:
|
||||
system_probe = _shutil.which("ffprobe")
|
||||
if system_probe:
|
||||
ffprobe_path = system_probe
|
||||
if ffprobe_path:
|
||||
checks.append({
|
||||
"id": "ffprobe", "label": "FFprobe", "status": "pass",
|
||||
"detail": ffprobe_path, "fix": None,
|
||||
})
|
||||
else:
|
||||
checks.append({
|
||||
"id": "ffprobe", "label": "FFprobe", "status": "warn",
|
||||
"detail": "Not bundled alongside ffmpeg.",
|
||||
"fix": "File-probe endpoint (/tools/probe) will 501. "
|
||||
"Install system ffmpeg (includes ffprobe) to enable it.",
|
||||
})
|
||||
|
||||
# ── yt-dlp
|
||||
yt_dlp_path = _shutil.which("yt-dlp")
|
||||
if yt_dlp_path:
|
||||
rc_ytv, yt_ver = _run_cmd([yt_dlp_path, "--version"], timeout=3.0)
|
||||
yt_version = yt_ver.strip() if rc_ytv == 0 else "unknown"
|
||||
checks.append({
|
||||
"id": "yt-dlp", "label": "yt-dlp", "status": "pass",
|
||||
"detail": f"{yt_dlp_path} (v{yt_version})", "fix": None,
|
||||
})
|
||||
else:
|
||||
checks.append({
|
||||
"id": "yt-dlp", "label": "yt-dlp", "status": "warn",
|
||||
"detail": "Not found in system PATH.",
|
||||
"fix": "YouTube clip downloads in Voice Gallery will fail. Download the standalone binary from https://github.com/yt-dlp/yt-dlp/releases and place it in your PATH.",
|
||||
})
|
||||
|
||||
# ── GPU
|
||||
gpu = _detect_gpu()
|
||||
if gpu["vendor"] == "apple" and gpu["available"]:
|
||||
gpu_status, gpu_fix = "pass", None
|
||||
gpu_detail = f"{gpu['device_name']} — Metal (MPS) ready"
|
||||
elif gpu["vendor"] == "nvidia" and gpu["available"]:
|
||||
gpu_status, gpu_fix = "pass", None
|
||||
gpu_detail = f"{gpu['device_name']} (driver {gpu['driver']}) — CUDA ready"
|
||||
elif gpu["vendor"] == "nvidia" and not gpu["available"]:
|
||||
gpu_status = "fail"
|
||||
gpu_detail = (
|
||||
f"{gpu['device_name']} found but CUDA not usable "
|
||||
f"(driver {gpu['driver']}). " + " ".join(gpu["notes"])
|
||||
)
|
||||
gpu_fix = (
|
||||
f"Update NVIDIA drivers to ≥ R{_MIN_NVIDIA_DRIVER} "
|
||||
"(https://www.nvidia.com/Download/index.aspx). Or run CPU-only "
|
||||
"by continuing past this step — dubbing will be ~10× slower."
|
||||
)
|
||||
elif gpu["vendor"] == "amd":
|
||||
gpu_status = "warn"
|
||||
gpu_detail = (
|
||||
f"{gpu['device_name']} — ROCm "
|
||||
+ ("ready" if gpu["available"] else "not configured")
|
||||
)
|
||||
gpu_fix = (
|
||||
None if gpu["available"] else
|
||||
"AMD support is experimental. Re-run `uv sync --index-url "
|
||||
"https://download.pytorch.org/whl/rocm6.1` to enable. App works "
|
||||
"on CPU otherwise (slower)."
|
||||
)
|
||||
else:
|
||||
gpu_status = "warn"
|
||||
gpu_detail = "No compatible GPU detected — running CPU-only."
|
||||
gpu_fix = (
|
||||
"Dubbing will work but ~10× slower than GPU. If you have an "
|
||||
"NVIDIA/AMD card, check drivers are installed."
|
||||
)
|
||||
checks.append({
|
||||
"id": "gpu", "label": "GPU acceleration",
|
||||
"status": gpu_status, "detail": gpu_detail, "fix": gpu_fix,
|
||||
})
|
||||
|
||||
# ── Network
|
||||
net_ok = _probe_network()
|
||||
checks.append({
|
||||
"id": "network", "label": "Network (huggingface.co)",
|
||||
"status": "pass" if net_ok else "fail",
|
||||
"detail": "Reachable" if net_ok else "Unreachable on port 443",
|
||||
"fix": None if net_ok else
|
||||
"Check internet connection, VPN, or corporate firewall "
|
||||
"whitelist for huggingface.co.",
|
||||
})
|
||||
|
||||
# Aggregate
|
||||
any_fail = any(c["status"] == "fail" for c in checks)
|
||||
any_warn = any(c["status"] == "warn" for c in checks)
|
||||
|
||||
return {
|
||||
"ok": not any_fail,
|
||||
"has_warnings": any_warn,
|
||||
"checks": checks,
|
||||
"device": {
|
||||
"os": sys.platform,
|
||||
"arch": arch,
|
||||
"gpu_vendor": gpu["vendor"],
|
||||
"gpu_backend": gpu["backend"],
|
||||
"gpu_available": gpu["available"],
|
||||
"gpu_driver": gpu["driver"],
|
||||
"gpu_device_name": gpu["device_name"],
|
||||
"ram_gb": round(ram, 1),
|
||||
"disk_free_gb": round(free, 1),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Warmup ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/setup/warmup")
|
||||
async def setup_warmup():
|
||||
"""Trigger a model load in the background so the first dub doesn't pay
|
||||
the cold-start tax."""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
async def _do_warmup():
|
||||
try:
|
||||
from services.model_manager import get_model
|
||||
await get_model()
|
||||
except Exception as e:
|
||||
logger.warning("setup/warmup: model load failed: %s", e)
|
||||
|
||||
loop.create_task(_do_warmup())
|
||||
return {"status": "warmup_started"}
|
||||
@@ -4,8 +4,9 @@ import uuid
|
||||
import psutil
|
||||
import asyncio
|
||||
import logging
|
||||
from fastapi import APIRouter, File, UploadFile, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi import APIRouter, File, UploadFile, HTTPException, Query
|
||||
from api.schemas import SysinfoResponse, SystemInfoResponse, ModelStatusResponse, LogsResponse, FlushMemoryResponse
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
import torch
|
||||
import shutil
|
||||
|
||||
@@ -22,28 +23,49 @@ _is_cuda = torch.cuda.is_available()
|
||||
# Prime psutil's internal CPU counter so the first non-blocking call returns useful data
|
||||
psutil.cpu_percent(interval=None)
|
||||
|
||||
@router.get("/model/status")
|
||||
@router.get("/model/status", response_model=ModelStatusResponse)
|
||||
def model_status():
|
||||
"""Report model loading state for frontend warm-up indicators."""
|
||||
return get_model_status()
|
||||
|
||||
|
||||
@router.get("/system/info")
|
||||
@router.get("/system/info", response_model=SystemInfoResponse)
|
||||
def system_info():
|
||||
"""Settings page system info — model, tokens, data dir, timeout."""
|
||||
return {
|
||||
"data_dir": DATA_DIR,
|
||||
"outputs_dir": OUTPUTS_DIR,
|
||||
"crash_log_path": CRASH_LOG_PATH,
|
||||
"idle_timeout_seconds": IDLE_TIMEOUT_SECONDS,
|
||||
"model_checkpoint": os.environ.get("OMNIVOICE_MODEL", "k2-fsa/OmniVoice"),
|
||||
"asr_model": os.environ.get("ASR_MODEL", "Systran/faster-whisper-large-v3"),
|
||||
"translate_provider": os.environ.get("TRANSLATE_PROVIDER", "google"),
|
||||
"has_hf_token": bool(os.environ.get("HF_TOKEN")),
|
||||
"device": get_best_device(),
|
||||
"python": sys.version.split()[0],
|
||||
"platform": sys.platform,
|
||||
}
|
||||
"""Settings page system info — model, tokens, data dir, timeout.
|
||||
|
||||
This endpoint MUST never throw — it's called on every Settings page load
|
||||
and a 500 here blocks the entire UI from rendering system details.
|
||||
"""
|
||||
try:
|
||||
return {
|
||||
"data_dir": DATA_DIR,
|
||||
"outputs_dir": OUTPUTS_DIR,
|
||||
"crash_log_path": CRASH_LOG_PATH,
|
||||
"idle_timeout_seconds": IDLE_TIMEOUT_SECONDS,
|
||||
"model_checkpoint": os.environ.get("OMNIVOICE_MODEL", "k2-fsa/OmniVoice"),
|
||||
"asr_model": os.environ.get("ASR_MODEL", "Systran/faster-whisper-large-v3"),
|
||||
"translate_provider": os.environ.get("TRANSLATE_PROVIDER", "google"),
|
||||
"has_hf_token": bool(os.environ.get("HF_TOKEN")),
|
||||
"device": get_best_device(),
|
||||
"python": sys.version.split()[0],
|
||||
"platform": sys.platform,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception("system_info failed — returning safe defaults")
|
||||
return {
|
||||
"data_dir": DATA_DIR,
|
||||
"outputs_dir": OUTPUTS_DIR,
|
||||
"crash_log_path": str(CRASH_LOG_PATH),
|
||||
"idle_timeout_seconds": IDLE_TIMEOUT_SECONDS,
|
||||
"model_checkpoint": "unknown",
|
||||
"asr_model": "unknown",
|
||||
"translate_provider": "unknown",
|
||||
"has_hf_token": False,
|
||||
"device": "cpu",
|
||||
"python": sys.version.split()[0],
|
||||
"platform": sys.platform,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
def _tail_file(path: str, tail: int):
|
||||
@@ -85,7 +107,7 @@ def _tauri_log_candidates():
|
||||
|
||||
|
||||
@router.get("/system/logs")
|
||||
def system_logs(tail: int = 200):
|
||||
async def system_logs(tail: int = 200):
|
||||
"""Tail the rolling runtime log — everything Python logged since last rotation.
|
||||
|
||||
Back-stop: if the rolling log doesn't exist yet (fresh install, disk error),
|
||||
@@ -100,12 +122,9 @@ def system_logs(tail: int = 200):
|
||||
if not os.path.exists(path):
|
||||
return {"lines": [], "path": LOG_PATH, "exists": False}
|
||||
try:
|
||||
lines, total = _tail_file(path, tail)
|
||||
lines, total = await asyncio.to_thread(_tail_file, path, tail)
|
||||
return {"lines": lines, "path": path, "exists": True, "total_lines": total}
|
||||
except Exception as e:
|
||||
# The log file exists but we can't read it — usually a permission
|
||||
# issue or the file got truncated mid-read. Point the user at the
|
||||
# path so they can inspect or delete manually.
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Could not read log at {path}: {e}. Check file permissions or delete it manually.",
|
||||
@@ -113,7 +132,7 @@ def system_logs(tail: int = 200):
|
||||
|
||||
|
||||
@router.get("/system/logs/tauri")
|
||||
def system_logs_tauri(tail: int = 200):
|
||||
async def system_logs_tauri(tail: int = 200):
|
||||
"""Tail the Tauri plugin log (or backend stdout redirect, whichever exists)."""
|
||||
try:
|
||||
tail = max(10, min(2000, int(tail)))
|
||||
@@ -123,22 +142,87 @@ def system_logs_tauri(tail: int = 200):
|
||||
for p in candidates:
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
lines, total = _tail_file(p, tail)
|
||||
lines, total = await asyncio.to_thread(_tail_file, p, tail)
|
||||
return {"lines": lines, "path": p, "exists": True, "total_lines": total}
|
||||
except Exception as e:
|
||||
return {"lines": [], "path": p, "exists": True, "error": str(e)}
|
||||
return {"lines": [], "path": None, "exists": False, "candidates": candidates}
|
||||
|
||||
|
||||
@router.get("/system/logs/stream")
|
||||
async def stream_logs(
|
||||
source: str = Query("backend", description="'backend' or 'tauri'"),
|
||||
interval: float = Query(1.0, ge=0.3, le=10.0, description="Poll interval in seconds"),
|
||||
):
|
||||
"""Server-Sent Events stream of new log lines.
|
||||
|
||||
The client opens an EventSource connection and receives new lines as they
|
||||
are appended to the log file. This replaces the polling pattern used by
|
||||
the LogsFooter component.
|
||||
|
||||
Usage (frontend)::
|
||||
|
||||
const es = new EventSource('/system/logs/stream?source=backend');
|
||||
es.onmessage = (e) => { const lines = JSON.parse(e.data); ... };
|
||||
"""
|
||||
if source == "tauri":
|
||||
candidates = _tauri_log_candidates()
|
||||
path = next((p for p in candidates if os.path.exists(p)), None)
|
||||
else:
|
||||
path = LOG_PATH if os.path.exists(LOG_PATH) else CRASH_LOG_PATH
|
||||
|
||||
if not path or not os.path.exists(path):
|
||||
raise HTTPException(status_code=404, detail=f"Log file not found for source={source}")
|
||||
|
||||
async def _generate():
|
||||
"""Yield SSE events whenever new lines appear in the log file."""
|
||||
last_pos = 0
|
||||
try:
|
||||
last_pos = os.path.getsize(path)
|
||||
except Exception:
|
||||
pass
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
if size < last_pos:
|
||||
# File was truncated (log rotation or clear) — reset
|
||||
last_pos = 0
|
||||
if size == last_pos:
|
||||
continue
|
||||
new_lines = await asyncio.to_thread(_read_from_pos, path, last_pos)
|
||||
last_pos = size
|
||||
if new_lines:
|
||||
import json
|
||||
yield f"data: {json.dumps(new_lines)}\n\n"
|
||||
except Exception:
|
||||
break
|
||||
|
||||
return StreamingResponse(
|
||||
_generate(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _read_from_pos(path: str, pos: int) -> list[str]:
|
||||
"""Read all lines from `pos` to EOF (runs in threadpool)."""
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
f.seek(pos)
|
||||
return f.readlines()
|
||||
|
||||
|
||||
@router.post("/system/logs/clear")
|
||||
def clear_system_logs():
|
||||
async def clear_system_logs():
|
||||
"""Truncate the rolling runtime log and the crash log (what the Backend tab reads)."""
|
||||
cleared_any = False
|
||||
for p in (LOG_PATH, CRASH_LOG_PATH):
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
with open(p, "w") as f:
|
||||
f.truncate(0)
|
||||
await asyncio.to_thread(_truncate_file, p)
|
||||
cleared_any = True
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
@@ -148,21 +232,26 @@ def clear_system_logs():
|
||||
return {"cleared": cleared_any}
|
||||
|
||||
|
||||
def _truncate_file(path: str):
|
||||
"""Truncate a file to zero length (runs in threadpool)."""
|
||||
with open(path, "w") as f:
|
||||
f.truncate(0)
|
||||
|
||||
|
||||
@router.post("/system/logs/tauri/clear")
|
||||
def clear_tauri_logs():
|
||||
async def clear_tauri_logs():
|
||||
"""Truncate whichever Tauri-side log files we know about. OS-level rotation may recreate them."""
|
||||
cleared = []
|
||||
for p in _tauri_log_candidates():
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
with open(p, "w") as f:
|
||||
f.truncate(0)
|
||||
await asyncio.to_thread(_truncate_file, p)
|
||||
cleared.append(p)
|
||||
except Exception:
|
||||
pass
|
||||
return {"cleared": cleared}
|
||||
|
||||
@router.get("/sysinfo")
|
||||
@router.get("/sysinfo", response_model=SysinfoResponse)
|
||||
def get_sys_info():
|
||||
vram = 0.0
|
||||
gpu_active = False
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Watermark detection API — upload audio, check if it was generated by OmniVoice.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
import logging
|
||||
import torchaudio
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException
|
||||
|
||||
from services.watermark import detect_watermark, is_enabled, _check_available
|
||||
from core.prefs import get as pref_get, set_ as pref_set
|
||||
|
||||
logger = logging.getLogger("omnivoice.watermark_api")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/watermark/detect")
|
||||
async def detect_audio_watermark(file: UploadFile = File(...)):
|
||||
"""
|
||||
Upload an audio file and check whether it contains an OmniVoice watermark.
|
||||
|
||||
Returns confidence score, decoded message, and source attribution.
|
||||
"""
|
||||
if not _check_available():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="AudioSeal is not installed. Run `uv pip install audioseal` to enable watermark detection.",
|
||||
)
|
||||
|
||||
# Accept common audio formats
|
||||
allowed = {".wav", ".mp3", ".flac", ".ogg", ".m4a", ".aac", ".opus"}
|
||||
ext = os.path.splitext(file.filename or "upload.wav")[1].lower()
|
||||
if ext not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unsupported format '{ext}'. Upload one of: {', '.join(sorted(allowed))}",
|
||||
)
|
||||
|
||||
# Write to temp file for torchaudio to load
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp:
|
||||
content = await file.read()
|
||||
tmp.write(content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
waveform, sr = torchaudio.load(tmp_path)
|
||||
result = detect_watermark(waveform, sr)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Watermark detection failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except (OSError, UnboundLocalError):
|
||||
pass
|
||||
|
||||
|
||||
@router.get("/watermark/status")
|
||||
def watermark_status():
|
||||
"""Return current watermark configuration."""
|
||||
return {
|
||||
"invisible_enabled": is_enabled(),
|
||||
"visible_audio_enabled": pref_get("watermark.visible_audio", False),
|
||||
"visible_video_enabled": pref_get("watermark.visible_video", True),
|
||||
"audioseal_available": _check_available(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/watermark/settings")
|
||||
def update_watermark_settings(
|
||||
invisible: bool | None = None,
|
||||
visible_audio: bool | None = None,
|
||||
visible_video: bool | None = None,
|
||||
):
|
||||
"""Update watermark preferences."""
|
||||
if invisible is not None:
|
||||
pref_set("watermark.invisible", invisible)
|
||||
if visible_audio is not None:
|
||||
pref_set("watermark.visible_audio", visible_audio)
|
||||
if visible_video is not None:
|
||||
pref_set("watermark.visible_video", visible_video)
|
||||
|
||||
return {
|
||||
"invisible_enabled": pref_get("watermark.invisible", True),
|
||||
"visible_audio_enabled": pref_get("watermark.visible_audio", False),
|
||||
"visible_video_enabled": pref_get("watermark.visible_video", True),
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Pydantic v2 schemas for request/response validation.
|
||||
|
||||
Shared across routers — import from here rather than defining inline.
|
||||
Using ``model_config = ConfigDict(...)`` for Pydantic v2 compat.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# ── System ────────────────────────────────────────────────────────────────
|
||||
|
||||
class SysinfoResponse(BaseModel):
|
||||
"""GET /sysinfo"""
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
cpu: float = Field(description="CPU usage percentage (0–100)")
|
||||
ram: float = Field(description="Used RAM in GiB")
|
||||
total_ram: float = Field(description="Total RAM in GiB")
|
||||
vram: float = Field(0.0, description="Used VRAM in GiB")
|
||||
gpu_active: bool = Field(False, description="Whether a GPU is actively used")
|
||||
|
||||
|
||||
class SystemInfoResponse(BaseModel):
|
||||
"""GET /system/info"""
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
data_dir: str
|
||||
outputs_dir: str
|
||||
crash_log_path: str
|
||||
idle_timeout_seconds: int
|
||||
model_checkpoint: str = "unknown"
|
||||
asr_model: str = "unknown"
|
||||
translate_provider: str = "unknown"
|
||||
has_hf_token: bool = False
|
||||
device: str = "cpu"
|
||||
python: str = ""
|
||||
platform: str = ""
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class ModelStatusResponse(BaseModel):
|
||||
"""GET /model/status"""
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
status: str = Field(description="idle | loading | ready")
|
||||
checkpoint: str | None = None
|
||||
loaded_at: str | None = None
|
||||
|
||||
|
||||
class LogsResponse(BaseModel):
|
||||
"""GET /system/logs"""
|
||||
lines: list[str] = Field(default_factory=list)
|
||||
path: str = ""
|
||||
exists: bool = False
|
||||
total_lines: int = 0
|
||||
error: str | None = None
|
||||
candidates: list[str] | None = None
|
||||
|
||||
|
||||
class FlushMemoryResponse(BaseModel):
|
||||
"""POST /system/flush-memory"""
|
||||
flushed: bool = True
|
||||
unloaded_model: bool = False
|
||||
ram_after: float = 0.0
|
||||
vram_after: float = 0.0
|
||||
|
||||
|
||||
# ── Setup ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class MissingModel(BaseModel):
|
||||
repo_id: str
|
||||
label: str
|
||||
|
||||
|
||||
class SetupStatusResponse(BaseModel):
|
||||
"""GET /setup/status"""
|
||||
models_ready: bool
|
||||
missing: list[MissingModel] = Field(default_factory=list)
|
||||
hf_cache_dir: str
|
||||
disk_free_gb: float
|
||||
min_free_gb: int = 10
|
||||
enough_disk: bool = True
|
||||
|
||||
|
||||
class PreflightCheck(BaseModel):
|
||||
"""One check in the preflight report."""
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
id: str
|
||||
label: str
|
||||
status: str = Field(description="pass | warn | fail")
|
||||
detail: str = ""
|
||||
fix: str | None = None
|
||||
|
||||
|
||||
class DeviceInfo(BaseModel):
|
||||
"""GPU/system device info from preflight."""
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
os: str
|
||||
arch: str
|
||||
gpu_vendor: str = "none"
|
||||
gpu_backend: str = "cpu"
|
||||
gpu_available: bool = False
|
||||
gpu_driver: str | None = None
|
||||
gpu_device_name: str | None = None
|
||||
ram_gb: float = 0.0
|
||||
disk_free_gb: float = 0.0
|
||||
|
||||
|
||||
class PreflightResponse(BaseModel):
|
||||
"""GET /setup/preflight"""
|
||||
ok: bool
|
||||
has_warnings: bool = False
|
||||
checks: list[PreflightCheck] = Field(default_factory=list)
|
||||
device: DeviceInfo
|
||||
|
||||
|
||||
class InstallModelRequest(BaseModel):
|
||||
"""POST /models/install"""
|
||||
repo_id: str
|
||||
|
||||
|
||||
class DeleteModelResponse(BaseModel):
|
||||
"""DELETE /models/{repo_id}"""
|
||||
deleted: bool = True
|
||||
repo_id: str
|
||||
freed_bytes: int = 0
|
||||
|
||||
|
||||
# ── Models list ───────────────────────────────────────────────────────────
|
||||
|
||||
class ModelEntry(BaseModel):
|
||||
"""One model in the GET /models response."""
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
repo_id: str
|
||||
label: str
|
||||
role: str
|
||||
size: str = ""
|
||||
required: bool = False
|
||||
installed: bool = False
|
||||
supported: bool = True
|
||||
size_on_disk: int | None = None
|
||||
nb_files: int | None = None
|
||||
@@ -0,0 +1,123 @@
|
||||
# ── OmniVoice Studio — Model Catalog ─────────────────────────────────────
|
||||
#
|
||||
# This file is the source of truth for all known HuggingFace models.
|
||||
# The backend loads it at startup via `load_model_catalog()`.
|
||||
#
|
||||
# To add a model: append an entry with the fields below.
|
||||
# To remove: delete the entry. The UI will stop showing it immediately.
|
||||
#
|
||||
# Fields:
|
||||
# repo_id (required) — HuggingFace repository ID
|
||||
# label (required) — Human-readable display name
|
||||
# role (required) — TTS | ASR | Diarisation
|
||||
# size_gb (required) — Approximate download size in GiB
|
||||
# required (optional) — true if the app needs this model to function
|
||||
# platforms (optional) — restrict to specific OS+arch tags (e.g. darwin-arm64, cuda)
|
||||
# note (optional) — shown in the UI as a tooltip/footnote
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
models:
|
||||
# ── Required ──────────────────────────────────────────────────────────
|
||||
|
||||
- repo_id: "k2-fsa/OmniVoice"
|
||||
label: "OmniVoice TTS (600+ languages, zero-shot)"
|
||||
role: TTS
|
||||
size_gb: 2.4
|
||||
required: true
|
||||
|
||||
- repo_id: "Systran/faster-whisper-large-v3"
|
||||
label: "Whisper large-v3 (faster-whisper — default, cross-platform)"
|
||||
role: ASR
|
||||
size_gb: 2.9
|
||||
required: true
|
||||
|
||||
# ── Optional ASR ──────────────────────────────────────────────────────
|
||||
|
||||
- repo_id: "mlx-community/whisper-large-v3-mlx"
|
||||
label: "Whisper large-v3 (MLX — optional mac-ARM speedup)"
|
||||
role: ASR
|
||||
size_gb: 3.0
|
||||
platforms: [darwin-arm64]
|
||||
|
||||
- repo_id: "openai/whisper-large-v3"
|
||||
label: "Whisper large-v3 (PyTorch — last-resort fallback)"
|
||||
role: ASR
|
||||
size_gb: 3.1
|
||||
platforms: [cuda]
|
||||
|
||||
- repo_id: "mlx-community/whisper-tiny-mlx"
|
||||
label: "Whisper tiny (MLX ASR — fast fallback)"
|
||||
role: ASR
|
||||
size_gb: 0.08
|
||||
platforms: [darwin-arm64]
|
||||
|
||||
# ── Diarisation ───────────────────────────────────────────────────────
|
||||
|
||||
- repo_id: "pyannote/speaker-diarization-3.1"
|
||||
label: "pyannote speaker diarisation (multi-speaker videos)"
|
||||
role: Diarisation
|
||||
size_gb: 0.8
|
||||
note: "Needs an HF_TOKEN with license accepted."
|
||||
|
||||
# ── Optional TTS ──────────────────────────────────────────────────────
|
||||
|
||||
- repo_id: "OpenMOSS-Team/MOSS-TTS-Nano-100M"
|
||||
label: "MOSS-TTS-Nano 100M (20 langs, CPU-realtime)"
|
||||
role: TTS
|
||||
size_gb: 0.4
|
||||
|
||||
- repo_id: "KittenML/kitten-tts-mini-0.8"
|
||||
label: "KittenTTS (English, 8 preset voices, CPU realtime)"
|
||||
role: TTS
|
||||
size_gb: 0.08
|
||||
|
||||
# ── mlx-audio engines (Apple Silicon only) ────────────────────────────
|
||||
|
||||
- repo_id: "mlx-community/Kokoro-82M-bf16"
|
||||
label: "Kokoro 82M (8 langs, small, mlx-audio default)"
|
||||
role: TTS
|
||||
size_gb: 0.15
|
||||
note: "Apple Silicon only — via mlx-audio backend."
|
||||
platforms: [darwin-arm64]
|
||||
|
||||
- repo_id: "mlx-community/csm-1b-8bit"
|
||||
label: "CSM 1B (voice cloning, mlx-audio)"
|
||||
role: TTS
|
||||
size_gb: 1.1
|
||||
note: "Apple Silicon only — via mlx-audio backend."
|
||||
platforms: [darwin-arm64]
|
||||
|
||||
- repo_id: "mlx-community/Qwen3-TTS-12Hz-1.7B-VoiceDesign-4bit"
|
||||
label: "Qwen3-TTS 1.7B 4bit (voice design, mlx-audio)"
|
||||
role: TTS
|
||||
size_gb: 1.4
|
||||
note: "Apple Silicon only — via mlx-audio backend."
|
||||
platforms: [darwin-arm64]
|
||||
|
||||
- repo_id: "mlx-community/Dia-1.6B"
|
||||
label: "Dia 1.6B (expressive, mlx-audio)"
|
||||
role: TTS
|
||||
size_gb: 3.2
|
||||
note: "Apple Silicon only — via mlx-audio backend."
|
||||
platforms: [darwin-arm64]
|
||||
|
||||
- repo_id: "mlx-community/Llama-OuteTTS-1.0-1B-4bit"
|
||||
label: "Llama-OuteTTS 1.0 1B 4bit (voice clone, mlx-audio)"
|
||||
role: TTS
|
||||
size_gb: 0.8
|
||||
note: "Apple Silicon only — via mlx-audio backend."
|
||||
platforms: [darwin-arm64]
|
||||
|
||||
- repo_id: "mlx-community/Chatterbox-TTS-4bit"
|
||||
label: "Chatterbox TTS 4bit (mlx-audio)"
|
||||
role: TTS
|
||||
size_gb: 0.5
|
||||
note: "Apple Silicon only — via mlx-audio backend."
|
||||
platforms: [darwin-arm64]
|
||||
|
||||
- repo_id: "mlx-community/MeloTTS-English-v3-MLX"
|
||||
label: "MeloTTS English v3 (mlx-audio)"
|
||||
role: TTS
|
||||
size_gb: 0.2
|
||||
note: "Apple Silicon only — via mlx-audio backend."
|
||||
platforms: [darwin-arm64]
|
||||
@@ -13,6 +13,36 @@ def get_app_data_dir():
|
||||
else:
|
||||
return os.path.expanduser("~/.omnivoice")
|
||||
|
||||
|
||||
def _ensure_short_hf_cache_on_windows():
|
||||
"""Redirect HuggingFace cache to a short path on Windows.
|
||||
|
||||
The default ``~/.cache/huggingface/hub/models--org--name/snapshots/<hash>/…``
|
||||
path regularly exceeds the 260-char MAX_PATH limit on NTFS, causing
|
||||
``FileNotFoundError`` or truncated downloads on first install. We shorten
|
||||
it to ``%LOCALAPPDATA%\\OmniVoice\\hf_cache`` (~40 chars) so even the
|
||||
deepest blob path stays well under the limit.
|
||||
|
||||
Respects any explicit override the user already set via
|
||||
``OMNIVOICE_CACHE_DIR``, ``HF_HOME``, or ``HF_HUB_CACHE``.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
# Don't override if the user (or main.py's OMNIVOICE_CACHE_DIR block)
|
||||
# already pointed the cache somewhere specific.
|
||||
if os.environ.get("OMNIVOICE_CACHE_DIR") or os.environ.get("HF_HOME") or os.environ.get("HF_HUB_CACHE"):
|
||||
return
|
||||
local_app = os.environ.get("LOCALAPPDATA", "")
|
||||
if not local_app:
|
||||
return
|
||||
short_cache = os.path.join(local_app, "OmniVoice", "hf_cache")
|
||||
os.makedirs(short_cache, exist_ok=True)
|
||||
os.environ["HF_HOME"] = short_cache
|
||||
os.environ["HF_HUB_CACHE"] = short_cache
|
||||
|
||||
_ensure_short_hf_cache_on_windows()
|
||||
|
||||
|
||||
DATA_DIR = get_app_data_dir()
|
||||
VOICES_DIR = os.path.join(DATA_DIR, "voices") # Reference audio for profiles
|
||||
OUTPUTS_DIR = os.path.join(DATA_DIR, "outputs") # Generated audio files
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""In-memory pub/sub event bus for real-time UI updates.
|
||||
|
||||
Any backend code that mutates sidebar-visible data (projects, profiles,
|
||||
history) calls ``emit(kind, payload)`` and the WebSocket endpoint fans it
|
||||
out to all connected frontends. This replaces the 45 s polling band-aid
|
||||
with instant push.
|
||||
|
||||
Events are fire-and-forget, no persistence needed — the frontend uses
|
||||
the event as a "hey, refetch this" signal rather than carrying the full
|
||||
data payload.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("omnivoice.events")
|
||||
|
||||
# All connected WebSocket listener queues
|
||||
_listeners: list[asyncio.Queue] = []
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def subscribe() -> asyncio.Queue:
|
||||
"""Register a new listener. Returns a Queue that receives event dicts."""
|
||||
q: asyncio.Queue = asyncio.Queue(maxsize=64)
|
||||
async with _lock:
|
||||
_listeners.append(q)
|
||||
return q
|
||||
|
||||
|
||||
async def unsubscribe(q: asyncio.Queue) -> None:
|
||||
"""Remove a listener."""
|
||||
async with _lock:
|
||||
try:
|
||||
_listeners.remove(q)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def emit(kind: str, payload: dict[str, Any] | None = None) -> None:
|
||||
"""Broadcast an event to all connected frontends.
|
||||
|
||||
Safe to call from sync or async context — uses fire-and-forget
|
||||
scheduling into the running event loop.
|
||||
|
||||
``kind`` is one of: projects, profiles, dub_history, export_history,
|
||||
generation_history, model_status, glossary.
|
||||
"""
|
||||
event = {
|
||||
"kind": kind,
|
||||
"ts": time.time(),
|
||||
**(payload or {}),
|
||||
}
|
||||
event_str = json.dumps(event)
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(_broadcast(event_str))
|
||||
except RuntimeError:
|
||||
# No event loop running (unlikely in FastAPI context but safe)
|
||||
logger.debug("No event loop — event dropped: %s", kind)
|
||||
|
||||
|
||||
async def _broadcast(event_str: str) -> None:
|
||||
"""Push event to all listener queues. Drop if full (slow consumer)."""
|
||||
async with _lock:
|
||||
dead: list[asyncio.Queue] = []
|
||||
for q in _listeners:
|
||||
try:
|
||||
q.put_nowait(event_str)
|
||||
except asyncio.QueueFull:
|
||||
# Slow consumer — drop oldest, then push
|
||||
try:
|
||||
q.get_nowait()
|
||||
q.put_nowait(event_str)
|
||||
except Exception:
|
||||
dead.append(q)
|
||||
for q in dead:
|
||||
try:
|
||||
_listeners.remove(q)
|
||||
except ValueError:
|
||||
pass
|
||||
@@ -37,7 +37,11 @@ def _load() -> dict:
|
||||
|
||||
def _save(data: dict) -> None:
|
||||
# Atomic write — no half-written JSON if the process dies mid-flush.
|
||||
fd, tmp = tempfile.mkstemp(prefix=".prefs.", suffix=".tmp", dir=DATA_DIR)
|
||||
# Derive temp-dir from _PREFS_PATH (not DATA_DIR) so os.replace() always
|
||||
# operates within the same filesystem — important when tests redirect the path.
|
||||
target_dir = os.path.dirname(_PREFS_PATH) or DATA_DIR
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(prefix=".prefs.", suffix=".tmp", dir=target_dir)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
@@ -3,10 +3,48 @@ import sys
|
||||
|
||||
try:
|
||||
import dotenv
|
||||
|
||||
dotenv.load_dotenv()
|
||||
# Also load the durable per-user config so env vars set once survive
|
||||
# Tauri/Finder launches that don't inherit a shell environment.
|
||||
_user_env = os.path.expanduser("~/.config/omnivoice/env")
|
||||
if os.path.isfile(_user_env):
|
||||
dotenv.load_dotenv(_user_env, override=False)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# ── cuDNN 8 library preload ─────────────────────────────────────────────
|
||||
# CTranslate2 (used by faster-whisper / WhisperX) requires cuDNN 8, but
|
||||
# PyTorch 2.8+ pulls cuDNN 9. scripts/setup_cudnn.py installs cuDNN 8
|
||||
# side-by-side into cudnn8_compat/ (survives `uv sync`). We preload all
|
||||
# cuDNN 8 libs via ctypes so CTranslate2's dlopen/LoadLibrary finds them.
|
||||
if sys.platform != "darwin": # macOS has no CUDA
|
||||
_project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
_pyver = f"python{sys.version_info.major}.{sys.version_info.minor}"
|
||||
if sys.platform == "win32":
|
||||
_cudnn8_lib = os.path.join(
|
||||
_project_root, ".venv", "Lib", "site-packages",
|
||||
"cudnn8_compat", "nvidia", "cudnn", "bin",
|
||||
)
|
||||
_cudnn8_glob = "cudnn*64_8.dll"
|
||||
else:
|
||||
_cudnn8_lib = os.path.join(
|
||||
_project_root, ".venv", "lib", _pyver, "site-packages",
|
||||
"cudnn8_compat", "nvidia", "cudnn", "lib",
|
||||
)
|
||||
_cudnn8_glob = "libcudnn*.so.8"
|
||||
if os.path.isdir(_cudnn8_lib):
|
||||
try:
|
||||
import ctypes, glob
|
||||
_mode = 0 if sys.platform == "win32" else ctypes.RTLD_GLOBAL
|
||||
for _so in sorted(glob.glob(os.path.join(_cudnn8_lib, _cudnn8_glob))):
|
||||
try:
|
||||
ctypes.CDLL(_so, mode=_mode)
|
||||
except OSError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Route HF/Torch caches to a single external directory when requested.
|
||||
_cache_dir = os.environ.get("OMNIVOICE_CACHE_DIR")
|
||||
if _cache_dir:
|
||||
@@ -15,6 +53,30 @@ if _cache_dir:
|
||||
os.environ["HF_HUB_CACHE"] = _cache_dir
|
||||
os.environ["TORCH_HOME"] = _cache_dir
|
||||
|
||||
# ── Windows symlink fix ─────────────────────────────────────────────────────
|
||||
# HuggingFace Hub creates NTFS symlinks in its cache to deduplicate blobs
|
||||
# across model revisions. On Windows, symlink creation requires either
|
||||
# Developer Mode enabled or an elevated (Administrator) shell. Without
|
||||
# either, `snapshot_download` / `hf_hub_download` raises:
|
||||
# OSError: [WinError 1314] A required privilege is not held by the client
|
||||
# Setting HF_HUB_DISABLE_SYMLINKS_WARNING silences the console spam, and the
|
||||
# newer HF_HUB_DISABLE_SYMLINKS (huggingface_hub ≥ 0.21) forces file copies
|
||||
# instead — slightly more disk but always works on first install.
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1")
|
||||
os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS", "1")
|
||||
|
||||
# ── HF Xet → legacy LFS fallback ────────────────────────────────────────────
|
||||
# huggingface_hub ≥ 1.5 routes large file downloads through the Xet content-
|
||||
# addressed protocol (hf_xet runtime), which has its own internal progress
|
||||
# reporting that bypasses our `tqdm` monkey-patch in `utils.hf_progress`.
|
||||
# As a result the SetupWizard install rows show no byte progress while the
|
||||
# download is actually running. Force the legacy LFS path until we add a
|
||||
# proper hf_xet progress hook — this still streams via the standard tqdm
|
||||
# wrapper that our patch intercepts. Override-able by the user.
|
||||
os.environ.setdefault("HF_HUB_DISABLE_XET", "1")
|
||||
|
||||
|
||||
# Prevent torchaudio from lazy-importing torchcodec (broken on some installs).
|
||||
# Proper fix = exclude torchcodec in pyproject.toml; this is a belt-and-braces guard.
|
||||
os.environ.setdefault("TORCHAUDIO_USE_TORCHCODEC", "0")
|
||||
@@ -42,11 +104,12 @@ class _JsonFormatter(logging.Formatter):
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
import json as _json
|
||||
|
||||
payload = {
|
||||
"t": self.formatTime(record, datefmt="%Y-%m-%dT%H:%M:%S"),
|
||||
"t": self.formatTime(record, datefmt="%Y-%m-%dT%H:%M:%S"),
|
||||
"level": record.levelname,
|
||||
"name": record.name,
|
||||
"msg": record.getMessage(),
|
||||
"name": record.name,
|
||||
"msg": record.getMessage(),
|
||||
}
|
||||
if record.exc_info:
|
||||
payload["exc"] = self.formatException(record.exc_info)
|
||||
@@ -67,13 +130,21 @@ if _json_logs:
|
||||
# Attached to root so uvicorn, fastapi, and every `omnivoice.*` namespace land here.
|
||||
# Not attached under _disable_file_log to keep CI/headless tests quiet.
|
||||
if not os.environ.get("OMNIVOICE_DISABLE_FILE_LOG"):
|
||||
from core.config import LOG_PATH as _LOG_PATH # local import — avoids circular import at module top
|
||||
from core.config import (
|
||||
LOG_PATH as _LOG_PATH,
|
||||
) # local import — avoids circular import at module top
|
||||
|
||||
try:
|
||||
_file_handler = RotatingFileHandler(
|
||||
_LOG_PATH, maxBytes=2 * 1024 * 1024, backupCount=3, encoding="utf-8",
|
||||
_LOG_PATH,
|
||||
maxBytes=2 * 1024 * 1024,
|
||||
backupCount=3,
|
||||
encoding="utf-8",
|
||||
)
|
||||
_file_handler.setLevel(logging.INFO)
|
||||
_file_handler.setFormatter(_JsonFormatter() if _json_logs else logging.Formatter(_LOG_FMT))
|
||||
_file_handler.setFormatter(
|
||||
_JsonFormatter() if _json_logs else logging.Formatter(_LOG_FMT)
|
||||
)
|
||||
logging.getLogger().addHandler(_file_handler)
|
||||
except Exception as _e: # disk full, permission denied, etc. — don't block startup
|
||||
logging.getLogger("omnivoice.api").warning("Runtime log file disabled: %s", _e)
|
||||
@@ -98,7 +169,25 @@ from core.tasks import task_manager
|
||||
from core import job_store
|
||||
from services.model_manager import idle_worker
|
||||
|
||||
from api.routers import system, profiles, exports, generation, dub_core, dub_generate, dub_export, dub_translate, projects, glossary, engines, tools, setup
|
||||
from api.routers import (
|
||||
system,
|
||||
profiles,
|
||||
exports,
|
||||
generation,
|
||||
dub_core,
|
||||
dub_generate,
|
||||
dub_export,
|
||||
dub_translate,
|
||||
projects,
|
||||
glossary,
|
||||
engines,
|
||||
tools,
|
||||
setup,
|
||||
gallery,
|
||||
batch,
|
||||
watermark,
|
||||
events,
|
||||
)
|
||||
from utils import hf_progress
|
||||
|
||||
# Install the HuggingFace tqdm patch early — every downstream library import
|
||||
@@ -106,9 +195,13 @@ from utils import hf_progress
|
||||
# the patched class, not the original.
|
||||
hf_progress.install()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
from api.routers.gallery import _init_gallery_db
|
||||
|
||||
_init_gallery_db()
|
||||
# Any job still in pending/running at startup is orphaned — a previous
|
||||
# process didn't finish it. Flip to failed with a clear message so the
|
||||
# UI doesn't show a fake spinner.
|
||||
@@ -121,18 +214,53 @@ async def lifespan(app: FastAPI):
|
||||
idle_task = asyncio.create_task(idle_worker())
|
||||
worker_task = asyncio.create_task(task_manager.worker())
|
||||
yield
|
||||
# ── Graceful shutdown (SIGTERM from Tauri, Ctrl+C, etc.) ────────────
|
||||
logger.info("Shutdown: cleaning up…")
|
||||
idle_task.cancel()
|
||||
worker_task.cancel()
|
||||
# Wait for tasks to finish their current iteration
|
||||
for t in (idle_task, worker_task):
|
||||
try:
|
||||
await asyncio.wait_for(t, timeout=3.0)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||
pass
|
||||
# Unload the model and free GPU memory
|
||||
try:
|
||||
import services.model_manager as mm
|
||||
if mm.model is not None:
|
||||
mm.model = None
|
||||
logger.info("Shutdown: model unloaded.")
|
||||
mm.free_vram()
|
||||
except Exception:
|
||||
pass
|
||||
# Run GC to release any remaining references
|
||||
try:
|
||||
import gc
|
||||
gc.collect()
|
||||
except Exception:
|
||||
pass
|
||||
# Close shared httpx connection pool
|
||||
try:
|
||||
from api.http_client import close_http_client
|
||||
await close_http_client()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("Shutdown: done.")
|
||||
|
||||
|
||||
app = FastAPI(title="OmniVoice Studio API", version="0.4.0", lifespan=lifespan)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
# Client disconnected mid-stream (browser canceled a <video>/range fetch).
|
||||
# The response is already partially sent — trying to wrap it in a 500 just
|
||||
# produces a second protocol error. Log a one-liner and bail.
|
||||
exc_name = type(exc).__name__
|
||||
if exc_name in ("LocalProtocolError", "ClientDisconnect") or "Content-Length" in str(exc):
|
||||
if exc_name in (
|
||||
"LocalProtocolError",
|
||||
"ClientDisconnect",
|
||||
) or "Content-Length" in str(exc):
|
||||
logger.info("Client disconnect during %s (%s)", request.url, exc_name)
|
||||
return Response(status_code=499)
|
||||
try:
|
||||
@@ -155,16 +283,18 @@ async def global_exception_handler(request: Request, exc: Exception):
|
||||
headers["Vary"] = "Origin"
|
||||
return JSONResponse({"detail": str(exc)}, status_code=500, headers=headers)
|
||||
|
||||
|
||||
_allowed = os.environ.get(
|
||||
"OMNIVOICE_ALLOWED_ORIGINS",
|
||||
"http://localhost:5173,http://127.0.0.1:5173,tauri://localhost,http://tauri.localhost",
|
||||
"http://localhost:3901,http://127.0.0.1:3901,tauri://localhost,http://tauri.localhost",
|
||||
).split(",")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[o.strip() for o in _allowed if o.strip()],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"], allow_headers=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["Content-Disposition"],
|
||||
)
|
||||
|
||||
@@ -184,15 +314,24 @@ app.include_router(glossary.router)
|
||||
app.include_router(engines.router)
|
||||
app.include_router(tools.router)
|
||||
app.include_router(setup.router)
|
||||
app.include_router(gallery.router)
|
||||
app.include_router(batch.router)
|
||||
app.include_router(watermark.router)
|
||||
app.include_router(events.router)
|
||||
|
||||
frontend_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist")
|
||||
if os.path.exists(frontend_path):
|
||||
app.mount("/", StaticFiles(directory=frontend_path, html=True), name="frontend")
|
||||
else:
|
||||
|
||||
@app.get("/")
|
||||
def _dev_fallback():
|
||||
return RedirectResponse(url="http://localhost:5173")
|
||||
return RedirectResponse(url="http://localhost:3901")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
|
||||
# Port 3900 picked to dodge common 8000 conflicts (Django/Rails/Jupyter).
|
||||
# Rust sidecar launcher in lib.rs::BACKEND_PORT must stay in sync.
|
||||
uvicorn.run(app, host="0.0.0.0", port=3900)
|
||||
|
||||
@@ -50,6 +50,10 @@ class ASRBackend(ABC):
|
||||
that already speak the shape plug in with zero adapter work.
|
||||
"""
|
||||
|
||||
def unload(self) -> None:
|
||||
"""Release the model from memory."""
|
||||
pass
|
||||
|
||||
|
||||
# ── WhisperX (cross-platform default — forced-alignment word timing) ────────
|
||||
|
||||
@@ -240,6 +244,17 @@ class WhisperXBackend(ASRBackend):
|
||||
"language": lang,
|
||||
}
|
||||
|
||||
def unload(self) -> None:
|
||||
self._asr = None
|
||||
self._align_cache.clear()
|
||||
import gc
|
||||
gc.collect()
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Faster-Whisper (cross-platform fallback) ────────────────────────────────
|
||||
|
||||
@@ -339,6 +354,17 @@ class FasterWhisperBackend(ASRBackend):
|
||||
}
|
||||
return out
|
||||
|
||||
def unload(self) -> None:
|
||||
self._asr = None
|
||||
import gc
|
||||
gc.collect()
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── MLX Whisper (Apple Silicon optional) ────────────────────────────────────
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ from fastapi import HTTPException
|
||||
from services.ffmpeg_utils import find_ffmpeg, _get_semaphore, _spawn_with_retry
|
||||
from services.model_manager import get_best_device
|
||||
from core.db import db_conn, get_db
|
||||
from core import event_bus
|
||||
|
||||
logger = logging.getLogger("omnivoice.dub_pipeline")
|
||||
|
||||
@@ -230,6 +231,8 @@ def save_job(job_id: str, job: dict, filename: str = "", duration: float = 0.0,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to persist dub job %s: %s", job_id, e)
|
||||
return
|
||||
event_bus.emit("dub_history", {"action": "saved", "id": job_id})
|
||||
|
||||
|
||||
# ── Ingest pipeline (download → extract → demucs → scene → thumb) ──────────
|
||||
|
||||
@@ -91,6 +91,52 @@ def free_vram():
|
||||
elif torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
|
||||
def offload_tts_for_asr():
|
||||
"""Move TTS model to CPU to free VRAM for ASR (WhisperX large-v3).
|
||||
|
||||
On a 7-8 GB laptop GPU the TTS model (~2.4 GB) and WhisperX large-v3
|
||||
(~3 GB) plus the VAD model can't coexist. Offloading the TTS model to
|
||||
CPU before transcription prevents CUDA OOM, then restore_tts_after_asr()
|
||||
moves it back.
|
||||
"""
|
||||
global model
|
||||
if model is None:
|
||||
return
|
||||
if not torch.cuda.is_available():
|
||||
return # Only needed on CUDA (limited VRAM)
|
||||
try:
|
||||
# Check if there's enough free VRAM to skip offloading (WhisperX + context needs >6GB safely)
|
||||
free_mem = torch.cuda.mem_get_info()[0]
|
||||
if free_mem > 8 * 1024 ** 3: # > 8 GB free → plenty of room, skip offload
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
logger.info("Offloading TTS model to CPU to free VRAM for ASR...")
|
||||
model.to("cpu")
|
||||
free_vram()
|
||||
logger.info("TTS model offloaded. VRAM freed for ASR.")
|
||||
except Exception as e:
|
||||
logger.warning("TTS offload failed: %s", e)
|
||||
|
||||
|
||||
def restore_tts_after_asr():
|
||||
"""Move TTS model back to CUDA after ASR completes."""
|
||||
global model
|
||||
if model is None:
|
||||
return
|
||||
if not torch.cuda.is_available():
|
||||
return
|
||||
try:
|
||||
device = get_best_device()
|
||||
if device == "cuda":
|
||||
logger.info("Restoring TTS model to CUDA...")
|
||||
model.to("cuda")
|
||||
free_vram()
|
||||
except Exception as e:
|
||||
logger.warning("TTS restore to CUDA failed: %s", e)
|
||||
|
||||
_diar_pipeline = None
|
||||
|
||||
def get_diarization_pipeline():
|
||||
|
||||
@@ -59,8 +59,38 @@ _ADAPT_PROMPT = """\
|
||||
You are a cinematic dubbing writer. Rewrite the literal translation using the
|
||||
editor's critique so it sounds natural, in-character, and fits the speaker's
|
||||
time slot. Keep meaning faithful but prefer native idiom over word-for-word
|
||||
accuracy. Reply ONLY with the adapted translation — no quotes, no headers,
|
||||
no code fences, no commentary."""
|
||||
accuracy. The output MUST be written in the same target language and script
|
||||
as the literal translation — never switch language or transliterate.
|
||||
Reply ONLY with the adapted translation — no quotes, no headers, no code
|
||||
fences, no commentary."""
|
||||
|
||||
# Per-language script ranges, mirrored from dub_translate.LANG_REQUIRED_SCRIPT
|
||||
# so the cinematic refine path can reject LLM outputs that drifted off the
|
||||
# target script. Kept local instead of imported because the routers package
|
||||
# also imports this services module — circular-import risk otherwise.
|
||||
_SCRIPT_RANGES = {
|
||||
"hi": (0x0900, 0x097F),
|
||||
"ar": (0x0600, 0x06FF),
|
||||
"zh": (0x4E00, 0x9FFF),
|
||||
"zh-CN": (0x4E00, 0x9FFF),
|
||||
"ja": (0x3040, 0x30FF),
|
||||
"ko": (0xAC00, 0xD7AF),
|
||||
"th": (0x0E00, 0x0E7F),
|
||||
"ru": (0x0400, 0x04FF),
|
||||
"uk": (0x0400, 0x04FF),
|
||||
}
|
||||
|
||||
|
||||
def _looks_like_target_script(text: str, code: str, threshold: float = 0.5) -> bool:
|
||||
rng = _SCRIPT_RANGES.get(code)
|
||||
if not rng:
|
||||
return True
|
||||
lo, hi = rng
|
||||
letters = [c for c in text if c.isalpha()]
|
||||
if not letters:
|
||||
return True
|
||||
inside = sum(1 for c in letters if lo <= ord(c) <= hi)
|
||||
return (inside / len(letters)) >= threshold
|
||||
|
||||
|
||||
def _llm_client():
|
||||
@@ -219,7 +249,22 @@ def cinematic_refine_sync(
|
||||
"error": f"adapt: {e}",
|
||||
}
|
||||
|
||||
final = adapted.strip() or literal_text
|
||||
final = (adapted or "").strip() or literal_text
|
||||
# Refuse adaptations that drifted off the target script (e.g. local LLM
|
||||
# rewrote a Devanagari line in Latin/German). Caller still gets the
|
||||
# critique so the UI can show what happened, but the live text falls
|
||||
# back to the literal translation rather than corrupting the dub.
|
||||
if final is not literal_text and not _looks_like_target_script(final, target_lang):
|
||||
logger.warning(
|
||||
"cinematic adapt produced wrong-script output for %s — falling back to literal",
|
||||
target_lang,
|
||||
)
|
||||
return {
|
||||
"text": literal_text,
|
||||
"literal": literal_text,
|
||||
"critique": critique,
|
||||
"error": f"adapt-wrong-script:{target_lang}",
|
||||
}
|
||||
return {
|
||||
"text": final,
|
||||
"literal": literal_text,
|
||||
|
||||
@@ -441,11 +441,11 @@ class MLXAudioBackend(TTSBackend):
|
||||
CURATED_MODELS = {
|
||||
"kokoro": "mlx-community/Kokoro-82M-bf16",
|
||||
"csm": "mlx-community/csm-1b-8bit",
|
||||
"qwen3-tts": "mlx-community/Qwen3-TTS-1.7B-4bit",
|
||||
"qwen3-tts": "mlx-community/Qwen3-TTS-12Hz-1.7B-VoiceDesign-4bit",
|
||||
"dia": "mlx-community/Dia-1.6B",
|
||||
"chatterbox": "mlx-community/Chatterbox",
|
||||
"melotts": "mlx-community/MeloTTS",
|
||||
"outetts": "mlx-community/OuteTTS-0.3-500M",
|
||||
"chatterbox": "mlx-community/Chatterbox-TTS-4bit",
|
||||
"melotts": "mlx-community/MeloTTS-English-v3-MLX",
|
||||
"outetts": "mlx-community/Llama-OuteTTS-1.0-1B-4bit",
|
||||
}
|
||||
DEFAULT_MODEL_KEY = "kokoro"
|
||||
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
"""
|
||||
Invisible + visible audio watermarking for OmniVoice Studio.
|
||||
|
||||
Two layers:
|
||||
1. **Invisible** — AudioSeal (Meta) embeds imperceptible neural watermarks
|
||||
that survive compression, resampling, and editing. Encodes a 16-bit
|
||||
message identifying OmniVoice as the source.
|
||||
2. **Visible** — Optional audio signature tone prepended to exports;
|
||||
ffmpeg-based logo overlay for video exports.
|
||||
|
||||
Usage:
|
||||
from services.watermark import embed_watermark, detect_watermark
|
||||
|
||||
# Embed (returns same shape tensor, watermarked)
|
||||
watermarked = embed_watermark(waveform, sample_rate)
|
||||
|
||||
# Detect (returns dict with confidence + metadata)
|
||||
result = detect_watermark(waveform, sample_rate)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import struct
|
||||
import torch
|
||||
import numpy as np
|
||||
from typing import Optional
|
||||
|
||||
from core.prefs import resolve
|
||||
|
||||
logger = logging.getLogger("omnivoice.watermark")
|
||||
|
||||
# ── Lazy-loaded AudioSeal models ──────────────────────────────────────────
|
||||
# Loaded on first use so cold-start isn't penalised when watermarking is off.
|
||||
_generator = None
|
||||
_detector = None
|
||||
_audioseal_available: Optional[bool] = None
|
||||
|
||||
# 16-bit message: "OM" in ASCII = 0x4F 0x4D = 0100_1111 0100_1101
|
||||
# This is our signature — every OmniVoice-generated audio carries it.
|
||||
OMNI_MESSAGE = [0, 1, 0, 0, 1, 1, 1, 1, 0, 1, 0, 0, 1, 1, 0, 1]
|
||||
|
||||
|
||||
def _check_available() -> bool:
|
||||
"""Check if AudioSeal is installed and importable."""
|
||||
global _audioseal_available
|
||||
if _audioseal_available is None:
|
||||
try:
|
||||
import audioseal # noqa: F401
|
||||
_audioseal_available = True
|
||||
except ImportError:
|
||||
_audioseal_available = False
|
||||
logger.info("audioseal not installed — invisible watermarking disabled")
|
||||
return _audioseal_available
|
||||
|
||||
|
||||
def _get_generator():
|
||||
"""Lazy-load the AudioSeal generator model."""
|
||||
global _generator
|
||||
if _generator is None:
|
||||
from audioseal import AudioSeal
|
||||
_generator = AudioSeal.load_generator("audioseal_wm_16bits")
|
||||
_generator.eval()
|
||||
logger.info("AudioSeal generator loaded (16-bit message mode)")
|
||||
return _generator
|
||||
|
||||
|
||||
def _get_detector():
|
||||
"""Lazy-load the AudioSeal detector model."""
|
||||
global _detector
|
||||
if _detector is None:
|
||||
from audioseal import AudioSeal
|
||||
_detector = AudioSeal.load_detector("audioseal_detector_16bits")
|
||||
_detector.eval()
|
||||
logger.info("AudioSeal detector loaded (16-bit message mode)")
|
||||
return _detector
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
"""Check if invisible watermarking is enabled in user preferences."""
|
||||
return resolve("watermark.invisible", default=True) is not False
|
||||
|
||||
|
||||
def is_visible_audio_enabled() -> bool:
|
||||
"""Check if audible branding tone is enabled for exports."""
|
||||
return resolve("watermark.visible_audio", default=False) is True
|
||||
|
||||
|
||||
def is_visible_video_enabled() -> bool:
|
||||
"""Check if video logo overlay is enabled for exports."""
|
||||
return resolve("watermark.visible_video", default=True) is not False
|
||||
|
||||
|
||||
# ── Invisible Watermark ───────────────────────────────────────────────────
|
||||
|
||||
@torch.no_grad()
|
||||
def embed_watermark(
|
||||
waveform: torch.Tensor,
|
||||
sample_rate: int,
|
||||
message: Optional[list[int]] = None,
|
||||
) -> torch.Tensor:
|
||||
"""
|
||||
Embed an imperceptible watermark into the audio waveform.
|
||||
|
||||
Args:
|
||||
waveform: Audio tensor of shape (channels, samples) or (1, channels, samples)
|
||||
sample_rate: Sample rate of the audio
|
||||
message: Optional 16-bit message (list of 0/1). Defaults to OMNI_MESSAGE.
|
||||
|
||||
Returns:
|
||||
Watermarked waveform (same shape as input).
|
||||
"""
|
||||
if not is_enabled() or not _check_available():
|
||||
return waveform
|
||||
|
||||
try:
|
||||
generator = _get_generator()
|
||||
msg = torch.tensor(message or OMNI_MESSAGE, dtype=torch.int32).unsqueeze(0)
|
||||
|
||||
# AudioSeal expects (batch, channels, samples) — normalise input
|
||||
original_shape = waveform.shape
|
||||
if waveform.dim() == 2:
|
||||
audio = waveform.unsqueeze(0) # (1, C, S)
|
||||
elif waveform.dim() == 1:
|
||||
audio = waveform.unsqueeze(0).unsqueeze(0) # (1, 1, S)
|
||||
else:
|
||||
audio = waveform
|
||||
|
||||
# AudioSeal operates at 16kHz internally; it handles resampling, but
|
||||
# we need to inform it of the source rate for correct embedding.
|
||||
watermarked = generator(audio, sample_rate=sample_rate, message=msg)
|
||||
|
||||
# Restore original shape
|
||||
if len(original_shape) == 2:
|
||||
watermarked = watermarked.squeeze(0)
|
||||
elif len(original_shape) == 1:
|
||||
watermarked = watermarked.squeeze(0).squeeze(0)
|
||||
|
||||
return watermarked
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Watermark embedding failed (passing through original): %s", e)
|
||||
return waveform
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def detect_watermark(
|
||||
waveform: torch.Tensor,
|
||||
sample_rate: int,
|
||||
) -> dict:
|
||||
"""
|
||||
Detect whether audio contains an OmniVoice watermark.
|
||||
|
||||
Args:
|
||||
waveform: Audio tensor of shape (channels, samples)
|
||||
sample_rate: Sample rate of the audio
|
||||
|
||||
Returns:
|
||||
Dict with keys:
|
||||
is_watermarked: bool
|
||||
confidence: float (0.0–1.0)
|
||||
message_bits: str (decoded 16-bit message)
|
||||
is_omnivoice: bool (true if message matches OMNI_MESSAGE)
|
||||
"""
|
||||
if not _check_available():
|
||||
return {
|
||||
"is_watermarked": False,
|
||||
"confidence": 0.0,
|
||||
"message_bits": "",
|
||||
"is_omnivoice": False,
|
||||
"error": "audioseal not installed",
|
||||
}
|
||||
|
||||
try:
|
||||
detector = _get_detector()
|
||||
|
||||
# Normalise shape to (batch, channels, samples)
|
||||
if waveform.dim() == 2:
|
||||
audio = waveform.unsqueeze(0)
|
||||
elif waveform.dim() == 1:
|
||||
audio = waveform.unsqueeze(0).unsqueeze(0)
|
||||
else:
|
||||
audio = waveform
|
||||
|
||||
result = detector.detect_watermark(audio, sample_rate=sample_rate, message_threshold=0.5)
|
||||
|
||||
# result is (detection_confidence, decoded_message)
|
||||
confidence = float(result[0]) if isinstance(result, tuple) else 0.0
|
||||
decoded_msg = result[1] if isinstance(result, tuple) and len(result) > 1 else None
|
||||
|
||||
# Decode message bits
|
||||
message_bits = ""
|
||||
is_omnivoice = False
|
||||
if decoded_msg is not None:
|
||||
try:
|
||||
bits = decoded_msg.squeeze().tolist()
|
||||
if isinstance(bits, list):
|
||||
message_bits = "".join(str(int(b > 0.5)) for b in bits)
|
||||
decoded_list = [int(b > 0.5) for b in bits]
|
||||
is_omnivoice = decoded_list == OMNI_MESSAGE
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"is_watermarked": confidence > 0.5,
|
||||
"confidence": round(confidence, 4),
|
||||
"message_bits": message_bits,
|
||||
"is_omnivoice": is_omnivoice,
|
||||
"source": "OmniVoice Studio" if is_omnivoice else "unknown",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Watermark detection failed: %s", e)
|
||||
return {
|
||||
"is_watermarked": False,
|
||||
"confidence": 0.0,
|
||||
"message_bits": "",
|
||||
"is_omnivoice": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
# ── Visible Audio Brand ──────────────────────────────────────────────────
|
||||
|
||||
def generate_brand_tone(sample_rate: int = 24000, duration_s: float = 0.4) -> torch.Tensor:
|
||||
"""
|
||||
Generate a short, distinctive audio signature tone.
|
||||
|
||||
A soft ascending three-note chime (C5→E5→G5) that serves as the
|
||||
OmniVoice "sound logo". Gentle enough for professional use.
|
||||
|
||||
Returns:
|
||||
Tensor of shape (1, samples).
|
||||
"""
|
||||
notes_hz = [523.25, 659.25, 783.99] # C5, E5, G5
|
||||
note_dur = duration_s / len(notes_hz)
|
||||
samples_per_note = int(note_dur * sample_rate)
|
||||
total_samples = samples_per_note * len(notes_hz)
|
||||
|
||||
tone = torch.zeros(1, total_samples)
|
||||
t = torch.linspace(0, note_dur, samples_per_note)
|
||||
|
||||
for idx, freq in enumerate(notes_hz):
|
||||
# Sine wave with exponential decay envelope
|
||||
envelope = torch.exp(-t * 6.0) * 0.15 # quiet — 15% amplitude
|
||||
wave = torch.sin(2 * math.pi * freq * t) * envelope
|
||||
start = idx * samples_per_note
|
||||
tone[0, start : start + samples_per_note] = wave
|
||||
|
||||
# Fade out the last 20%
|
||||
fade_len = int(total_samples * 0.2)
|
||||
if fade_len > 0:
|
||||
tone[0, -fade_len:] *= torch.linspace(1.0, 0.0, fade_len)
|
||||
|
||||
return tone
|
||||
|
||||
|
||||
def apply_audio_brand(
|
||||
waveform: torch.Tensor,
|
||||
sample_rate: int,
|
||||
) -> torch.Tensor:
|
||||
"""
|
||||
Prepend the OmniVoice brand tone to a waveform (for final exports only).
|
||||
|
||||
Returns:
|
||||
Tensor with brand tone + original audio concatenated.
|
||||
"""
|
||||
if not is_visible_audio_enabled():
|
||||
return waveform
|
||||
|
||||
brand = generate_brand_tone(sample_rate=sample_rate)
|
||||
# Add 100ms silence gap between brand and content
|
||||
gap = torch.zeros(1, int(0.1 * sample_rate))
|
||||
return torch.cat([brand, gap, waveform], dim=-1)
|
||||
|
||||
|
||||
# ── Video Logo Overlay ────────────────────────────────────────────────────
|
||||
|
||||
def get_ffmpeg_overlay_args(logo_path: str, duration_s: float = 5.0) -> list[str]:
|
||||
"""
|
||||
Build ffmpeg filter args to overlay the OmniVoice logo in the bottom-right
|
||||
corner with a fade-out after `duration_s` seconds.
|
||||
|
||||
Returns:
|
||||
List of ffmpeg filter_complex args.
|
||||
"""
|
||||
if not is_visible_video_enabled():
|
||||
return []
|
||||
|
||||
# Scale logo to 64px height, place bottom-right with 20px padding,
|
||||
# fade out after duration_s seconds.
|
||||
filter_str = (
|
||||
f"[1:v]scale=-1:64,format=rgba,"
|
||||
f"fade=t=out:st={duration_s - 1}:d=1:alpha=1[logo];"
|
||||
f"[0:v][logo]overlay=W-w-20:H-h-20:enable='lte(t,{duration_s})'"
|
||||
)
|
||||
return ["-filter_complex", filter_str]
|
||||
@@ -18,6 +18,7 @@ Usage:
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import itertools
|
||||
import logging
|
||||
import threading
|
||||
@@ -25,6 +26,14 @@ from typing import Callable, Optional
|
||||
|
||||
logger = logging.getLogger("omnivoice.hf_progress")
|
||||
|
||||
# Context-scoped active repo_id. Set in the install/delete handler so every
|
||||
# tqdm event fired while a snapshot_download runs can be stamped with the
|
||||
# originating repo, letting the frontend route per-file events to the right
|
||||
# row instead of heuristically matching filename substrings.
|
||||
current_repo_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
|
||||
"omnivoice_hf_progress_repo_id", default=None,
|
||||
)
|
||||
|
||||
# Event shape forwarded to listeners. Typed loosely on purpose — SSE encodes
|
||||
# it as JSON so consumers read the dict directly.
|
||||
# {
|
||||
@@ -61,6 +70,11 @@ def unregister_listener(lid: int) -> None:
|
||||
def _emit(event: ProgressEvent) -> None:
|
||||
"""Fan out to all registered listeners. Never raise — a bad listener
|
||||
shouldn't break a download."""
|
||||
# Stamp the active repo_id so frontends can route events to the right
|
||||
# row. Only set when this emit is happening inside an install handler.
|
||||
rid = current_repo_id.get()
|
||||
if rid is not None and "repo_id" not in event:
|
||||
event = {**event, "repo_id": rid}
|
||||
with _listener_lock:
|
||||
listeners = list(_listeners.values())
|
||||
for cb in listeners:
|
||||
@@ -70,6 +84,12 @@ def _emit(event: ProgressEvent) -> None:
|
||||
logger.debug("hf_progress listener raised: %s", e)
|
||||
|
||||
|
||||
def emit(event: ProgressEvent) -> None:
|
||||
"""Public emit — lets non-tqdm operations (delete, verify, etc.) push
|
||||
lifecycle events onto the same SSE stream."""
|
||||
_emit(event)
|
||||
|
||||
|
||||
def install() -> None:
|
||||
"""Monkey-patch `huggingface_hub`'s tqdm so every download reports to our
|
||||
listeners. Safe to call multiple times — second call is a no-op."""
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"name": "omnivoice-studio-monorepo",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1",
|
||||
"kill-port-process": "^4.0.2",
|
||||
"playwright": "^1.59.1",
|
||||
"turbo": "^2.9.6",
|
||||
"typescript": "^6.0.3",
|
||||
"wait-on": "^9.0.5",
|
||||
@@ -13,22 +15,38 @@
|
||||
},
|
||||
"frontend": {
|
||||
"name": "omnivoice-studio",
|
||||
"version": "0.0.0",
|
||||
"version": "0.2.3",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/source-serif-4": "^5.2.9",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "4",
|
||||
"@tanstack/react-query": "^5.100.4",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-process": "^2.3.0",
|
||||
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-window": "^2.2.7",
|
||||
"tailwindcss": "4",
|
||||
"wavesurfer.js": "^7.12.6",
|
||||
"zustand": "^5.0.2",
|
||||
"zustand": "^5.0.12",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
@@ -37,12 +55,12 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^8.0.8",
|
||||
"globals": "^17.5.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.9",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -101,6 +119,14 @@
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
|
||||
|
||||
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
|
||||
|
||||
"@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="],
|
||||
|
||||
"@fontsource-variable/source-serif-4": ["@fontsource-variable/source-serif-4@5.2.9", "", {}, "sha512-PPcxjLFk/fS0WHg79pDM2YNvz61kC+oYZ5cWZZyCS0DHpJncmuYOuiZAsvj4tDxlWPBEvxxcRLQQNmSaRbPkqw=="],
|
||||
@@ -137,44 +163,166 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="],
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.124.0", "", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="],
|
||||
"@oxc-project/types": ["@oxc-project/types@0.126.0", "", {}, "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="],
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg=="],
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw=="],
|
||||
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.15", "", { "os": "freebsd", "cpu": "x64" }, "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw=="],
|
||||
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm" }, "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA=="],
|
||||
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w=="],
|
||||
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ=="],
|
||||
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ=="],
|
||||
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "s390x" }, "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ=="],
|
||||
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA=="],
|
||||
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.15", "", { "os": "linux", "cpu": "x64" }, "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw=="],
|
||||
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.15", "", { "os": "none", "cpu": "arm64" }, "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg=="],
|
||||
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.15", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.3" }, "cpu": "none" }, "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q=="],
|
||||
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA=="],
|
||||
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.15", "", { "os": "win32", "cpu": "x64" }, "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g=="],
|
||||
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
|
||||
|
||||
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
|
||||
|
||||
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
||||
|
||||
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
|
||||
|
||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.8", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA=="],
|
||||
|
||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
||||
|
||||
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
|
||||
|
||||
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
|
||||
|
||||
"@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="],
|
||||
|
||||
"@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q=="],
|
||||
|
||||
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
|
||||
|
||||
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
|
||||
|
||||
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
|
||||
|
||||
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
|
||||
|
||||
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
|
||||
|
||||
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
||||
|
||||
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
|
||||
|
||||
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
|
||||
|
||||
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
|
||||
|
||||
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
|
||||
|
||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.16", "", { "os": "android", "cpu": "arm64" }, "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.16", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm" }, "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "s390x" }, "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.16", "", { "os": "none", "cpu": "arm64" }, "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.16", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "x64" }, "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
|
||||
|
||||
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
|
||||
|
||||
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.2.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.4" } }, "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.4", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.4", "@tailwindcss/oxide-darwin-arm64": "4.2.4", "@tailwindcss/oxide-darwin-x64": "4.2.4", "@tailwindcss/oxide-freebsd-x64": "4.2.4", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", "@tailwindcss/oxide-linux-x64-musl": "4.2.4", "@tailwindcss/oxide-wasm32-wasi": "4.2.4", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" } }, "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.4", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.4", "", { "dependencies": { "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "tailwindcss": "4.2.4" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw=="],
|
||||
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.100.4", "", {}, "sha512-LdW/DDImiw9g4ukyndlrifIXPFpoQjNybCAIDBcPvdYu9iUIhAKwhznfAATe2dJBonhm0O3ksoCMmVTUxN89uA=="],
|
||||
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.100.4", "", { "dependencies": { "@tanstack/query-core": "5.100.4" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-L6n5UWBvnMuYaZTu6WgTbl2mJ7fob1NIdL2vIFo05R/mkr9XvP5PmesZOBiicxzhDAuUurYIG+ZFONbvarEFtQ=="],
|
||||
|
||||
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
|
||||
|
||||
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.24", "", { "dependencies": { "@tanstack/virtual-core": "3.14.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg=="],
|
||||
|
||||
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
|
||||
|
||||
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.14.0", "", {}, "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q=="],
|
||||
|
||||
"@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
|
||||
|
||||
"@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="],
|
||||
@@ -203,6 +351,8 @@
|
||||
|
||||
"@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.0", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw=="],
|
||||
|
||||
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ=="],
|
||||
|
||||
"@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="],
|
||||
|
||||
"@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.10.1", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA=="],
|
||||
@@ -245,6 +395,8 @@
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"axios": ["axios@1.15.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="],
|
||||
@@ -287,12 +439,16 @@
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.334", "", {}, "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.21.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
@@ -305,9 +461,9 @@
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["eslint@10.2.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.4", "@eslint/config-helpers": "^0.5.4", "@eslint/core": "^1.2.0", "@eslint/plugin-kit": "^0.7.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA=="],
|
||||
"eslint": ["eslint@10.2.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q=="],
|
||||
|
||||
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="],
|
||||
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="],
|
||||
|
||||
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.2", "", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA=="],
|
||||
|
||||
@@ -325,6 +481,8 @@
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
@@ -333,6 +491,8 @@
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
|
||||
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||
@@ -345,7 +505,7 @@
|
||||
|
||||
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
@@ -355,16 +515,24 @@
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
|
||||
|
||||
"get-them-args": ["get-them-args@1.3.2", "", {}, "sha512-LRn8Jlk+DwZE4GTlDbT3Hikd1wSHgLMme/+7ddlqKd7ldwR6LjJgTVWzBnR01wnYGe4KgrXjg287RaI22UHmAw=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"globals": ["globals@17.4.0", "", {}, "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw=="],
|
||||
"globals": ["globals@17.5.0", "", {}, "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g=="],
|
||||
|
||||
"goober": ["goober@2.1.18", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
@@ -377,6 +545,8 @@
|
||||
|
||||
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||
|
||||
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
@@ -387,8 +557,16 @@
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
|
||||
|
||||
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"joi": ["joi@18.1.2", "", { "dependencies": { "@hapi/address": "^5.1.1", "@hapi/formula": "^3.0.2", "@hapi/hoek": "^11.0.7", "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", "@standard-schema/spec": "^1.1.0" } }, "sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
@@ -405,6 +583,8 @@
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"kill-port-process": ["kill-port-process@4.0.2", "", { "dependencies": { "get-them-args": "1.3.2", "pid-port": "2.0.1" }, "bin": { "kill-port": "dist/bin/kill-port-process.js" } }, "sha512-fO8gc45EYJQUQWozPBmdTpsR0GDvldsmrhP2I4FPoNejwyBY4Liiwj9Is7P/5rj6k07ZQ5Ob0g0k2dqQcslW/w=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
@@ -439,6 +619,8 @@
|
||||
|
||||
"lucide-react": ["lucide-react@1.8.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
@@ -457,6 +639,8 @@
|
||||
|
||||
"node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
|
||||
|
||||
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
|
||||
|
||||
"omnivoice-studio": ["omnivoice-studio@workspace:frontend"],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
@@ -465,6 +649,8 @@
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
@@ -473,25 +659,41 @@
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="],
|
||||
"pid-port": ["pid-port@2.0.1", "", { "dependencies": { "execa": "^9.6.0" } }, "sha512-pnLo01AmMclw8l+/gfknsP2N351oe8VkVmCLFUvJZ11NRPPmghJrv0OcwsdgPQxsZkFYwm6hPWW0JKmXYCaXAw=="],
|
||||
|
||||
"playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
|
||||
|
||||
"postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="],
|
||||
|
||||
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
|
||||
|
||||
"react-hot-toast": ["react-hot-toast@2.6.0", "", { "dependencies": { "csstype": "^3.1.3", "goober": "^2.1.16" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg=="],
|
||||
|
||||
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
|
||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
|
||||
"react-window": ["react-window@2.2.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-rc.15", "", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="],
|
||||
"rolldown": ["rolldown@1.0.0-rc.16", "", { "dependencies": { "@oxc-project/types": "=0.126.0", "@rolldown/pluginutils": "1.0.0-rc.16" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-x64": "1.0.0-rc.16", "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g=="],
|
||||
|
||||
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
|
||||
|
||||
@@ -505,14 +707,22 @@
|
||||
|
||||
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
|
||||
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
|
||||
|
||||
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="],
|
||||
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
||||
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
|
||||
@@ -525,11 +735,17 @@
|
||||
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"vite": ["vite@8.0.8", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "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-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="],
|
||||
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||
|
||||
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||
|
||||
"vite": ["vite@8.0.9", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.16", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw=="],
|
||||
|
||||
"wait-on": ["wait-on@9.0.5", "", { "dependencies": { "axios": "^1.15.0", "joi": "^18.1.2", "lodash": "^4.18.1", "minimist": "^1.2.8", "rxjs": "^7.8.2" }, "bin": { "wait-on": "bin/wait-on" } }, "sha512-qgnbHDfDTRIp73ANEJNRW/7kn8CrDUcvZz18xotJQku/P4saTGkbIzvnMZebPmVvVNUiRq1qWAPyqCH+W4H8KA=="],
|
||||
|
||||
@@ -551,6 +767,8 @@
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
|
||||
@@ -559,10 +777,30 @@
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@radix-ui/react-progress/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
|
||||
|
||||
"@radix-ui/react-progress/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"omnivoice-studio/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="],
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.16", "", {}, "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA=="],
|
||||
|
||||
"vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"@radix-ui/react-progress/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ services:
|
||||
container_name: omnivoice-studio
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "3900:3900"
|
||||
volumes:
|
||||
# Map the backend data directory to host for persistent SQLite, voices, and history
|
||||
- ./omnivoice_data:/app/omnivoice_data
|
||||
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 144 KiB |
@@ -0,0 +1,61 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<!-- Background gradient -->
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1e1e2e"/>
|
||||
<stop offset="100%" stop-color="#13131f"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Waveform pink gradient -->
|
||||
<linearGradient id="wave" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#e8a4b8"/>
|
||||
<stop offset="50%" stop-color="#d3869b"/>
|
||||
<stop offset="100%" stop-color="#c07090"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Glow filter -->
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="8" result="blur"/>
|
||||
<feColorMatrix in="blur" type="matrix" values="1 0 0 0 0 0 0.4 0 0 0 0 0 0.5 0 0 0 0 0 0.6 0" result="glow"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="glow"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
<!-- Subtle inner shadow -->
|
||||
<filter id="inset" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="6" result="blur"/>
|
||||
<feOffset dx="0" dy="3" result="offset"/>
|
||||
<feComposite in="SourceGraphic" in2="offset" operator="over"/>
|
||||
</filter>
|
||||
|
||||
<!-- Edge highlight -->
|
||||
<linearGradient id="edge" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.12"/>
|
||||
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.03"/>
|
||||
<stop offset="100%" stop-color="#000000" stop-opacity="0.15"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Outer rounded square -->
|
||||
<rect x="16" y="16" width="480" height="480" rx="96" ry="96" fill="url(#bg)"/>
|
||||
|
||||
<!-- Edge/border highlight -->
|
||||
<rect x="16" y="16" width="480" height="480" rx="96" ry="96" fill="none" stroke="url(#edge)" stroke-width="2"/>
|
||||
|
||||
<!-- Central waveform bars (audio visualizer style) -->
|
||||
<g transform="translate(256, 256)" filter="url(#glow)">
|
||||
<!-- 7 bars, symmetric heights, rounded caps -->
|
||||
<rect x="-120" y="-30" width="20" height="60" rx="10" fill="url(#wave)" opacity="0.7"/>
|
||||
<rect x="-84" y="-55" width="20" height="110" rx="10" fill="url(#wave)" opacity="0.85"/>
|
||||
<rect x="-48" y="-80" width="20" height="160" rx="10" fill="url(#wave)" opacity="0.95"/>
|
||||
<rect x="-10" y="-100" width="20" height="200" rx="10" fill="url(#wave)"/>
|
||||
<rect x="28" y="-75" width="20" height="150" rx="10" fill="url(#wave)" opacity="0.95"/>
|
||||
<rect x="64" y="-50" width="20" height="100" rx="10" fill="url(#wave)" opacity="0.85"/>
|
||||
<rect x="100" y="-25" width="20" height="50" rx="10" fill="url(#wave)" opacity="0.7"/>
|
||||
</g>
|
||||
|
||||
<!-- Subtle circle ring behind bars -->
|
||||
<circle cx="256" cy="256" r="140" fill="none" stroke="#d3869b" stroke-width="1.5" opacity="0.15"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 358 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 295 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "omnivoice-studio",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -17,17 +17,33 @@
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/source-serif-4": "^5.2.9",
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "4",
|
||||
"@tanstack/react-query": "^5.100.4",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-process": "^2.3.0",
|
||||
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-window": "^2.2.7",
|
||||
"tailwindcss": "4",
|
||||
"wavesurfer.js": "^7.12.6",
|
||||
"zustand": "^5.0.2"
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
@@ -36,11 +52,11 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^8.0.8"
|
||||
"globals": "^17.5.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,61 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#d3869b" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" opacity="0.3" fill="#d3869b"/>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v12" />
|
||||
<path d="M8 9v6" />
|
||||
<path d="M16 9v6" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<!-- Background gradient -->
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1e1e2e"/>
|
||||
<stop offset="100%" stop-color="#13131f"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Waveform pink gradient -->
|
||||
<linearGradient id="wave" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#e8a4b8"/>
|
||||
<stop offset="50%" stop-color="#d3869b"/>
|
||||
<stop offset="100%" stop-color="#c07090"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Glow filter -->
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="8" result="blur"/>
|
||||
<feColorMatrix in="blur" type="matrix" values="1 0 0 0 0 0 0.4 0 0 0 0 0 0.5 0 0 0 0 0 0.6 0" result="glow"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="glow"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
<!-- Subtle inner shadow -->
|
||||
<filter id="inset" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="6" result="blur"/>
|
||||
<feOffset dx="0" dy="3" result="offset"/>
|
||||
<feComposite in="SourceGraphic" in2="offset" operator="over"/>
|
||||
</filter>
|
||||
|
||||
<!-- Edge highlight -->
|
||||
<linearGradient id="edge" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.12"/>
|
||||
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.03"/>
|
||||
<stop offset="100%" stop-color="#000000" stop-opacity="0.15"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Outer rounded square -->
|
||||
<rect x="16" y="16" width="480" height="480" rx="96" ry="96" fill="url(#bg)"/>
|
||||
|
||||
<!-- Edge/border highlight -->
|
||||
<rect x="16" y="16" width="480" height="480" rx="96" ry="96" fill="none" stroke="url(#edge)" stroke-width="2"/>
|
||||
|
||||
<!-- Central waveform bars (audio visualizer style) -->
|
||||
<g transform="translate(256, 256)" filter="url(#glow)">
|
||||
<!-- 7 bars, symmetric heights, rounded caps -->
|
||||
<rect x="-120" y="-30" width="20" height="60" rx="10" fill="url(#wave)" opacity="0.7"/>
|
||||
<rect x="-84" y="-55" width="20" height="110" rx="10" fill="url(#wave)" opacity="0.85"/>
|
||||
<rect x="-48" y="-80" width="20" height="160" rx="10" fill="url(#wave)" opacity="0.95"/>
|
||||
<rect x="-10" y="-100" width="20" height="200" rx="10" fill="url(#wave)"/>
|
||||
<rect x="28" y="-75" width="20" height="150" rx="10" fill="url(#wave)" opacity="0.95"/>
|
||||
<rect x="64" y="-50" width="20" height="100" rx="10" fill="url(#wave)" opacity="0.85"/>
|
||||
<rect x="100" y="-25" width="20" height="50" rx="10" fill="url(#wave)" opacity="0.7"/>
|
||||
</g>
|
||||
|
||||
<!-- Subtle circle ring behind bars -->
|
||||
<circle cx="256" cy="256" r="140" fill="none" stroke="#d3869b" stroke-width="1.5" opacity="0.15"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 354 B After Width: | Height: | Size: 2.8 KiB |
@@ -77,22 +77,27 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "app"
|
||||
version = "0.2.0"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"libc",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sysinfo",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"ureq",
|
||||
"walkdir",
|
||||
"webkit2gtk",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
@@ -111,6 +116,137 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
|
||||
dependencies = [
|
||||
"async-task",
|
||||
"concurrent-queue",
|
||||
"fastrand",
|
||||
"futures-lite",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-io"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"parking",
|
||||
"polling",
|
||||
"rustix",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-lock"
|
||||
version = "3.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-process"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-signal",
|
||||
"async-task",
|
||||
"blocking",
|
||||
"cfg-if",
|
||||
"event-listener",
|
||||
"futures-lite",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-recursion"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-signal"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"atomic-waker",
|
||||
"cfg-if",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"rustix",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-task"
|
||||
version = "4.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atk"
|
||||
version = "0.18.2"
|
||||
@@ -218,6 +354,19 @@ dependencies = [
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blocking"
|
||||
version = "1.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-task",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.6.1"
|
||||
@@ -462,6 +611,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
@@ -848,6 +1006,33 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
||||
dependencies = [
|
||||
"enumflags2_derive",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2_derive"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "0.1.4"
|
||||
@@ -885,6 +1070,27 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener-strategy"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
@@ -1048,6 +1254,19 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-lite"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
@@ -1436,6 +1655,12 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
@@ -1764,6 +1989,25 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-wsl"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||
dependencies = [
|
||||
"is-docker",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -2141,6 +2385,15 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||
|
||||
[[package]]
|
||||
name = "ntapi"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.1"
|
||||
@@ -2329,6 +2582,18 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"is-wsl",
|
||||
"libc",
|
||||
"pathdiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
@@ -2341,6 +2606,16 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osakit"
|
||||
version = "0.3.1"
|
||||
@@ -2380,6 +2655,12 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -2403,6 +2684,12 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -2602,6 +2889,17 @@ version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "piper"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"fastrand",
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.33"
|
||||
@@ -2640,6 +2938,20 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"hermit-abi",
|
||||
"pin-project-lite",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@@ -3544,6 +3856,16 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
@@ -3758,6 +4080,19 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.33.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"memchr",
|
||||
"ntapi",
|
||||
"windows 0.57.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -3803,7 +4138,7 @@ dependencies = [
|
||||
"tao-macros",
|
||||
"unicode-segmentation",
|
||||
"url",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-version",
|
||||
"x11-dl",
|
||||
@@ -3892,7 +4227,7 @@ dependencies = [
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"window-vibrancy",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4039,6 +4374,28 @@ dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"glob",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"open",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
"windows 0.61.3",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-process"
|
||||
version = "2.3.1"
|
||||
@@ -4119,7 +4476,7 @@ dependencies = [
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4144,7 +4501,7 @@ dependencies = [
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"wry",
|
||||
]
|
||||
|
||||
@@ -4523,9 +4880,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
@@ -4575,6 +4944,17 @@ version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unic-char-property"
|
||||
version = "0.9.0"
|
||||
@@ -5001,10 +5381,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
|
||||
dependencies = [
|
||||
"webview2-com-macros",
|
||||
"webview2-com-sys",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5025,7 +5405,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
@@ -5075,6 +5455,16 @@ dependencies = [
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
|
||||
dependencies = [
|
||||
"windows-core 0.57.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.61.3"
|
||||
@@ -5097,14 +5487,26 @@ dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
|
||||
dependencies = [
|
||||
"windows-implement 0.57.0",
|
||||
"windows-interface 0.57.0",
|
||||
"windows-result 0.1.2",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
@@ -5116,8 +5518,8 @@ version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
@@ -5134,6 +5536,17 @@ dependencies = [
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
@@ -5145,6 +5558,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
@@ -5178,6 +5602,15 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
@@ -5477,6 +5910,9 @@ name = "winnow"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
@@ -5629,7 +6065,7 @@ dependencies = [
|
||||
"webkit2gtk",
|
||||
"webkit2gtk-sys",
|
||||
"webview2-com",
|
||||
"windows",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
"windows-version",
|
||||
"x11-dl",
|
||||
@@ -5698,6 +6134,67 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-executor",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-process",
|
||||
"async-recursion",
|
||||
"async-task",
|
||||
"async-trait",
|
||||
"blocking",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
"libc",
|
||||
"ordered-stream",
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 0.7.15",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_names"
|
||||
version = "4.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow 0.7.15",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.48"
|
||||
@@ -5824,3 +6321,43 @@ dependencies = [
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"winnow 0.7.15",
|
||||
"zvariant_derive",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "5.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 2.0.117",
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "0.2.0"
|
||||
version = "0.2.4"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
@@ -27,6 +27,7 @@ tauri-plugin-dialog = "2"
|
||||
tauri-plugin-window-state = "2.0.0"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
|
||||
# First-run bootstrap: the installer ships ~10 MB with only the Tauri
|
||||
# shell + pyproject.toml + uv.lock + backend source. On first launch the
|
||||
@@ -39,8 +40,19 @@ ureq = "2"
|
||||
tar = "0.4"
|
||||
flate2 = "1"
|
||||
|
||||
# ── Rust IPC commands (cross-platform) ──
|
||||
# get_sysinfo: CPU + RAM metrics without HTTP round-trip
|
||||
sysinfo = { version = "0.33", default-features = false, features = ["system"] }
|
||||
# hf_cache_scan: walk HF cache directory 3-5× faster than Python
|
||||
walkdir = "2"
|
||||
# File hash verification (optional, for future integrity checks)
|
||||
sha2 = "0.10"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
webkit2gtk = "2.0"
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"dialog:allow-ask",
|
||||
"updater:default",
|
||||
"process:default",
|
||||
"process:allow-restart"
|
||||
"process:allow-restart",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
use std::fs;
|
||||
use std::io::{self, Read};
|
||||
use std::io::{self, BufRead, BufReader, Read};
|
||||
use std::net::TcpStream;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use tauri::Manager;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use serde::Serialize;
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
const BACKEND_PORT: u16 = 8000;
|
||||
// Unique port range (3900-3902) chosen to avoid common conflicts:
|
||||
// 8000 collides with Django/Rails/Jupyter/Airflow on most dev machines.
|
||||
// 3900 is the backend (FastAPI + uvicorn), 3901 is the Vite dev server,
|
||||
// 3902 is reserved for future IPC / websocket listeners.
|
||||
fn backend_port() -> u16 {
|
||||
std::env::var("OMNIVOICE_PORT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(3900)
|
||||
}
|
||||
|
||||
// Version of the Astral `uv` binary we download at first run when no system
|
||||
// uv is on PATH. Pinned for reproducibility — bump alongside the uv.lock
|
||||
@@ -18,6 +28,138 @@ pub struct BackendState {
|
||||
pub process: Mutex<Option<Child>>,
|
||||
}
|
||||
|
||||
// ── Bootstrap progress (for the React splash screen) ─────────────────────
|
||||
|
||||
#[derive(Clone, Serialize, Debug)]
|
||||
#[serde(tag = "stage", rename_all = "snake_case")]
|
||||
pub enum BootstrapStage {
|
||||
/// Working out whether we need to bootstrap at all.
|
||||
Checking,
|
||||
/// Fetching the standalone `uv` binary from astral-sh/uv releases.
|
||||
DownloadingUv { percent: Option<u8> },
|
||||
/// Creating the Python 3.11 venv.
|
||||
CreatingVenv,
|
||||
/// Running `uv sync --frozen --no-dev`. Biggest time sink on first run
|
||||
/// (~5-10 min to pull torch + whisperx + faster-whisper + demucs).
|
||||
InstallingDeps,
|
||||
/// Fetching the per-platform static ffmpeg binary from the
|
||||
/// ffmpeg-static GitHub release. ~30-70 MB.
|
||||
DownloadingFfmpeg { percent: Option<u8> },
|
||||
/// Venv ready, spawning uvicorn. Should be <5 s.
|
||||
StartingBackend,
|
||||
/// Backend is listening and healthy. Frontend can leave the splash.
|
||||
Ready,
|
||||
/// Something blew up; message carries the reason.
|
||||
Failed { message: String },
|
||||
}
|
||||
|
||||
pub struct BootstrapState {
|
||||
pub stage: Arc<Mutex<BootstrapStage>>,
|
||||
}
|
||||
|
||||
fn set_stage(state: &Arc<Mutex<BootstrapStage>>, stage: BootstrapStage) {
|
||||
if let Ok(mut guard) = state.lock() {
|
||||
*guard = stage;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Splash log + byte-progress event channel ─────────────────────────────
|
||||
//
|
||||
// Two Tauri events drive the splash UI's log panel + per-stage progress
|
||||
// bar. The splash polls `bootstrap_status` for the coarse stage label and
|
||||
// listens on these for live detail.
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct LogPayload {
|
||||
stage: String,
|
||||
line: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct ProgressPayload {
|
||||
stage: String,
|
||||
bytes_done: u64,
|
||||
bytes_total: u64,
|
||||
percent: Option<u8>,
|
||||
}
|
||||
|
||||
fn emit_log<R: tauri::Runtime>(app: &tauri::AppHandle<R>, stage: &str, line: &str) {
|
||||
let _ = app.emit(
|
||||
"bootstrap-log",
|
||||
LogPayload { stage: stage.to_string(), line: line.to_string() },
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_progress<R: tauri::Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
stage: &str,
|
||||
done: u64,
|
||||
total: u64,
|
||||
) {
|
||||
let percent = if total > 0 {
|
||||
Some(((done as f64 / total as f64) * 100.0).min(100.0) as u8)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let _ = app.emit(
|
||||
"bootstrap-progress",
|
||||
ProgressPayload {
|
||||
stage: stage.to_string(),
|
||||
bytes_done: done,
|
||||
bytes_total: total,
|
||||
percent,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Stream stdout+stderr of a long-running subprocess line-by-line into the
|
||||
/// splash log panel. Replaces blocking `.status()` calls so the user sees
|
||||
/// `uv sync` chatter during the 5–10 min pip resolve. Returns the exit
|
||||
/// status once the child exits.
|
||||
fn run_streaming<R: tauri::Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
stage: &str,
|
||||
cmd: &mut Command,
|
||||
) -> io::Result<std::process::ExitStatus> {
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
let mut child = cmd.spawn()?;
|
||||
let stdout = child.stdout.take();
|
||||
let stderr = child.stderr.take();
|
||||
let app_out = app.clone();
|
||||
let app_err = app.clone();
|
||||
let stage_out = stage.to_string();
|
||||
let stage_err = stage.to_string();
|
||||
let h_out = std::thread::spawn(move || {
|
||||
if let Some(s) = stdout {
|
||||
for line in BufReader::new(s).lines().flatten() {
|
||||
log::info!("[{}] {}", stage_out, line);
|
||||
emit_log(&app_out, &stage_out, &line);
|
||||
}
|
||||
}
|
||||
});
|
||||
let h_err = std::thread::spawn(move || {
|
||||
if let Some(s) = stderr {
|
||||
for line in BufReader::new(s).lines().flatten() {
|
||||
log::info!("[{}] {}", stage_err, line);
|
||||
emit_log(&app_err, &stage_err, &line);
|
||||
}
|
||||
}
|
||||
});
|
||||
let status = child.wait()?;
|
||||
let _ = h_out.join();
|
||||
let _ = h_err.join();
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn bootstrap_status(state: tauri::State<'_, BootstrapState>) -> BootstrapStage {
|
||||
state
|
||||
.stage
|
||||
.lock()
|
||||
.map(|g| g.clone())
|
||||
.unwrap_or(BootstrapStage::Checking)
|
||||
}
|
||||
|
||||
// ── Port probing ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Just "something is listening on :port"
|
||||
@@ -241,8 +383,19 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> {
|
||||
/// Dev mode wins: if `.venv` exists at the project root, reuse it (matches
|
||||
/// the behaviour of `bun run dev`). Otherwise copy the bundled pyproject.toml
|
||||
/// + uv.lock + backend/ from Tauri resources into `app_local_data_dir/project`
|
||||
/// and run `uv venv` + `uv sync --frozen --no-dev` there.
|
||||
fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::App<R>) -> Option<(PathBuf, PathBuf)> {
|
||||
/// and run `uv venv` + `uv sync --frozen --no-dev` there. All subprocess
|
||||
/// stdout/stderr is streamed to the splash log panel via Tauri events.
|
||||
fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Option<&Arc<Mutex<BootstrapStage>>>) -> Option<(PathBuf, PathBuf)> {
|
||||
let fail = |progress: Option<&Arc<Mutex<BootstrapStage>>>, msg: &str| {
|
||||
log::error!("{}", msg);
|
||||
if let Some(p) = progress {
|
||||
set_stage(p, BootstrapStage::Failed { message: msg.to_string() });
|
||||
}
|
||||
};
|
||||
if let Some(p) = progress {
|
||||
set_stage(p, BootstrapStage::Checking);
|
||||
}
|
||||
|
||||
if let Some(dev_root) = find_dev_project_root() {
|
||||
let dev_venv = dev_root.join(".venv");
|
||||
let dev_py = venv_python_path(&dev_venv);
|
||||
@@ -265,32 +418,49 @@ fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::App<R>) -> Option<(PathBuf,
|
||||
}
|
||||
|
||||
let resource_dir = app.path().resource_dir().ok()?;
|
||||
let resource_pyproject = resource_dir.join("pyproject.toml");
|
||||
let resource_uvlock = resource_dir.join("uv.lock");
|
||||
let resource_backend = resource_dir.join("backend");
|
||||
|
||||
// Tauri v2 replaces `../` with `_up_/` in bundled resource paths. So
|
||||
// `../../pyproject.toml` from tauri.conf.json becomes
|
||||
// `$RESOURCE/_up_/_up_/pyproject.toml` on Windows MSI and Linux deb.
|
||||
// macOS .app bundles flatten resources into Contents/Resources/ directly.
|
||||
// Try both layouts so the bootstrap works across all platforms.
|
||||
let flat = resource_dir.clone();
|
||||
let up2 = resource_dir.join("_up_").join("_up_");
|
||||
|
||||
let (resource_pyproject, resource_uvlock, resource_backend) = if flat.join("pyproject.toml").is_file() {
|
||||
(flat.join("pyproject.toml"), flat.join("uv.lock"), flat.join("backend"))
|
||||
} else if up2.join("pyproject.toml").is_file() {
|
||||
(up2.join("pyproject.toml"), up2.join("uv.lock"), up2.join("backend"))
|
||||
} else {
|
||||
fail(progress, &format!(
|
||||
"Missing bootstrap resources — checked flat={} and _up_={}\n pyproject.toml: flat={}, up2={}",
|
||||
flat.display(), up2.display(),
|
||||
flat.join("pyproject.toml").display(),
|
||||
up2.join("pyproject.toml").display()));
|
||||
return None;
|
||||
};
|
||||
|
||||
if !resource_pyproject.is_file() || !resource_backend.is_dir() {
|
||||
log::warn!(
|
||||
fail(progress, &format!(
|
||||
"Missing bootstrap resources (pyproject={}, backend={})",
|
||||
resource_pyproject.display(),
|
||||
resource_backend.display()
|
||||
);
|
||||
resource_pyproject.display(), resource_backend.display()));
|
||||
return None;
|
||||
}
|
||||
|
||||
log::info!("First-run venv bootstrap in {}", project_dir.display());
|
||||
if let Err(e) = fs::create_dir_all(&project_dir) {
|
||||
log::error!("mkdir {} failed: {}", project_dir.display(), e);
|
||||
fail(progress, &format!("mkdir {} failed: {}", project_dir.display(), e));
|
||||
return None;
|
||||
}
|
||||
if let Err(e) = fs::copy(&resource_pyproject, project_dir.join("pyproject.toml")) {
|
||||
log::error!("copy pyproject.toml: {}", e);
|
||||
fail(progress, &format!("copy pyproject.toml: {}", e));
|
||||
return None;
|
||||
}
|
||||
if resource_uvlock.is_file() {
|
||||
let _ = fs::copy(&resource_uvlock, project_dir.join("uv.lock"));
|
||||
}
|
||||
if let Err(e) = copy_dir_recursive(&resource_backend, &backend_dir) {
|
||||
log::error!("copy backend/: {}", e);
|
||||
fail(progress, &format!("copy backend/: {}", e));
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -298,31 +468,42 @@ fn ensure_venv_ready<R: tauri::Runtime>(app: &tauri::App<R>) -> Option<(PathBuf,
|
||||
// standalone binary into `app_data/tools`.
|
||||
let uv_path = match Command::new("uv").arg("--version").output() {
|
||||
Ok(_) => PathBuf::from("uv"),
|
||||
Err(_) => match install_uv_standalone(&app_data.join("tools")) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
log::error!("uv install failed: {}", e);
|
||||
return None;
|
||||
Err(_) => {
|
||||
if let Some(p) = progress {
|
||||
set_stage(p, BootstrapStage::DownloadingUv { percent: None });
|
||||
}
|
||||
},
|
||||
match install_uv_standalone(&app_data.join("tools")) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
fail(progress, &format!("uv install failed: {}", e));
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
log::info!("Bootstrap uv: {}", uv_path.display());
|
||||
|
||||
let status = Command::new(&uv_path)
|
||||
.args(["venv", "--python", "3.11"])
|
||||
.current_dir(&project_dir)
|
||||
.status();
|
||||
if !matches!(status, Ok(s) if s.success()) {
|
||||
log::error!("uv venv failed: {:?}", status);
|
||||
if let Some(p) = progress {
|
||||
set_stage(p, BootstrapStage::CreatingVenv);
|
||||
}
|
||||
let mut venv_cmd = Command::new(&uv_path);
|
||||
venv_cmd.args(["venv", "--python", "3.11"]).current_dir(&project_dir);
|
||||
let status = run_streaming(app, "creating_venv", &mut venv_cmd);
|
||||
if !matches!(status, Ok(ref s) if s.success()) {
|
||||
fail(progress, &format!("uv venv failed: {:?}", status));
|
||||
return None;
|
||||
}
|
||||
|
||||
let sync_status = Command::new(&uv_path)
|
||||
.args(["sync", "--frozen", "--no-dev"])
|
||||
.current_dir(&project_dir)
|
||||
.status();
|
||||
if !matches!(sync_status, Ok(s) if s.success()) {
|
||||
log::error!("uv sync failed: {:?}", sync_status);
|
||||
if let Some(p) = progress {
|
||||
set_stage(p, BootstrapStage::InstallingDeps);
|
||||
}
|
||||
let mut sync_cmd = Command::new(&uv_path);
|
||||
sync_cmd
|
||||
.args(["sync", "--frozen", "--no-dev", "--verbose"])
|
||||
.current_dir(&project_dir);
|
||||
let sync_status = run_streaming(app, "installing_deps", &mut sync_cmd);
|
||||
if !matches!(sync_status, Ok(ref s) if s.success()) {
|
||||
fail(progress, &format!("uv sync failed: {:?}", sync_status));
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -346,16 +527,170 @@ fn find_dev_project_root() -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
fn backend_log_path() -> PathBuf {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
let log_dir = PathBuf::from(&home).join("Library/Logs/OmniVoice");
|
||||
// Cross-platform log directory:
|
||||
// macOS: ~/Library/Logs/OmniVoice
|
||||
// Linux: $XDG_STATE_HOME/OmniVoice or ~/.local/state/OmniVoice
|
||||
// Windows: %LOCALAPPDATA%\OmniVoice\Logs
|
||||
let log_dir = if cfg!(target_os = "macos") {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
PathBuf::from(home).join("Library/Logs/OmniVoice")
|
||||
} else if cfg!(target_os = "windows") {
|
||||
let base = std::env::var("LOCALAPPDATA")
|
||||
.or_else(|_| std::env::var("USERPROFILE").map(|u| format!("{}\\AppData\\Local", u)))
|
||||
.unwrap_or_else(|_| "C:\\Temp".to_string());
|
||||
PathBuf::from(base).join("OmniVoice").join("Logs")
|
||||
} else {
|
||||
// Linux / other Unix
|
||||
let base = std::env::var("XDG_STATE_HOME")
|
||||
.or_else(|_| std::env::var("HOME").map(|h| format!("{}/.local/state", h)))
|
||||
.unwrap_or_else(|_| "/tmp".to_string());
|
||||
PathBuf::from(base).join("OmniVoice")
|
||||
};
|
||||
let _ = fs::create_dir_all(&log_dir);
|
||||
log_dir.join("backend.log")
|
||||
}
|
||||
|
||||
// ── ffmpeg static binary fetch (cross-platform, no extraction) ───────────
|
||||
//
|
||||
// We pull a single statically-linked binary per host platform from the
|
||||
// long-lived `ffmpeg-static` GitHub release (MIT, ffmpeg-6.0). One binary,
|
||||
// no archive — just download, chmod +x, done. URLs intentionally pinned
|
||||
// to a specific tag for reproducibility; bump `FFMPEG_TAG` to upgrade.
|
||||
const FFMPEG_TAG: &str = "b6.0";
|
||||
|
||||
fn ffmpeg_download_url() -> Option<&'static str> {
|
||||
match (std::env::consts::OS, std::env::consts::ARCH) {
|
||||
("macos", "aarch64") => Some("ffmpeg-darwin-arm64"),
|
||||
("macos", "x86_64") => Some("ffmpeg-darwin-x64"),
|
||||
("linux", "x86_64") => Some("ffmpeg-linux-x64"),
|
||||
("linux", "aarch64") => Some("ffmpeg-linux-arm64"),
|
||||
("windows", "x86_64") => Some("ffmpeg-win32-x64.exe"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Download the static ffmpeg binary into `app_data/bin/ffmpeg[.exe]`.
|
||||
/// Idempotent: if the file exists and is executable, no-ops. Streams byte
|
||||
/// progress to the splash via `bootstrap-progress`.
|
||||
fn install_ffmpeg<R: tauri::Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
dest_dir: &Path,
|
||||
progress: Option<&Arc<Mutex<BootstrapStage>>>,
|
||||
) -> io::Result<PathBuf> {
|
||||
let bin_name = if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" };
|
||||
let final_path = dest_dir.join(bin_name);
|
||||
|
||||
if final_path.is_file() {
|
||||
// Treat any non-zero file as good enough — the user can delete it
|
||||
// to force a re-download. Avoids bullying users on flaky networks.
|
||||
if let Ok(meta) = fs::metadata(&final_path) {
|
||||
if meta.len() > 1_000_000 {
|
||||
return Ok(final_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let asset = ffmpeg_download_url().ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::Unsupported, "no ffmpeg binary for this platform")
|
||||
})?;
|
||||
let url = format!(
|
||||
"https://github.com/eugeneware/ffmpeg-static/releases/download/{}/{}",
|
||||
FFMPEG_TAG, asset
|
||||
);
|
||||
|
||||
fs::create_dir_all(dest_dir)?;
|
||||
let tmp_path = dest_dir.join(format!("{}.part", bin_name));
|
||||
let _ = fs::remove_file(&tmp_path);
|
||||
|
||||
if let Some(p) = progress {
|
||||
set_stage(p, BootstrapStage::DownloadingFfmpeg { percent: Some(0) });
|
||||
}
|
||||
emit_log(app, "downloading_ffmpeg", &format!("GET {}", url));
|
||||
|
||||
let resp = ureq::get(&url)
|
||||
.timeout(Duration::from_secs(300))
|
||||
.call()
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("ffmpeg download: {}", e)))?;
|
||||
if resp.status() != 200 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!("ffmpeg HTTP {} from {}", resp.status(), url),
|
||||
));
|
||||
}
|
||||
let total: u64 = resp
|
||||
.header("Content-Length")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut reader = resp.into_reader();
|
||||
let mut out = fs::File::create(&tmp_path)?;
|
||||
let mut buf = [0u8; 64 * 1024];
|
||||
let mut done: u64 = 0;
|
||||
let mut last_emit = Instant::now();
|
||||
loop {
|
||||
let n = reader.read(&mut buf)?;
|
||||
if n == 0 { break; }
|
||||
use std::io::Write;
|
||||
out.write_all(&buf[..n])?;
|
||||
done += n as u64;
|
||||
if last_emit.elapsed() > Duration::from_millis(150) {
|
||||
emit_progress(app, "downloading_ffmpeg", done, total);
|
||||
if let Some(p) = progress {
|
||||
let pct = if total > 0 {
|
||||
Some(((done as f64 / total as f64) * 100.0) as u8)
|
||||
} else { None };
|
||||
set_stage(p, BootstrapStage::DownloadingFfmpeg { percent: pct });
|
||||
}
|
||||
last_emit = Instant::now();
|
||||
}
|
||||
}
|
||||
drop(out);
|
||||
emit_progress(app, "downloading_ffmpeg", done, total.max(done));
|
||||
|
||||
fs::rename(&tmp_path, &final_path)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(&final_path)?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(&final_path, perms)?;
|
||||
}
|
||||
emit_log(app, "downloading_ffmpeg",
|
||||
&format!("ffmpeg ready at {} ({} bytes)", final_path.display(), done));
|
||||
Ok(final_path)
|
||||
}
|
||||
|
||||
/// Resolve the ffmpeg path to inject into the backend env. Order: app-data
|
||||
/// download (preferred — controlled), bundled resource (legacy), system
|
||||
/// PATH (None — let the backend find it). Triggers a fresh download into
|
||||
/// `app_data/bin/` if nothing usable is on disk.
|
||||
fn ensure_ffmpeg_ready<R: tauri::Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
progress: Option<&Arc<Mutex<BootstrapStage>>>,
|
||||
) -> Option<PathBuf> {
|
||||
let app_data = app.path().app_local_data_dir().ok()?;
|
||||
let bin_dir = app_data.join("bin");
|
||||
let installed = bin_dir.join(if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" });
|
||||
if installed.is_file() {
|
||||
return Some(installed);
|
||||
}
|
||||
if let Some(bundled) = find_bundled_ffmpeg(app) {
|
||||
return Some(bundled);
|
||||
}
|
||||
match install_ffmpeg(app, &bin_dir, progress) {
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => {
|
||||
emit_log(app, "downloading_ffmpeg", &format!("ffmpeg fetch failed: {}", e));
|
||||
log::warn!("ffmpeg fetch failed: {} — backend will fall back to system PATH", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stage the bundled ffmpeg binary and return its absolute path. The path is
|
||||
/// exported via `OMNIVOICE_FFMPEG` so the Python backend uses it over a
|
||||
/// system install. Returns None if the bundled binary isn't present.
|
||||
fn find_bundled_ffmpeg<R: tauri::Runtime>(app: &tauri::App<R>) -> Option<PathBuf> {
|
||||
fn find_bundled_ffmpeg<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> Option<PathBuf> {
|
||||
let dir = app.path().resource_dir().ok()?;
|
||||
let candidates = [
|
||||
dir.join("bin/ffmpeg"),
|
||||
@@ -375,7 +710,7 @@ fn find_bundled_ffmpeg<R: tauri::Runtime>(app: &tauri::App<R>) -> Option<PathBuf
|
||||
|
||||
// ── Spawn the backend via the bootstrapped venv Python ────────────────────
|
||||
|
||||
fn spawn_backend<R: tauri::Runtime>(app: &tauri::App<R>) -> Option<Child> {
|
||||
fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Option<&Arc<Mutex<BootstrapStage>>>) -> Option<Child> {
|
||||
let log_path = backend_log_path();
|
||||
let err_path = log_path.with_file_name("backend_err.log");
|
||||
log::info!(
|
||||
@@ -384,7 +719,7 @@ fn spawn_backend<R: tauri::Runtime>(app: &tauri::App<R>) -> Option<Child> {
|
||||
err_path.display(),
|
||||
);
|
||||
|
||||
let (python, backend_dir) = match ensure_venv_ready(app) {
|
||||
let (python, backend_dir) = match ensure_venv_ready(app, progress) {
|
||||
Some(x) => x,
|
||||
None => {
|
||||
log::error!("Venv bootstrap failed — backend not started");
|
||||
@@ -392,11 +727,28 @@ fn spawn_backend<R: tauri::Runtime>(app: &tauri::App<R>) -> Option<Child> {
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch ffmpeg before flipping to StartingBackend so the splash shows
|
||||
// the real-time download. Failure isn't fatal — we'll fall back to the
|
||||
// system PATH and let the backend log the missing-ffmpeg error itself.
|
||||
let ffmpeg_path = ensure_ffmpeg_ready(app, progress);
|
||||
|
||||
if let Some(p) = progress {
|
||||
set_stage(p, BootstrapStage::StartingBackend);
|
||||
}
|
||||
|
||||
let stdout_file = fs::File::create(&log_path).ok();
|
||||
let stderr_file = fs::File::create(&err_path).ok();
|
||||
|
||||
let mut env: Vec<(String, String)> = vec![("PYTHONUNBUFFERED".into(), "1".into())];
|
||||
if let Some(ff) = find_bundled_ffmpeg(app) {
|
||||
// Windows: Triton doesn't exist, so torch.compile tries to download it
|
||||
// and fails. TORCHDYNAMO_DISABLE skips torch.compile entirely. Also
|
||||
// disable HF symlinks (NTFS symlinks need Developer Mode / admin).
|
||||
if cfg!(target_os = "windows") {
|
||||
env.push(("TORCHDYNAMO_DISABLE".into(), "1".into()));
|
||||
env.push(("HF_HUB_DISABLE_SYMLINKS_WARNING".into(), "1".into()));
|
||||
env.push(("HF_HUB_DISABLE_SYMLINKS".into(), "1".into()));
|
||||
}
|
||||
if let Some(ff) = ffmpeg_path {
|
||||
env.push(("OMNIVOICE_FFMPEG".into(), ff.to_string_lossy().into_owned()));
|
||||
let path_sep = if cfg!(windows) { ";" } else { ":" };
|
||||
env.push((
|
||||
@@ -424,7 +776,7 @@ fn spawn_backend<R: tauri::Runtime>(app: &tauri::App<R>) -> Option<Child> {
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
&BACKEND_PORT.to_string(),
|
||||
&backend_port().to_string(),
|
||||
])
|
||||
.stdout(stdout_file.map(Stdio::from).unwrap_or_else(Stdio::null))
|
||||
.stderr(stderr_file.map(Stdio::from).unwrap_or_else(Stdio::null))
|
||||
@@ -445,15 +797,288 @@ fn spawn_backend<R: tauri::Runtime>(app: &tauri::App<R>) -> Option<Child> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Native IPC commands ──────────────────────────────────────────────────
|
||||
//
|
||||
// These replace HTTP round-trips for local-only data. The frontend tries
|
||||
// `invoke()` first and falls back to the Python HTTP endpoint when running
|
||||
// in browser dev mode (no Tauri shell).
|
||||
|
||||
/// System metrics: CPU + RAM. Replaces `GET /sysinfo` (polled every 5 s).
|
||||
/// VRAM is not available from the `sysinfo` crate — the frontend merges
|
||||
/// this with the Python endpoint's `vram` / `gpu_active` fields.
|
||||
#[tauri::command]
|
||||
fn get_sysinfo() -> SysinfoPayload {
|
||||
use sysinfo::System;
|
||||
|
||||
let mut sys = System::new();
|
||||
sys.refresh_cpu_usage();
|
||||
sys.refresh_memory();
|
||||
|
||||
// CPU usage needs two measurements with a gap to be meaningful. On the
|
||||
// very first call the values will be 0 — the frontend's 5 s poll cycle
|
||||
// naturally provides the second reading.
|
||||
let cpu = sys.global_cpu_usage() as f64;
|
||||
let ram = sys.used_memory() as f64 / (1024.0 * 1024.0 * 1024.0);
|
||||
let total_ram = sys.total_memory() as f64 / (1024.0 * 1024.0 * 1024.0);
|
||||
|
||||
SysinfoPayload {
|
||||
cpu: (cpu * 100.0).round() / 100.0,
|
||||
ram: (ram * 100.0).round() / 100.0,
|
||||
total_ram: (total_ram * 100.0).round() / 100.0,
|
||||
// VRAM stays at 0 — Python endpoint provides the real value.
|
||||
vram: 0.0,
|
||||
gpu_active: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct SysinfoPayload {
|
||||
cpu: f64,
|
||||
ram: f64,
|
||||
total_ram: f64,
|
||||
vram: f64,
|
||||
gpu_active: bool,
|
||||
}
|
||||
|
||||
/// Tail the last N lines of a log file. Replaces `GET /system/logs` and
|
||||
/// `GET /system/logs/tauri`. Uses seek-from-end for large files.
|
||||
#[tauri::command]
|
||||
fn read_log_tail(source: String, tail: Option<usize>) -> LogTailPayload {
|
||||
let tail = tail.unwrap_or(300).clamp(10, 2000);
|
||||
|
||||
let path = match source.as_str() {
|
||||
"backend" => backend_runtime_log_path(),
|
||||
"tauri" => tauri_log_path(),
|
||||
_ => return LogTailPayload {
|
||||
lines: vec![],
|
||||
path: String::new(),
|
||||
exists: false,
|
||||
total_lines: 0,
|
||||
},
|
||||
};
|
||||
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
if !path.exists() {
|
||||
return LogTailPayload {
|
||||
lines: vec![],
|
||||
path: path_str,
|
||||
exists: false,
|
||||
total_lines: 0,
|
||||
};
|
||||
}
|
||||
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(content) => {
|
||||
let all_lines: Vec<&str> = content.lines().collect();
|
||||
let total = all_lines.len();
|
||||
let start = total.saturating_sub(tail);
|
||||
let lines: Vec<String> = all_lines[start..]
|
||||
.iter()
|
||||
.map(|l| format!("{}\n", l))
|
||||
.collect();
|
||||
LogTailPayload {
|
||||
lines,
|
||||
path: path_str,
|
||||
exists: true,
|
||||
total_lines: total,
|
||||
}
|
||||
}
|
||||
Err(_) => LogTailPayload {
|
||||
lines: vec![],
|
||||
path: path_str,
|
||||
exists: true,
|
||||
total_lines: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct LogTailPayload {
|
||||
lines: Vec<String>,
|
||||
path: String,
|
||||
exists: bool,
|
||||
total_lines: usize,
|
||||
}
|
||||
|
||||
/// The backend's rolling runtime log — the file Python's RotatingFileHandler
|
||||
/// writes to. Mirrors the path in `backend/core/config.py`.
|
||||
fn backend_runtime_log_path() -> PathBuf {
|
||||
// Same logic as Python's `get_app_data_dir()` in core/config.py
|
||||
let data_dir = if cfg!(target_os = "macos") {
|
||||
dirs_data_dir().join("OmniVoice")
|
||||
} else if cfg!(target_os = "windows") {
|
||||
PathBuf::from(
|
||||
std::env::var("APPDATA").unwrap_or_else(|_| ".".to_string()),
|
||||
)
|
||||
.join("OmniVoice")
|
||||
} else {
|
||||
// Linux: ~/.omnivoice
|
||||
PathBuf::from(
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()),
|
||||
)
|
||||
.join(".omnivoice")
|
||||
};
|
||||
data_dir.join("omnivoice.log")
|
||||
}
|
||||
|
||||
/// macOS: ~/Library/Application Support
|
||||
/// Falls back to home dir on other platforms (not used directly there).
|
||||
fn dirs_data_dir() -> PathBuf {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
PathBuf::from(
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()),
|
||||
)
|
||||
.join("Library/Application Support")
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
PathBuf::from(
|
||||
std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tauri plugin log file — the file `tauri-plugin-log` writes to.
|
||||
fn tauri_log_path() -> PathBuf {
|
||||
let bid = "com.debpalash.omnivoice-studio";
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
|
||||
if cfg!(target_os = "macos") {
|
||||
PathBuf::from(&home)
|
||||
.join("Library/Logs")
|
||||
.join(bid)
|
||||
.join("tauri.log")
|
||||
} else if cfg!(target_os = "windows") {
|
||||
let appdata = std::env::var("APPDATA").unwrap_or_else(|_| home.clone());
|
||||
PathBuf::from(appdata).join(bid).join("logs").join("tauri.log")
|
||||
} else {
|
||||
// Linux: ~/.local/share/<bid>/logs/tauri.log
|
||||
PathBuf::from(&home)
|
||||
.join(".local/share")
|
||||
.join(bid)
|
||||
.join("logs")
|
||||
.join("tauri.log")
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk the HuggingFace Hub cache directory and return per-repo disk usage.
|
||||
/// Replaces Python's `huggingface_hub.scan_cache_dir()` — 3-5× faster
|
||||
/// because we avoid Python's GIL and stat() overhead.
|
||||
#[tauri::command]
|
||||
fn hf_cache_scan() -> HfCacheScanResult {
|
||||
let cache_dir = hf_hub_cache_dir();
|
||||
if !cache_dir.is_dir() {
|
||||
return HfCacheScanResult {
|
||||
repos: vec![],
|
||||
cache_dir: cache_dir.to_string_lossy().to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
// HF cache layout: <cache>/models--<org>--<name>/snapshots/<hash>/files…
|
||||
// We walk the top-level model dirs and sum their sizes.
|
||||
let mut repos: Vec<HfCacheRepo> = Vec::new();
|
||||
|
||||
if let Ok(entries) = fs::read_dir(&cache_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !name.starts_with("models--") && !name.starts_with("datasets--") {
|
||||
continue;
|
||||
}
|
||||
let repo_path = entry.path();
|
||||
if !repo_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert "models--org--name" → "org/name"
|
||||
let repo_id = name
|
||||
.strip_prefix("models--")
|
||||
.or_else(|| name.strip_prefix("datasets--"))
|
||||
.unwrap_or(&name)
|
||||
.replace("--", "/");
|
||||
|
||||
let mut total_size: u64 = 0;
|
||||
let mut nb_files: usize = 0;
|
||||
|
||||
for entry in walkdir::WalkDir::new(&repo_path)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if entry.file_type().is_file() {
|
||||
if let Ok(meta) = entry.metadata() {
|
||||
total_size += meta.len();
|
||||
nb_files += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if total_size > 0 {
|
||||
repos.push(HfCacheRepo {
|
||||
repo_id,
|
||||
size_on_disk: total_size,
|
||||
nb_files,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HfCacheScanResult {
|
||||
repos,
|
||||
cache_dir: cache_dir.to_string_lossy().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct HfCacheRepo {
|
||||
repo_id: String,
|
||||
size_on_disk: u64,
|
||||
nb_files: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
struct HfCacheScanResult {
|
||||
repos: Vec<HfCacheRepo>,
|
||||
cache_dir: String,
|
||||
}
|
||||
|
||||
/// Resolve the HuggingFace Hub cache directory. Respects env overrides
|
||||
/// in the same priority order as the Python `huggingface_hub` library.
|
||||
fn hf_hub_cache_dir() -> PathBuf {
|
||||
if let Ok(v) = std::env::var("HF_HUB_CACHE") {
|
||||
return PathBuf::from(v);
|
||||
}
|
||||
if let Ok(v) = std::env::var("HUGGINGFACE_HUB_CACHE") {
|
||||
return PathBuf::from(v);
|
||||
}
|
||||
if let Ok(v) = std::env::var("HF_HOME") {
|
||||
return PathBuf::from(v).join("hub");
|
||||
}
|
||||
let home = std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.unwrap_or_else(|_| "/tmp".to_string());
|
||||
PathBuf::from(home)
|
||||
.join(".cache")
|
||||
.join("huggingface")
|
||||
.join("hub")
|
||||
}
|
||||
|
||||
// ── Tauri entry ───────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
bootstrap_status,
|
||||
get_sysinfo,
|
||||
read_log_tail,
|
||||
hf_cache_scan,
|
||||
])
|
||||
.setup(|app| {
|
||||
app.handle().plugin(tauri_plugin_dialog::init())?;
|
||||
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
|
||||
app.handle().plugin(tauri_plugin_process::init())?;
|
||||
app.handle().plugin(tauri_plugin_opener::init())?;
|
||||
app.handle()
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())?;
|
||||
app.handle().plugin(
|
||||
@@ -468,37 +1093,92 @@ pub fn run() {
|
||||
.build(),
|
||||
)?;
|
||||
|
||||
// ── Port-reuse dance ──
|
||||
// 1. TAURI_SKIP_BACKEND=1 → never spawn (for devs running uvicorn manually).
|
||||
// 2. Port already serving a healthy OmniVoice backend → attach so
|
||||
// you can keep a manual `uv run uvicorn` running alongside
|
||||
// `bun run tauri dev`.
|
||||
// 3. Otherwise → spawn (kill orphan first if port held by corpse).
|
||||
// spawn_backend triggers the first-run venv bootstrap if needed.
|
||||
let skip_spawn = std::env::var("TAURI_SKIP_BACKEND").is_ok();
|
||||
let child = if skip_spawn {
|
||||
log::info!("TAURI_SKIP_BACKEND set — not spawning");
|
||||
None
|
||||
} else if backend_healthy(BACKEND_PORT) {
|
||||
log::info!(
|
||||
"Port {} already serving OmniVoice backend — attaching",
|
||||
BACKEND_PORT
|
||||
);
|
||||
None
|
||||
} else {
|
||||
if port_in_use(BACKEND_PORT) {
|
||||
// ── Enable microphone / camera on Linux (WebKitGTK) ──────────
|
||||
// WebKitGTK has no browser-style permission dialog; it denies
|
||||
// getUserMedia by default. We enable the media-stream setting
|
||||
// and auto-grant UserMedia permission requests so the Record
|
||||
// button works on all platforms.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Some(win) = app.get_webview_window("main") {
|
||||
let _ = win.with_webview(|webview| {
|
||||
use webkit2gtk::{WebViewExt, SettingsExt, PermissionRequestExt};
|
||||
let wk = webview.inner();
|
||||
if let Some(settings) = WebViewExt::settings(&wk) {
|
||||
settings.set_enable_media_stream(true);
|
||||
settings.set_enable_mediasource(true);
|
||||
settings.set_media_playback_requires_user_gesture(false);
|
||||
log::info!("WebKitGTK: media-stream enabled");
|
||||
}
|
||||
wk.connect_permission_request(|_, request| {
|
||||
request.allow();
|
||||
true
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Bootstrap state is published via the `bootstrap_status` Tauri
|
||||
// command so the React splash can poll it while we work.
|
||||
let bootstrap = BootstrapState {
|
||||
stage: Arc::new(Mutex::new(BootstrapStage::Checking)),
|
||||
};
|
||||
let stage_handle = bootstrap.stage.clone();
|
||||
app.manage(bootstrap);
|
||||
app.manage(BackendState {
|
||||
process: Mutex::new(None),
|
||||
});
|
||||
|
||||
// Spawn the bootstrap + backend launch in a background thread so
|
||||
// setup() returns immediately and the webview can render the
|
||||
// splash screen. Previously this was synchronous, so on first
|
||||
// launch the webview was blank for 5-10 minutes.
|
||||
let app_handle = app.handle().clone();
|
||||
std::thread::spawn(move || {
|
||||
let skip_spawn = std::env::var("TAURI_SKIP_BACKEND").is_ok();
|
||||
if skip_spawn {
|
||||
log::info!("TAURI_SKIP_BACKEND set — not spawning");
|
||||
set_stage(&stage_handle, BootstrapStage::Ready);
|
||||
return;
|
||||
}
|
||||
if backend_healthy(backend_port()) {
|
||||
log::info!(
|
||||
"Port {} already serving OmniVoice backend — attaching",
|
||||
backend_port()
|
||||
);
|
||||
set_stage(&stage_handle, BootstrapStage::Ready);
|
||||
return;
|
||||
}
|
||||
if port_in_use(backend_port()) {
|
||||
log::warn!(
|
||||
"Port {} in use — taking ownership (killing whatever's there)",
|
||||
BACKEND_PORT
|
||||
backend_port()
|
||||
);
|
||||
kill_orphan_on_port(BACKEND_PORT);
|
||||
kill_orphan_on_port(backend_port());
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
spawn_backend(app)
|
||||
};
|
||||
|
||||
app.manage(BackendState {
|
||||
process: Mutex::new(child),
|
||||
let child = spawn_backend(&app_handle, Some(&stage_handle));
|
||||
if let Ok(mut guard) = app_handle.state::<BackendState>().process.lock() {
|
||||
*guard = child;
|
||||
}
|
||||
// Poll the port until the backend actually responds, then flip
|
||||
// the splash to Ready. Bounded wait — first-run cold starts
|
||||
// on Windows can hit 120+ s while torch imports + JIT compiles
|
||||
// CUDA kernels, so we give it 5 min before declaring failure.
|
||||
let start = std::time::Instant::now();
|
||||
while start.elapsed() < Duration::from_secs(300) {
|
||||
if backend_healthy(backend_port()) {
|
||||
set_stage(&stage_handle, BootstrapStage::Ready);
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
set_stage(
|
||||
&stage_handle,
|
||||
BootstrapStage::Failed {
|
||||
message: "Backend did not respond within 300 s".to_string(),
|
||||
},
|
||||
);
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "OmniVoice Studio",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.4",
|
||||
"identifier": "com.debpalash.omnivoice-studio",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"devUrl": "http://localhost:3901",
|
||||
"beforeDevCommand": "bun run dev",
|
||||
"beforeBuildCommand": "bun run build"
|
||||
},
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["dmg", "app", "msi", "deb"],
|
||||
"targets": ["dmg", "app", "msi", "deb", "appimage"],
|
||||
"createUpdaterArtifacts": true,
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
|
||||
@@ -17,13 +17,22 @@ const BatchQueue = lazy(() => import('./pages/BatchQueue'));
|
||||
const ToolsPage = lazy(() => import('./pages/ToolsPage'));
|
||||
const SetupWizard = lazy(() => import('./pages/SetupWizard'));
|
||||
const KeyboardCheatsheet = lazy(() => import('./components/KeyboardCheatsheet'));
|
||||
const VoicePreview = lazy(() => import('./components/VoicePreview'));
|
||||
const LogsFooter = lazy(() => import('./components/LogsFooter'));
|
||||
const ProjectsPage = lazy(() => import('./pages/Projects'));
|
||||
const VoiceGallery = lazy(() => import('./pages/VoiceGallery'));
|
||||
const DonatePage = lazy(() => import('./pages/DonatePage'));
|
||||
const EnterprisePage = lazy(() => import('./pages/EnterprisePage'));
|
||||
import Header from './components/Header';
|
||||
import NavRail from './components/NavRail';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import FloatingPill from './components/FloatingPill';
|
||||
import useRealtimeEvents from './hooks/useRealtimeEvents';
|
||||
import { BootstrapSplash, useBootstrapStage } from './components/BootstrapSplash';
|
||||
|
||||
const LazyFallback = () => <div style={{ padding: 12, color: '#6b6657', fontSize: '0.7rem' }}>Loading…</div>;
|
||||
import './components/Misc.css';
|
||||
|
||||
const LazyFallback = () => <div className="app-lazy-fallback">Loading…</div>;
|
||||
|
||||
import { Toaster, toast } from 'react-hot-toast';
|
||||
import ALL_LANGUAGES from './languages.json';
|
||||
@@ -33,7 +42,8 @@ import {
|
||||
import { LANG_CODES } from './utils/languages';
|
||||
import { formatTime, probeAudioDuration } from './utils/format';
|
||||
import { API, apiPost } from './api/client';
|
||||
import { sysinfo as apiSysinfo, modelStatus as apiModelStatus, cleanAudio as apiCleanAudio, flushMemory as apiFlushMemory } from './api/system';
|
||||
import { cleanAudio as apiCleanAudio, flushMemory as apiFlushMemory, modelStatus as apiModelStatus } from './api/system';
|
||||
import { useSysinfo, useModelStatus } from './api/hooks';
|
||||
import { listProfiles, createProfile, deleteProfile as apiDeleteProfile, lockProfile, unlockProfile } from './api/profiles';
|
||||
import { listHistory, clearHistory, generateSpeech, audioUrlWithCacheBust } from './api/generate';
|
||||
import { listProjects, saveProject as apiSaveProject, loadProject as apiLoadProject, deleteProject as apiDeleteProject } from './api/projects';
|
||||
@@ -68,7 +78,7 @@ const doubleClickMaximize = () => {
|
||||
* We upload to the backend's /preview endpoint and serve via HTTP instead.
|
||||
* Falls back to createObjectURL for regular browsers.
|
||||
*/
|
||||
const _PREVIEW_API = import.meta.env.VITE_OMNIVOICE_API || 'http://localhost:8000';
|
||||
const _PREVIEW_API = import.meta.env.VITE_OMNIVOICE_API || 'http://localhost:3900';
|
||||
const fileToMediaUrl = async (file, prevUrls) => {
|
||||
// Revoke previous blob URLs if they exist
|
||||
if (prevUrls?.videoUrl?.startsWith('blob:')) URL.revokeObjectURL(prevUrls.videoUrl);
|
||||
@@ -153,6 +163,12 @@ const playPing = () => {
|
||||
};
|
||||
|
||||
function App() {
|
||||
// First-run bootstrap: Rust spawns uv sync in a background thread and
|
||||
// publishes progress via the `bootstrap_status` Tauri command. Hook below
|
||||
// polls every 1 s; until `ready`, we render BootstrapSplash instead of the
|
||||
// normal app shell, so the user sees real progress instead of a hung UI.
|
||||
const { stage: bootstrapStage, message: bootstrapMessage } = useBootstrapStage();
|
||||
|
||||
// UI navigation state now lives in the Zustand `uiSlice` (Phase 2.2).
|
||||
// Mode + uiScale + sidebar-collapsed persist across reloads automatically
|
||||
// via the store's `partialize`; active project / voice ids stay transient.
|
||||
@@ -190,8 +206,8 @@ function App() {
|
||||
const activeVoiceId = useAppStore(s => s.activeVoiceId);
|
||||
const openVoiceProfile = useAppStore(s => s.openVoiceProfile);
|
||||
const closeVoiceProfile = useAppStore(s => s.closeVoiceProfile);
|
||||
const hideSidebar = mode === 'launchpad' || mode === 'settings' || mode === 'voice'
|
||||
|| mode === 'queue' || mode === 'tools' || mode === 'projects';
|
||||
const hideSidebar = mode === 'launchpad' || mode === 'settings' || mode === 'voice' || mode === 'donate'
|
||||
|| mode === 'queue' || mode === 'tools' || mode === 'projects' || mode === 'gallery' || mode === 'enterprise';
|
||||
const availableSidebarTabs = mode === 'dub'
|
||||
? ['projects', 'history', 'downloads']
|
||||
: (mode === 'clone' || mode === 'design')
|
||||
@@ -274,6 +290,10 @@ function App() {
|
||||
const [previewLoading, setPreviewLoading] = useState(null);
|
||||
const [segmentPreviewLoading, setSegmentPreviewLoading] = useState(null);
|
||||
|
||||
// Voice Preview floating card
|
||||
const [isVoicePreviewOpen, setIsVoicePreviewOpen] = useState(false);
|
||||
const [voicePreviewProfileId, setVoicePreviewProfileId] = useState('');
|
||||
|
||||
// ═══ MIC RECORDING ═══
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isCleaning, setIsCleaning] = useState(false);
|
||||
@@ -536,11 +556,11 @@ function App() {
|
||||
});
|
||||
}, [dubSegments]);
|
||||
|
||||
// ── MODEL STATUS ──
|
||||
const [modelStatus, setModelStatus] = useState('idle'); // 'idle' | 'loading' | 'ready'
|
||||
|
||||
// ── LOAD DATA FROM SERVER ──
|
||||
const [sysStats, setSysStats] = useState(null);
|
||||
// ── MODEL STATUS + SYSINFO (TanStack Query) ──
|
||||
const sysQuery = useSysinfo();
|
||||
const msQuery = useModelStatus();
|
||||
const sysStats = sysQuery.data ?? null;
|
||||
const modelStatus = msQuery.data?.status ?? 'idle';
|
||||
|
||||
// First-run gate — `/setup/status` reports whether required HF models are
|
||||
// on disk. If not, we render <SetupWizard> in place of the main studio so
|
||||
@@ -667,44 +687,27 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// sysinfo + modelStatus polling is now handled by TanStack Query hooks
|
||||
// (useSysinfo / useModelStatus at top of component). No manual setInterval.
|
||||
|
||||
// ── Floating pill for model loading (ASR cold start can take ~120s) ──
|
||||
const prevModelStatusRef = useRef(modelStatus);
|
||||
useEffect(() => {
|
||||
let interval = null;
|
||||
let cancelled = false;
|
||||
let lastCpu = -1, lastRam = -1, lastVram = -1, lastModelSt = '';
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const [sys, ms] = await Promise.all([apiSysinfo(), apiModelStatus()]);
|
||||
if (sys) {
|
||||
// Only update state if values actually changed (avoids re-rendering entire tree)
|
||||
const cpu = Math.round(sys.cpu);
|
||||
const ram = Math.round(sys.ram * 10);
|
||||
const vram = Math.round(sys.vram * 10);
|
||||
if (cpu !== lastCpu || ram !== lastRam || vram !== lastVram) {
|
||||
lastCpu = cpu; lastRam = ram; lastVram = vram;
|
||||
setSysStats(sys);
|
||||
}
|
||||
}
|
||||
if (ms && ms.status !== lastModelSt) {
|
||||
lastModelSt = ms.status;
|
||||
setModelStatus(ms.status);
|
||||
}
|
||||
return true;
|
||||
} catch (e) { return false; }
|
||||
};
|
||||
// Wait for backend to be reachable before starting the polling interval
|
||||
const startPolling = async () => {
|
||||
while (!cancelled) {
|
||||
const ok = await fetchStats();
|
||||
if (ok) {
|
||||
if (!cancelled) interval = setInterval(fetchStats, 4000);
|
||||
return;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
const prev = prevModelStatusRef.current;
|
||||
prevModelStatusRef.current = modelStatus;
|
||||
const pill = useAppStore.getState();
|
||||
// Only show pill if model transitions to loading and pill isn't already
|
||||
// showing something more important (e.g. active dubbing).
|
||||
if (modelStatus === 'loading' && prev !== 'loading' && pill.stage === 'idle') {
|
||||
pill.showPill('loading-model', 'Loading ASR model…');
|
||||
}
|
||||
if (modelStatus === 'ready' && prev === 'loading') {
|
||||
// Only dismiss if the pill is still showing the model-loading state
|
||||
if (pill.stage === 'loading-model' && pill.label.includes('ASR')) {
|
||||
pill.completePill('ASR model ready');
|
||||
}
|
||||
};
|
||||
startPolling();
|
||||
return () => { cancelled = true; if (interval) clearInterval(interval); };
|
||||
}, []);
|
||||
}
|
||||
}, [modelStatus]);
|
||||
|
||||
const loadProfiles = useCallback(async () => {
|
||||
try { setProfiles(await listProfiles()); } catch (e) {}
|
||||
@@ -726,6 +729,17 @@ function App() {
|
||||
try { setExportHistory(await listExportHistory()); } catch (e) {}
|
||||
}, []);
|
||||
|
||||
// ── Real-time sidebar updates via WebSocket ────────────────────────────
|
||||
// Replaces polling — the backend pushes an event on every DB mutation and
|
||||
// we simply re-fetch the affected list. Reconnects automatically.
|
||||
useRealtimeEvents({
|
||||
projects: () => loadProjects(),
|
||||
profiles: () => loadProfiles(),
|
||||
dub_history: () => loadDubHistory(),
|
||||
export_history: () => loadExportHistory(),
|
||||
generation_history: () => loadHistory(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for backend to come alive before loading data (handles Tauri startup race)
|
||||
let cancelled = false;
|
||||
@@ -1312,30 +1326,38 @@ function App() {
|
||||
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(); // refresh sidebar
|
||||
loadProfiles(); // speaker clones may have been auto-created
|
||||
} 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 {
|
||||
@@ -1352,6 +1374,7 @@ function App() {
|
||||
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,
|
||||
@@ -1360,26 +1383,33 @@ function App() {
|
||||
});
|
||||
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(); // refresh sidebar
|
||||
loadProfiles(); // speaker clones may have been auto-created
|
||||
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 {
|
||||
@@ -1410,6 +1440,7 @@ function App() {
|
||||
await _waitForTranscribe(dubJobId, ctrl);
|
||||
setTranscribeStart(null);
|
||||
setDubStep('editing');
|
||||
loadProjects(); // refresh sidebar
|
||||
} catch (err) {
|
||||
setTranscribeStart(null);
|
||||
if (err.name === 'AbortError') {
|
||||
@@ -1520,6 +1551,8 @@ function App() {
|
||||
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)),
|
||||
@@ -1570,7 +1603,12 @@ function App() {
|
||||
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 });
|
||||
if (evt.type === 'progress') {
|
||||
setDubProgress({ current: evt.current + 1, total: evt.total, text: evt.text });
|
||||
const pct = Math.round(((evt.current + 1) / evt.total) * 100);
|
||||
useAppStore.getState().setPillProgress(pct);
|
||||
useAppStore.getState().setPillLabel(`Generating dub… ${evt.current + 1}/${evt.total}`);
|
||||
}
|
||||
else if (evt.type === 'done') {
|
||||
setDubStep('done');
|
||||
setDubTracks(evt.tracks || []);
|
||||
@@ -1621,9 +1659,16 @@ function App() {
|
||||
if (!wasCancelled) {
|
||||
if (dubStep !== 'done') setDubStep('done');
|
||||
loadDubHistory();
|
||||
loadProjects(); // refresh sidebar with updated project state
|
||||
playPing();
|
||||
useAppStore.getState().completePill('Dub complete');
|
||||
} else {
|
||||
useAppStore.getState().dismissPill();
|
||||
}
|
||||
} catch (err) { setDubError(err.message); setDubStep('editing'); setDubTaskId(null); }
|
||||
} catch (err) {
|
||||
setDubError(err.message); setDubStep('editing'); setDubTaskId(null);
|
||||
useAppStore.getState().errorPill(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDubStop = async () => {
|
||||
@@ -1906,8 +1951,8 @@ function App() {
|
||||
// flash the empty studio before the wizard has a chance to mount.
|
||||
if (!setupChecked) {
|
||||
return (
|
||||
<div className="app-container sidebar-hidden" style={{ zoom: uiScale, display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', flexDirection: 'column', gap: 12, color: '#a89984', fontSize: 13 }}>
|
||||
<div style={{ fontSize: 18, color: '#ebdbb2' }}>OmniVoice Studio</div>
|
||||
<div className="app-container sidebar-hidden app-startup" style={{ zoom: uiScale }}>
|
||||
<div className="app-startup__title">OmniVoice Studio</div>
|
||||
<div>Starting backend…</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1918,18 +1963,8 @@ function App() {
|
||||
// studio layout reserves for the main content column.
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
/* Same pattern as .app-container: shrink by whatever the
|
||||
LogsFooter is currently occupying so it never covers the
|
||||
wizard footer buttons / content. */
|
||||
minHeight: 'calc(100vh - var(--logs-footer-height, 28px))',
|
||||
maxHeight: 'calc(100vh - var(--logs-footer-height, 28px))',
|
||||
width: '100%',
|
||||
overflow: 'auto',
|
||||
zoom: uiScale,
|
||||
background: 'var(--color-bg, #1d2021)',
|
||||
position: 'relative',
|
||||
}}
|
||||
className="app-wizard-wrap"
|
||||
style={{ zoom: uiScale }}
|
||||
>
|
||||
{/* Invisible drag strip across the top 28 px of the wizard —
|
||||
matches the macOS traffic-light zone so the window can be
|
||||
@@ -1943,10 +1978,7 @@ function App() {
|
||||
).catch(() => {});
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0,
|
||||
height: 28, zIndex: 10,
|
||||
}}
|
||||
className="app-wizard-dragstrip"
|
||||
/>
|
||||
<Suspense fallback={<LazyFallback />}>
|
||||
<SetupWizard onReady={() => setSetupNeeded(false)} />
|
||||
@@ -1958,6 +1990,12 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
// Block the main UI until Rust reports the backend is ready. In dev web
|
||||
// (no Tauri), the hook returns 'ready' immediately so this is a no-op.
|
||||
if (bootstrapStage !== 'ready') {
|
||||
return <BootstrapSplash stage={bootstrapStage} message={bootstrapMessage} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
@@ -1984,6 +2022,8 @@ function App() {
|
||||
success: { iconTheme: { primary: '#b8bb26', secondary: '#fff' } }
|
||||
}}/>
|
||||
|
||||
<FloatingPill />
|
||||
|
||||
<Header
|
||||
mode={mode} setMode={setMode}
|
||||
sysStats={sysStats} modelStatus={modelStatus}
|
||||
@@ -2048,6 +2088,24 @@ function App() {
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
) : mode === 'gallery' ? (
|
||||
<ErrorBoundary name="gallery">
|
||||
<Suspense fallback={<LazyFallback />}>
|
||||
<VoiceGallery />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
) : mode === 'donate' ? (
|
||||
<ErrorBoundary name="donate">
|
||||
<Suspense fallback={<LazyFallback />}>
|
||||
<DonatePage onBack={() => setMode('launchpad')} onEnterprise={() => setMode('enterprise')} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
) : mode === 'enterprise' ? (
|
||||
<ErrorBoundary name="enterprise">
|
||||
<Suspense fallback={<LazyFallback />}>
|
||||
<EnterprisePage onBack={() => setMode('launchpad')} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
) : mode === 'launchpad' ? (
|
||||
<ErrorBoundary name="launchpad">
|
||||
<Suspense fallback={<LazyFallback />}>
|
||||
@@ -2172,6 +2230,10 @@ function App() {
|
||||
handleUnlockProfile={handleUnlockProfile}
|
||||
handleLockProfile={handleLockProfile}
|
||||
handlePreviewVoice={handlePreviewVoice}
|
||||
onOpenVoicePreview={(profileId) => {
|
||||
setVoicePreviewProfileId(profileId || '');
|
||||
setIsVoicePreviewOpen(true);
|
||||
}}
|
||||
restoreHistory={restoreHistory}
|
||||
restoreDubHistory={restoreDubHistory}
|
||||
handleSaveHistoryAsProfile={handleSaveHistoryAsProfile}
|
||||
@@ -2219,6 +2281,19 @@ function App() {
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* ═══ VOICE PREVIEW FLOATING CARD ═══ */}
|
||||
{isVoicePreviewOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<VoicePreview
|
||||
open={isVoicePreviewOpen}
|
||||
onClose={() => setIsVoicePreviewOpen(false)}
|
||||
profiles={profiles}
|
||||
initialProfileId={voicePreviewProfileId}
|
||||
fileToMediaUrl={fileToMediaUrl}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* ═══ BOTTOM LOGS PANEL (VSCode-style) ═══ */}
|
||||
<Suspense fallback={null}>
|
||||
<LogsFooter />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Backend always listens on localhost:8000 — both in dev (Vite @ 5173 talking
|
||||
// to a separate uvicorn) and in the built .app (Tauri webview @ tauri://localhost
|
||||
// talking to the bundled frozen backend sidecar). Relative fetches against
|
||||
// tauri://localhost don't reach the sidecar, so we hardcode the absolute host.
|
||||
export const API = 'http://localhost:8000';
|
||||
// Backend base URL. Configurable via VITE_API_URL or VITE_API_PORT env vars.
|
||||
// In production Tauri builds, the webview talks to the sidecar on localhost.
|
||||
const viteEnv = import.meta.env ?? {};
|
||||
const _port = viteEnv.VITE_API_PORT || '3900';
|
||||
export const API = viteEnv.VITE_API_URL || `http://localhost:${_port}`;
|
||||
|
||||
export class ApiError extends Error {
|
||||
status?: number;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* openExternal — open a URL in the user's default browser.
|
||||
*
|
||||
* In a Tauri desktop app `window.open()` is blocked by the webview.
|
||||
* This helper uses `@tauri-apps/plugin-opener` when available and
|
||||
* falls back to `window.open()` for browser-based dev mode.
|
||||
*/
|
||||
|
||||
const isTauri =
|
||||
typeof window !== 'undefined' &&
|
||||
!!((window as any).__TAURI_INTERNALS__ || (window as any).__TAURI__);
|
||||
|
||||
let _openUrl: ((url: string) => Promise<void>) | null = null;
|
||||
|
||||
/**
|
||||
* Open an external URL in the system default browser.
|
||||
* @param {string} url — the URL to open
|
||||
*/
|
||||
export async function openExternal(url: string) {
|
||||
if (isTauri) {
|
||||
try {
|
||||
if (!_openUrl) {
|
||||
const mod = await import('@tauri-apps/plugin-opener');
|
||||
_openUrl = mod.openUrl as (url: string) => Promise<void>;
|
||||
}
|
||||
await _openUrl(url);
|
||||
return;
|
||||
} catch (err) {
|
||||
console.warn('[openExternal] Tauri opener failed, falling back:', err);
|
||||
}
|
||||
}
|
||||
// Fallback for browser dev mode
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { apiJson, apiPost, apiFetch } from './client';
|
||||
|
||||
export interface GalleryCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface GalleryVoice {
|
||||
id: string;
|
||||
name: string;
|
||||
character: string;
|
||||
category: string;
|
||||
source_type: string;
|
||||
source_url?: string;
|
||||
audio_path: string;
|
||||
duration: number;
|
||||
description?: string;
|
||||
thumbnail?: string;
|
||||
tags: string[];
|
||||
is_favorite?: boolean;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export const listCategories = (): Promise<GalleryCategory[]> => apiJson('/gallery/categories');
|
||||
|
||||
export const listGalleryVoices = (params?: { category?: string; search?: string; limit?: number }): Promise<GalleryVoice[]> => {
|
||||
const query = params ? '?' + new URLSearchParams(params as Record<string, string>).toString() : '';
|
||||
return apiJson(`/gallery/voices${query}`);
|
||||
};
|
||||
|
||||
export const getGalleryVoice = (voiceId: string): Promise<GalleryVoice> => apiJson(`/gallery/voices/${voiceId}`);
|
||||
|
||||
export const deleteGalleryVoice = (voiceId: string): Promise<{ success: boolean }> =>
|
||||
apiFetch(`/gallery/voices/${voiceId}`, { method: 'DELETE' }).then(r => r.json());
|
||||
|
||||
export interface YoutubeSearchResult {
|
||||
title: string;
|
||||
video_id: string;
|
||||
duration: string | null;
|
||||
thumbnail: string | null;
|
||||
}
|
||||
|
||||
export const searchYoutube = async (
|
||||
query: string,
|
||||
category: string,
|
||||
maxResults: number = 5
|
||||
): Promise<{ results: YoutubeSearchResult[]; query: string; category: string }> => {
|
||||
const url = `/gallery/search/youtube?query=${encodeURIComponent(query)}&category=${encodeURIComponent(category)}&max_results=${maxResults}`;
|
||||
return apiJson(url, { method: 'POST' });
|
||||
};
|
||||
|
||||
export interface DownloadParams {
|
||||
video_url: string;
|
||||
start_time: number;
|
||||
duration: number;
|
||||
character_name: string;
|
||||
category: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const downloadYoutubeClip = async (params: DownloadParams): Promise<{ success: boolean; voice_id: string }> => {
|
||||
const url = `/gallery/download?video_url=${encodeURIComponent(params.video_url)}&start_time=${params.start_time}&duration=${params.duration}&character_name=${encodeURIComponent(params.character_name)}&category=${encodeURIComponent(params.category)}&description=${encodeURIComponent(params.description || '')}`;
|
||||
return apiJson(url, { method: 'POST' });
|
||||
};
|
||||
|
||||
export const uploadVoiceClip = async (formData: FormData): Promise<{ id: string; name: string }> =>
|
||||
apiPost('/gallery/upload', formData);
|
||||
|
||||
export const saveVoiceAsProfile = async (voiceId: string, profileName: string): Promise<{ profile_id: string; name: string }> => {
|
||||
const url = `/gallery/voices/${voiceId}/save-as-profile?profile_name=${encodeURIComponent(profileName)}`;
|
||||
return apiJson(url, { method: 'POST' });
|
||||
};
|
||||
|
||||
export const previewVoiceUrl = (voiceId: string): string => `/gallery/voices/${voiceId}/preview`;
|
||||
|
||||
export const updateGalleryVoice = async (
|
||||
voiceId: string,
|
||||
updates: { name?: string; tags?: string[]; is_favorite?: boolean; description?: string },
|
||||
): Promise<{ success: boolean; updated: string[] }> =>
|
||||
apiFetch(`/gallery/voices/${voiceId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
}).then(r => r.json());
|
||||
|
||||
export const batchDeleteGalleryVoices = async (
|
||||
ids: string[],
|
||||
): Promise<{ deleted: number }> =>
|
||||
apiFetch('/gallery/voices/batch-delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids }),
|
||||
}).then(r => r.json());
|
||||
|
||||
export const galleryVoiceToProfile = async (
|
||||
voiceId: string,
|
||||
): Promise<{ success: boolean; profile_id: string; name: string }> =>
|
||||
apiFetch(`/gallery/voices/${voiceId}/to-profile`, {
|
||||
method: 'POST',
|
||||
}).then(r => r.json());
|
||||
@@ -0,0 +1,188 @@
|
||||
// ── TanStack Query hooks ─────────────────────────────────────────────────
|
||||
// Central place for all query/mutation hooks. Components import from here
|
||||
// instead of calling api/* + useEffect + useState manually.
|
||||
// Deduplication is automatic — two components using useSysinfo() share one
|
||||
// network request and one cache entry.
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import * as systemApi from './system';
|
||||
import * as setupApi from './setup';
|
||||
import * as galleryApi from './gallery';
|
||||
|
||||
// ── Keys (prevents typos, enables targeted invalidation) ─────────────────
|
||||
export const queryKeys = {
|
||||
sysinfo: ['sysinfo'] as const,
|
||||
modelStatus: ['model-status'] as const,
|
||||
systemInfo: ['system-info'] as const,
|
||||
systemLogs: (tail?: number) => ['system-logs', tail ?? 300] as const,
|
||||
tauriLogs: (tail?: number) => ['tauri-logs', tail ?? 300] as const,
|
||||
models: ['models'] as const,
|
||||
recommendations: ['recommendations'] as const,
|
||||
preflight: ['preflight'] as const,
|
||||
setupStatus: ['setup-status'] as const,
|
||||
galleryVoices: (params?: any) => ['gallery-voices', params] as const,
|
||||
galleryCategories: ['gallery-categories'] as const,
|
||||
};
|
||||
|
||||
// ── Polling queries (sysinfo, model status, logs) ────────────────────────
|
||||
|
||||
export function useSysinfo(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.sysinfo,
|
||||
queryFn: systemApi.sysinfo,
|
||||
refetchInterval: 5_000,
|
||||
refetchIntervalInBackground: true,
|
||||
retry: Infinity,
|
||||
retryDelay: 1_500,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useModelStatus(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.modelStatus,
|
||||
queryFn: systemApi.modelStatus,
|
||||
refetchInterval: 10_000,
|
||||
refetchIntervalInBackground: false,
|
||||
retry: Infinity,
|
||||
retryDelay: 1_500,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSystemLogs(tail = 300, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.systemLogs(tail),
|
||||
queryFn: () => systemApi.systemLogs(tail),
|
||||
refetchInterval: 10_000,
|
||||
refetchIntervalInBackground: false,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTauriLogs(tail = 300, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.tauriLogs(tail),
|
||||
queryFn: () => systemApi.systemLogsTauri(tail),
|
||||
refetchInterval: 10_000,
|
||||
refetchIntervalInBackground: false,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
// ── One-shot queries ─────────────────────────────────────────────────────
|
||||
|
||||
export function useSystemInfo() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.systemInfo,
|
||||
queryFn: systemApi.systemInfo,
|
||||
staleTime: 60_000,
|
||||
retry: Infinity,
|
||||
retryDelay: 2_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useModels() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.models,
|
||||
queryFn: setupApi.listModels,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRecommendations() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.recommendations,
|
||||
queryFn: setupApi.getRecommendations,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePreflight() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.preflight,
|
||||
queryFn: setupApi.preflight,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetupStatus() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.setupStatus,
|
||||
queryFn: setupApi.setupStatus,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGalleryCategories() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.galleryCategories,
|
||||
queryFn: galleryApi.listCategories,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGalleryVoices(params?: any) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.galleryVoices(params),
|
||||
queryFn: () => galleryApi.listGalleryVoices(params),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mutations ────────────────────────────────────────────────────────────
|
||||
|
||||
export function useInstallModel() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (repo_id: string) => setupApi.installModel(repo_id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.models });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.setupStatus });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.recommendations });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteModel() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (repo_id: string) => setupApi.deleteModel(repo_id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.models });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.setupStatus });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.recommendations });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useFlushMemory() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (unloadModel: boolean) => systemApi.flushMemory(unloadModel),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.sysinfo });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.modelStatus });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useClearLogs() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => systemApi.clearSystemLogs(),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.systemLogs() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useClearTauriLogs() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => systemApi.clearTauriLogs(),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.tauriLogs() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -97,7 +97,12 @@ export async function getRecommendations(): Promise<Recommendations> {
|
||||
}
|
||||
|
||||
export async function deleteModel(repo_id: string): Promise<{ deleted: boolean; repo_id: string; freed_bytes: number }> {
|
||||
const r = await apiFetch(`/models/${encodeURIComponent(repo_id)}`, { method: 'DELETE' });
|
||||
// HF repo_ids look like "owner/name" — encode each segment so special chars
|
||||
// are escaped but the literal "/" survives into FastAPI's `:path` converter.
|
||||
// encodeURIComponent on the whole string would turn "/" into "%2F", which
|
||||
// some ASGI middleware rejects as a path-traversal attempt.
|
||||
const path = repo_id.split('/').map(encodeURIComponent).join('/');
|
||||
const r = await apiFetch(`/models/${path}`, { method: 'DELETE' });
|
||||
return r.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +1,128 @@
|
||||
import { apiJson, apiFetch, apiPost } from './client';
|
||||
import type { SystemInfo, ModelStatus, LogsResponse, ClearTauriResponse } from './types';
|
||||
|
||||
export async function sysinfo(): Promise<SystemInfo> {
|
||||
return apiJson<SystemInfo>('/sysinfo');
|
||||
// ── Tauri IPC helpers ────────────────────────────────────────────────────
|
||||
// Try native Tauri invoke() first — it's faster (no HTTP round-trip) and
|
||||
// works when the Python backend is still booting. Falls back to HTTP when
|
||||
// running in browser dev mode (no Tauri shell).
|
||||
|
||||
let _invoke: ((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null = null;
|
||||
|
||||
async function getInvoke() {
|
||||
if (_invoke !== null) return _invoke;
|
||||
try {
|
||||
const mod = await import('@tauri-apps/api/core');
|
||||
_invoke = mod.invoke;
|
||||
return _invoke;
|
||||
} catch {
|
||||
// Not running inside Tauri (browser dev mode)
|
||||
_invoke = null as any;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Try Tauri invoke, fall back to HTTP. */
|
||||
async function invokeOrFetch<T>(
|
||||
command: string,
|
||||
args: Record<string, unknown> | undefined,
|
||||
httpFallback: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
const invoke = await getInvoke();
|
||||
if (invoke) {
|
||||
return (await invoke(command, args)) as T;
|
||||
}
|
||||
} catch {
|
||||
// invoke failed — fall through to HTTP
|
||||
}
|
||||
return httpFallback();
|
||||
}
|
||||
|
||||
// ── System info (polled every 5s) ────────────────────────────────────────
|
||||
|
||||
export interface SysinfoData {
|
||||
cpu: number;
|
||||
ram: number;
|
||||
total_ram: number;
|
||||
vram: number;
|
||||
gpu_active: boolean;
|
||||
}
|
||||
|
||||
// Cache VRAM from Python — it changes much slower than CPU/RAM, so we
|
||||
// only refresh it every 15s instead of every 5s poll cycle.
|
||||
let _vramCache: { vram: number; gpu_active: boolean; ts: number } | null = null;
|
||||
const VRAM_CACHE_TTL = 15_000;
|
||||
|
||||
export async function sysinfo(): Promise<SysinfoData> {
|
||||
// Rust provides CPU + RAM; VRAM stays at 0. We merge with the Python
|
||||
// endpoint to get GPU data when available.
|
||||
const rustData = await invokeOrFetch<SysinfoData>(
|
||||
'get_sysinfo',
|
||||
undefined,
|
||||
() => apiJson<SysinfoData>('/sysinfo'),
|
||||
);
|
||||
|
||||
// If we got data from Rust (vram=0), enrich with Python's VRAM data
|
||||
// but only re-fetch every 15s to avoid hammering the backend.
|
||||
if (rustData.vram === 0) {
|
||||
const now = Date.now();
|
||||
if (!_vramCache || now - _vramCache.ts > VRAM_CACHE_TTL) {
|
||||
try {
|
||||
const pyData = await apiJson<SysinfoData>('/sysinfo');
|
||||
_vramCache = { vram: pyData.vram, gpu_active: pyData.gpu_active, ts: now };
|
||||
} catch {
|
||||
// Python backend not ready yet — return Rust-only data
|
||||
return rustData;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...rustData,
|
||||
vram: _vramCache.vram,
|
||||
gpu_active: _vramCache.gpu_active,
|
||||
};
|
||||
}
|
||||
return rustData;
|
||||
}
|
||||
|
||||
// ── Model status ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function modelStatus(): Promise<ModelStatus> {
|
||||
return apiJson<ModelStatus>('/model/status');
|
||||
}
|
||||
|
||||
// ── Audio cleaning ───────────────────────────────────────────────────────
|
||||
|
||||
export async function cleanAudio(formData: FormData): Promise<Response> {
|
||||
// Returns Response because caller needs blob body + X-Clean-Filename header.
|
||||
return apiFetch('/clean-audio', { method: 'POST', body: formData });
|
||||
}
|
||||
|
||||
// ── System info (one-shot, for Settings) ─────────────────────────────────
|
||||
|
||||
export async function systemInfo(): Promise<SystemInfo> {
|
||||
return apiJson<SystemInfo>('/system/info');
|
||||
}
|
||||
|
||||
// ── Logs (polled every 5s) ───────────────────────────────────────────────
|
||||
|
||||
export async function systemLogs(tail: number = 300): Promise<LogsResponse> {
|
||||
return apiJson<LogsResponse>(`/system/logs?tail=${tail}`);
|
||||
return invokeOrFetch<LogsResponse>(
|
||||
'read_log_tail',
|
||||
{ source: 'backend', tail },
|
||||
() => apiJson<LogsResponse>(`/system/logs?tail=${tail}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function systemLogsTauri(tail: number = 300): Promise<LogsResponse> {
|
||||
return apiJson<LogsResponse>(`/system/logs/tauri?tail=${tail}`);
|
||||
return invokeOrFetch<LogsResponse>(
|
||||
'read_log_tail',
|
||||
{ source: 'tauri', tail },
|
||||
() => apiJson<LogsResponse>(`/system/logs/tauri?tail=${tail}`),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Log clearing ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function clearSystemLogs(): Promise<unknown> {
|
||||
return apiPost('/system/logs/clear');
|
||||
}
|
||||
@@ -34,6 +131,8 @@ export async function clearTauriLogs(): Promise<ClearTauriResponse> {
|
||||
return apiPost<ClearTauriResponse>('/system/logs/tauri/clear');
|
||||
}
|
||||
|
||||
// ── Memory flush ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function flushMemory(unloadModel: boolean = false): Promise<unknown> {
|
||||
return apiPost(`/system/flush-memory?unload_model=${unloadModel}`);
|
||||
}
|
||||
|
||||
@@ -650,7 +650,7 @@ export default function AudioTrimmer({ file, maxSeconds = 15, onConfirm, onCance
|
||||
onClick={togglePlay}
|
||||
disabled={!ready}
|
||||
leading={playing ? <Pause size={12} /> : <Play size={12} />}
|
||||
style={{ color: 'var(--color-success)', borderColor: 'rgba(142,192,124,0.3)', background: 'rgba(142,192,124,0.08)' }}
|
||||
className="audio-trimmer__play-btn"
|
||||
>
|
||||
{playing ? 'Pause' : 'Preview selection'}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
.batch-add-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(0,0,0,0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.batch-add {
|
||||
width: min(560px, 92vw);
|
||||
max-height: 80vh;
|
||||
background: var(--chrome-bg);
|
||||
border: 1px solid var(--chrome-border-strong);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 12px 48px rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: batch-in 0.2s ease-out;
|
||||
}
|
||||
@keyframes batch-in {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.batch-add__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
}
|
||||
.batch-add__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
.batch-add__close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--chrome-fg-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.batch-add__close:hover {
|
||||
background: var(--chrome-hover-bg);
|
||||
}
|
||||
|
||||
.batch-add__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.batch-add__drop {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 28px 16px;
|
||||
border: 2px dashed var(--chrome-border);
|
||||
border-radius: 10px;
|
||||
color: var(--chrome-fg-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.batch-add__drop:hover,
|
||||
.batch-add__drop.is-over {
|
||||
border-color: var(--chrome-accent);
|
||||
background: rgba(255,255,255,0.02);
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
.batch-add__drop-hint {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
color: var(--chrome-fg-dim);
|
||||
}
|
||||
.batch-add__file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.batch-add__files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.batch-add__kicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--chrome-fg-dim);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.batch-add__file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: var(--chrome-hover-bg);
|
||||
border-radius: 6px;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
.batch-add__file-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
.batch-add__file-size {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
color: var(--chrome-fg-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.batch-add__file-x {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--chrome-fg-dim);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.batch-add__file-x:hover { color: var(--color-danger); }
|
||||
|
||||
.batch-add__settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.batch-add__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.batch-add__select {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.batch-add__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--chrome-fg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.batch-add__toggle input { cursor: pointer; }
|
||||
|
||||
.batch-add__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--chrome-border);
|
||||
}
|
||||
.batch-add__estimate {
|
||||
flex: 1;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
color: var(--chrome-fg-dim);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { Upload, Film, Globe, X, Plus, Loader } from 'lucide-react';
|
||||
import { Button } from '../ui';
|
||||
import MultiLangPicker from './MultiLangPicker';
|
||||
import { PRESETS } from '../utils/constants';
|
||||
import './BatchAddDialog.css';
|
||||
|
||||
/**
|
||||
* BatchAddDialog — multi-file drop zone + shared settings for batch dubbing.
|
||||
*
|
||||
* Users drop N video files, pick languages + voice, then click "Add to Queue".
|
||||
* Each file is POSTed as a separate job to the batch endpoint.
|
||||
*/
|
||||
export default function BatchAddDialog({
|
||||
open,
|
||||
onClose,
|
||||
profiles = [],
|
||||
onEnqueue, // async (files, settings) => void
|
||||
}) {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [langs, setLangs] = useState([{ lang: 'Spanish', code: 'es' }]);
|
||||
const [voiceId, setVoiceId] = useState('');
|
||||
const [preserveBg, setPreserveBg] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const handleDrop = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
const dropped = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('video/'));
|
||||
if (dropped.length) setFiles(prev => [...prev, ...dropped]);
|
||||
}, []);
|
||||
|
||||
const removeFile = (idx) => {
|
||||
setFiles(prev => prev.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!files.length || !langs.length) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onEnqueue?.(files, { langs, voiceId, preserveBg });
|
||||
setFiles([]);
|
||||
onClose?.();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="batch-add-overlay" onClick={onClose}>
|
||||
<div className="batch-add" onClick={e => e.stopPropagation()}>
|
||||
<div className="batch-add__head">
|
||||
<span className="batch-add__title">
|
||||
<Plus size={13} /> Add Videos to Queue
|
||||
</span>
|
||||
<button type="button" className="batch-add__close" onClick={onClose}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="batch-add__body">
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
className="batch-add__drop"
|
||||
onDragOver={e => { e.preventDefault(); e.currentTarget.classList.add('is-over'); }}
|
||||
onDragLeave={e => e.currentTarget.classList.remove('is-over')}
|
||||
onDrop={e => { e.currentTarget.classList.remove('is-over'); handleDrop(e); }}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload size={24} />
|
||||
<span>Drop video files here or click to browse</span>
|
||||
<span className="batch-add__drop-hint">MP4 · MOV · MKV · WEBM</span>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
multiple
|
||||
className="batch-add__file-input"
|
||||
onChange={e => {
|
||||
const added = Array.from(e.target.files);
|
||||
if (added.length) setFiles(prev => [...prev, ...added]);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* File list */}
|
||||
{files.length > 0 && (
|
||||
<div className="batch-add__files">
|
||||
<span className="batch-add__kicker">FILES ({files.length})</span>
|
||||
{files.map((f, i) => (
|
||||
<div key={`${f.name}-${i}`} className="batch-add__file-row">
|
||||
<Film size={10} />
|
||||
<span className="batch-add__file-name">{f.name}</span>
|
||||
<span className="batch-add__file-size">{(f.size / 1024 / 1024).toFixed(1)} MB</span>
|
||||
<button type="button" className="batch-add__file-x" onClick={() => removeFile(i)}>
|
||||
<X size={9} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings */}
|
||||
<div className="batch-add__settings">
|
||||
<div className="batch-add__field">
|
||||
<span className="batch-add__kicker"><Globe size={9} /> TARGET LANGUAGES</span>
|
||||
<MultiLangPicker selected={langs} onChange={setLangs} />
|
||||
</div>
|
||||
|
||||
<div className="batch-add__field">
|
||||
<span className="batch-add__kicker">VOICE</span>
|
||||
<select
|
||||
className="input-base batch-add__select"
|
||||
value={voiceId}
|
||||
onChange={e => setVoiceId(e.target.value)}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
{profiles.filter(p => !p.instruct).length > 0 && (
|
||||
<optgroup label="Clone Profiles">
|
||||
{profiles.filter(p => !p.instruct).map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{PRESETS.length > 0 && (
|
||||
<optgroup label="Presets">
|
||||
{PRESETS.map(p => (
|
||||
<option key={p.id} value={`preset:${p.id}`}>{p.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label className="batch-add__toggle">
|
||||
<input type="checkbox" checked={preserveBg} onChange={e => setPreserveBg(e.target.checked)} />
|
||||
<span>Preserve background audio (music/FX)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="batch-add__foot">
|
||||
<span className="batch-add__estimate">
|
||||
{files.length > 0 && langs.length > 0
|
||||
? `${files.length} video${files.length > 1 ? 's' : ''} × ${langs.length} lang${langs.length > 1 ? 's' : ''} = ${files.length * langs.length} job${files.length * langs.length > 1 ? 's' : ''}`
|
||||
: 'Select files and languages'}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!files.length || !langs.length || submitting}
|
||||
loading={submitting}
|
||||
leading={!submitting && <Plus size={10} />}
|
||||
>
|
||||
Add to Queue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
.bootstrap-splash {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--chrome-bg, #141414);
|
||||
color: var(--chrome-fg, #eee);
|
||||
font-family: 'Inter Variable', 'Inter', system-ui, sans-serif;
|
||||
z-index: 9999;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.bootstrap-splash__card {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
background: color-mix(in srgb, var(--chrome-fg, #eee) 4%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 10%, transparent);
|
||||
border-radius: 14px;
|
||||
padding: 2rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.bootstrap-splash__card h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.bootstrap-splash__status {
|
||||
margin: 0 0 1.25rem;
|
||||
font-size: 0.95rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.bootstrap-splash__bar {
|
||||
height: 4px;
|
||||
width: 100%;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--chrome-fg, #eee) 8%, transparent);
|
||||
overflow: hidden;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.bootstrap-splash__bar-fill {
|
||||
height: 100%;
|
||||
background: var(--chrome-accent, #8ec07c);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.bootstrap-splash__steps {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.bootstrap-splash__steps li {
|
||||
padding-left: 1.5rem;
|
||||
position: relative;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.bootstrap-splash__steps li::before {
|
||||
content: '○';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.bootstrap-splash__steps li.done {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.bootstrap-splash__steps li.done::before {
|
||||
content: '✓';
|
||||
color: var(--chrome-accent, #8ec07c);
|
||||
}
|
||||
|
||||
.bootstrap-splash__steps li.active {
|
||||
opacity: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bootstrap-splash__steps li.active::before {
|
||||
content: '●';
|
||||
color: var(--chrome-accent, #8ec07c);
|
||||
animation: bootstrap-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes bootstrap-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.bootstrap-splash__error {
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
font-size: 0.8rem;
|
||||
background: color-mix(in srgb, #ef4444 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #ef4444 35%, transparent);
|
||||
color: #fca5a5;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.bootstrap-splash__sub-progress {
|
||||
margin: -0.5rem 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.bootstrap-splash__sub-bar {
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, var(--chrome-fg, #eee) 6%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bootstrap-splash__sub-bar-fill {
|
||||
height: 100%;
|
||||
background: color-mix(in srgb, var(--chrome-accent, #8ec07c) 70%, transparent);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.bootstrap-splash__sub-label {
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.bootstrap-splash__log-toggle {
|
||||
margin-top: 1.25rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
opacity: 0.65;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.25rem 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.bootstrap-splash__log-toggle:hover { opacity: 1; }
|
||||
|
||||
.bootstrap-splash__log-count {
|
||||
opacity: 0.6;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.bootstrap-splash__logs {
|
||||
margin: 0.5rem 0 0;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
font-family: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
background: color-mix(in srgb, var(--chrome-fg, #eee) 4%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 8%, transparent);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
opacity: 0.85;
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* First-run bootstrap splash.
|
||||
*
|
||||
* Two data sources drive this UI:
|
||||
* 1. `bootstrap_status` Tauri command (polled every 1 s) — coarse stage.
|
||||
* 2. `bootstrap-log` + `bootstrap-progress` Tauri events — live stdout
|
||||
* from `uv sync`, ffmpeg byte counts, etc. The log panel shows the
|
||||
* last N lines so users can see *something* happening during the 5–10
|
||||
* min dependency install.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import './BootstrapSplash.css';
|
||||
|
||||
const STAGE_LABEL = {
|
||||
checking: 'Checking environment…',
|
||||
downloading_uv: 'Downloading uv (Python package manager)…',
|
||||
creating_venv: 'Creating Python virtual environment…',
|
||||
installing_deps: 'Installing dependencies — first run, 5–10 min.',
|
||||
downloading_ffmpeg: 'Downloading ffmpeg…',
|
||||
starting_backend: 'Starting backend…',
|
||||
ready: 'Ready',
|
||||
failed: 'Setup failed',
|
||||
};
|
||||
|
||||
const STEPS = [
|
||||
'checking',
|
||||
'downloading_uv',
|
||||
'creating_venv',
|
||||
'installing_deps',
|
||||
'downloading_ffmpeg',
|
||||
'starting_backend',
|
||||
];
|
||||
|
||||
const MAX_LOG_LINES = 200;
|
||||
|
||||
function formatBytes(n) {
|
||||
if (!n || n < 0) return '';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let i = 0;
|
||||
let v = n;
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }
|
||||
return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function BootstrapSplash({ stage, message }) {
|
||||
const label = STAGE_LABEL[stage] || stage;
|
||||
const stepIndex = Math.max(0, STEPS.indexOf(stage));
|
||||
const isFailed = stage === 'failed';
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
const [progress, setProgress] = useState(null); // { stage, bytes_done, bytes_total, percent }
|
||||
const logRef = useRef(null);
|
||||
|
||||
// Subscribe to live log + progress events from the Rust bootstrap.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!('__TAURI_INTERNALS__' in window)) return;
|
||||
let unlistenLog = null;
|
||||
let unlistenProgress = null;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const { listen } = await import('@tauri-apps/api/event');
|
||||
if (cancelled) return;
|
||||
unlistenLog = await listen('bootstrap-log', (e) => {
|
||||
const { stage: s, line } = e.payload || {};
|
||||
if (!line) return;
|
||||
setLogs((prev) => {
|
||||
const next = prev.concat([{ stage: s, line, t: Date.now() }]);
|
||||
return next.length > MAX_LOG_LINES
|
||||
? next.slice(next.length - MAX_LOG_LINES)
|
||||
: next;
|
||||
});
|
||||
});
|
||||
unlistenProgress = await listen('bootstrap-progress', (e) => {
|
||||
setProgress(e.payload || null);
|
||||
});
|
||||
} catch {
|
||||
/* not in Tauri or listen unavailable — silent */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (unlistenLog) unlistenLog();
|
||||
if (unlistenProgress) unlistenProgress();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-scroll the log panel to the latest line whenever it opens or
|
||||
// new lines arrive.
|
||||
useEffect(() => {
|
||||
if (logsOpen && logRef.current) {
|
||||
logRef.current.scrollTop = logRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs, logsOpen]);
|
||||
|
||||
const stageProgress = progress && progress.stage === stage ? progress : null;
|
||||
const pctFromBytes = stageProgress?.percent != null ? stageProgress.percent : null;
|
||||
|
||||
return (
|
||||
<div className="bootstrap-splash">
|
||||
<div className="bootstrap-splash__card">
|
||||
<h1>OmniVoice Studio</h1>
|
||||
<p className="bootstrap-splash__status">{label}</p>
|
||||
{isFailed ? (
|
||||
<pre className="bootstrap-splash__error">{message || 'Unknown error'}</pre>
|
||||
) : (
|
||||
<>
|
||||
<div className="bootstrap-splash__bar">
|
||||
<div
|
||||
className="bootstrap-splash__bar-fill"
|
||||
style={{ width: `${((stepIndex + 1) / STEPS.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
{stageProgress && (
|
||||
<div className="bootstrap-splash__sub-progress">
|
||||
<div className="bootstrap-splash__sub-bar">
|
||||
<div
|
||||
className="bootstrap-splash__sub-bar-fill"
|
||||
style={{ width: `${pctFromBytes ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="bootstrap-splash__sub-label">
|
||||
{formatBytes(stageProgress.bytes_done)}
|
||||
{stageProgress.bytes_total > 0
|
||||
? ` / ${formatBytes(stageProgress.bytes_total)}`
|
||||
: ''}
|
||||
{pctFromBytes != null ? ` (${pctFromBytes}%)` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ol className="bootstrap-splash__steps">
|
||||
{STEPS.map((s, i) => (
|
||||
<li
|
||||
key={s}
|
||||
className={
|
||||
i < stepIndex ? 'done' :
|
||||
i === stepIndex ? 'active' :
|
||||
'pending'
|
||||
}
|
||||
>
|
||||
{STAGE_LABEL[s]}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="bootstrap-splash__log-toggle"
|
||||
onClick={() => setLogsOpen((v) => !v)}
|
||||
>
|
||||
{logsOpen ? '▾ Hide logs' : '▸ Show logs'}
|
||||
{logs.length > 0 && (
|
||||
<span className="bootstrap-splash__log-count"> ({logs.length})</span>
|
||||
)}
|
||||
</button>
|
||||
{logsOpen && (
|
||||
<pre className="bootstrap-splash__logs" ref={logRef}>
|
||||
{logs.length === 0
|
||||
? 'Waiting for output…'
|
||||
: logs.map((l, i) => `[${l.stage}] ${l.line}`).join('\n')}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: polls the Rust `bootstrap_status` command every pollMs ms. Returns
|
||||
* the current stage (string) + message. In a non-Tauri context (dev web),
|
||||
* returns 'ready' immediately so the splash never mounts.
|
||||
*/
|
||||
export function useBootstrapStage(pollMs = 1000) {
|
||||
const [state, setState] = useState({ stage: 'checking', message: null });
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') { setState({ stage: 'ready', message: null }); return; }
|
||||
if (!('__TAURI_INTERNALS__' in window)) { setState({ stage: 'ready', message: null }); return; }
|
||||
if (import.meta.env.DEV) { setState({ stage: 'ready', message: null }); return; }
|
||||
|
||||
let cancelled = false;
|
||||
let timer = null;
|
||||
const invoke = async () => {
|
||||
try {
|
||||
const { invoke: tauriInvoke } = await import('@tauri-apps/api/core');
|
||||
return tauriInvoke;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
(async () => {
|
||||
const tauriInvoke = await invoke();
|
||||
if (!tauriInvoke) { setState({ stage: 'ready', message: null }); return; }
|
||||
const tick = async () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const res = await tauriInvoke('bootstrap_status');
|
||||
if (cancelled) return;
|
||||
// Rust returns { stage: 'ready' } or { stage: 'failed', message: '…' } etc.
|
||||
setState({ stage: res.stage || 'ready', message: res.message || null });
|
||||
if (res.stage !== 'ready' && res.stage !== 'failed') {
|
||||
timer = setTimeout(tick, pollMs);
|
||||
}
|
||||
} catch {
|
||||
setState({ stage: 'ready', message: null });
|
||||
}
|
||||
};
|
||||
tick();
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [pollMs]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { CheckCircle, ArrowRight, X, Sparkles, Languages, Mic } from 'lucide-react';
|
||||
import { Button } from '../ui';
|
||||
import './Misc.css';
|
||||
|
||||
/**
|
||||
* Phase 4.3 — between-stage checkpoint banner.
|
||||
@@ -50,48 +51,23 @@ export default function CheckpointBanner({ stage, count, onContinue, onDismiss,
|
||||
|
||||
return (
|
||||
<div
|
||||
className="checkpoint-banner"
|
||||
style={{
|
||||
// Accent shows through as a left-edge bar instead of a gradient wash,
|
||||
// and the fill stays flat chrome so the banner rhymes with the rest
|
||||
// of the studio strips.
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '8px 12px',
|
||||
marginBottom: 6,
|
||||
borderRadius: 'var(--chrome-radius-pill)',
|
||||
background: 'var(--chrome-bg)',
|
||||
border: '1px solid var(--chrome-border)',
|
||||
borderLeft: `2px solid ${cfg.accent}`,
|
||||
}}
|
||||
className="checkpoint-banner ckpt-banner"
|
||||
style={{ borderLeft: `2px solid ${cfg.accent}` }}
|
||||
role="status"
|
||||
>
|
||||
<Icon size={14} color={cfg.accent} style={{ flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--chrome-font-mono)',
|
||||
fontSize: 'var(--chrome-label-size)',
|
||||
letterSpacing: 'var(--chrome-label-track)',
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: 600,
|
||||
color: 'var(--chrome-fg)',
|
||||
}}>
|
||||
<Icon size={14} color={cfg.accent} className="ckpt-icon" />
|
||||
<div className="ckpt-body">
|
||||
<div className="ckpt-head">
|
||||
<span className="ckpt-title">
|
||||
{cfg.title}
|
||||
</span>
|
||||
{typeof count === 'number' && (
|
||||
<span style={{
|
||||
fontFamily: 'var(--chrome-font-mono)',
|
||||
fontSize: 'var(--chrome-label-size)',
|
||||
color: 'var(--chrome-fg-muted)',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}>
|
||||
<span className="ckpt-count">
|
||||
{count} segment{count === 1 ? '' : 's'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ fontSize: '0.64rem', color: 'var(--chrome-fg-muted)', lineHeight: 1.35 }}>
|
||||
<span className="ckpt-hint">
|
||||
{cfg.hint}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -121,7 +121,7 @@ export default function CompareModal({
|
||||
value={compareText}
|
||||
onChange={e => setCompareText(e.target.value)}
|
||||
rows={2}
|
||||
style={{ resize: 'none' }}
|
||||
className="compare-textarea--noresize"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Sparkles, X } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Dialog, Button, Textarea, Field, Badge } from '../ui';
|
||||
import { apiPost } from '../api/client';
|
||||
import './Misc.css';
|
||||
|
||||
/**
|
||||
* DirectionDialog — Phase 4.2 per-segment direction editor.
|
||||
@@ -63,7 +64,7 @@ export default function DirectionDialog({ open, seg, onSave, onClose }) {
|
||||
variant="ghost" size="sm"
|
||||
onClick={() => { setText(''); }}
|
||||
leading={<X size={11} />}
|
||||
style={{ marginRight: 'auto' }}
|
||||
className="dir-clear-btn"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
@@ -91,7 +92,7 @@ export default function DirectionDialog({ open, seg, onSave, onClose }) {
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 8 }}>
|
||||
<div className="dir-preview-actions">
|
||||
<Button
|
||||
variant="subtle" size="sm"
|
||||
onClick={runPreview}
|
||||
@@ -117,8 +118,8 @@ export default function DirectionDialog({ open, seg, onSave, onClose }) {
|
||||
</div>
|
||||
<div>
|
||||
<strong>Rate bias:</strong> <code>{preview.rate_bias?.toFixed?.(2)}</code>
|
||||
{preview.rate_bias > 1.05 && <> · <span style={{ color: 'var(--color-brand)' }}>speeds up</span></>}
|
||||
{preview.rate_bias < 0.95 && <> · <span style={{ color: 'var(--color-info)' }}>slows down</span></>}
|
||||
{preview.rate_bias > 1.05 && <> · <span className="dir-rate-up">speeds up</span></>}
|
||||
{preview.rate_bias < 0.95 && <> · <span className="dir-rate-down">slows down</span></>}
|
||||
</div>
|
||||
{Object.keys(preview.tokens || {}).length > 0 && (
|
||||
<details>
|
||||
@@ -127,7 +128,7 @@ export default function DirectionDialog({ open, seg, onSave, onClose }) {
|
||||
</details>
|
||||
)}
|
||||
{preview.error && (
|
||||
<div style={{ color: 'var(--color-warn)', fontSize: '0.7rem' }}>
|
||||
<div className="dir-error">
|
||||
{preview.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/* ═══ DubSegmentRow extracted layout styles ═══ */
|
||||
.seg-check {
|
||||
width: 16px; flex-shrink: 0; margin-right: 2px; cursor: pointer;
|
||||
}
|
||||
.seg-time {
|
||||
width: 50px; flex-shrink: 0; display: flex; flex-direction: column;
|
||||
}
|
||||
.seg-sync-badge {
|
||||
font-size: 0.5rem; margin-top: 2px;
|
||||
display: inline-flex; align-items: center; gap: 2px;
|
||||
}
|
||||
.seg-rate-badge {
|
||||
font-size: 0.5rem; margin-top: 2px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.seg-speed-badge {
|
||||
font-size: 0.55rem; margin-left: 2px;
|
||||
}
|
||||
.seg-speaker {
|
||||
width: 45px; flex-shrink: 0; font-size: 0.55rem; color: #a89984;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.seg-text-col {
|
||||
flex: 1 1 0%; display: flex; flex-direction: column; gap: 2px;
|
||||
min-width: 80px; overflow: hidden;
|
||||
}
|
||||
.seg-text-col .segment-input {
|
||||
width: 100%; min-width: 0;
|
||||
}
|
||||
.seg-orig-row {
|
||||
font-size: 0.55rem; color: #6b6657;
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 0 4px; overflow: hidden;
|
||||
}
|
||||
.seg-orig-label {
|
||||
opacity: 0.8; text-transform: uppercase; font-weight: 600;
|
||||
font-size: 0.5rem; color: #7c6f64;
|
||||
}
|
||||
.seg-orig-text {
|
||||
flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.seg-budget-warn { color: #fabd2f; font-size: 0.5rem; }
|
||||
.seg-restore-btn {
|
||||
background: none; border: none; color: #83a598;
|
||||
cursor: pointer; padding: 0; font-size: 0.55rem;
|
||||
}
|
||||
.seg-lang-select {
|
||||
width: 42px; flex-shrink: 0; font-size: 0.5rem; padding: 1px 2px;
|
||||
}
|
||||
.seg-profile-select {
|
||||
width: 60px; flex-shrink: 0; font-size: 0.55rem; padding: 1px 2px;
|
||||
overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.seg-gain-slider {
|
||||
width: 40px !important; max-width: 40px; flex-shrink: 0; flex-grow: 0;
|
||||
height: 3px; padding: 0; margin: 0;
|
||||
}
|
||||
.seg-actions {
|
||||
display: flex; gap: 1px; width: 42px; flex-shrink: 0;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { formatTime } from '../utils/format';
|
||||
import { LANG_CODES } from '../utils/languages';
|
||||
import { PRESETS } from '../utils/constants';
|
||||
import { Menu, Button, Badge } from '../ui';
|
||||
import './DubSegmentRow.css';
|
||||
|
||||
const CHAR_BUDGET_RATIO = 1.3;
|
||||
|
||||
@@ -51,24 +52,23 @@ function DubSegmentRow({
|
||||
onChange={(e) => onSelect(seg.id, idx, e.nativeEvent.shiftKey)}
|
||||
onClick={(e) => onSelect(seg.id, idx, e.shiftKey)}
|
||||
disabled={disabled}
|
||||
style={{ width: 14, marginRight: 4, cursor: 'pointer', accentColor: '#d3869b' }}
|
||||
style={{ accentColor: '#d3869b' }}
|
||||
className="seg-check"
|
||||
title="Select segment (shift+click for range)"
|
||||
/>
|
||||
<span className="segment-time" style={{ width: 55, display: 'flex', flexDirection: 'column' }}>
|
||||
<span className="segment-time seg-time">
|
||||
<span>
|
||||
{formatTime(seg.start)}–{formatTime(seg.end)}
|
||||
{seg.speed && seg.speed !== 1.0 && (
|
||||
<span style={{ fontSize: '0.55rem', color: seg.speed > 1 ? '#d3869b' : '#8ec07c', marginLeft: 2 }}>
|
||||
<span className="seg-speed-badge" style={{ color: seg.speed > 1 ? '#d3869b' : '#8ec07c' }}>
|
||||
{seg.speed.toFixed(2)}x
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{SyncIcon && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.5rem', marginTop: 2, display: 'inline-flex',
|
||||
alignItems: 'center', gap: 2, color: syncColor,
|
||||
}}
|
||||
className="seg-sync-badge"
|
||||
style={{ color: syncColor }}
|
||||
title={`Generated audio is ${Math.round(seg.sync_ratio * 100)}% the duration of original`}
|
||||
>
|
||||
<SyncIcon size={8} /> Sync: {Math.round(seg.sync_ratio * 100)}%
|
||||
@@ -76,11 +76,8 @@ function DubSegmentRow({
|
||||
)}
|
||||
{seg.rate_ratio != null && Math.abs(seg.rate_ratio - 1.0) > 0.03 && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.5rem', marginTop: 2,
|
||||
color: seg.rate_ratio > 1.15 ? '#fb4934' : seg.rate_ratio < 0.85 ? '#83a598' : '#a89984',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
className="seg-rate-badge"
|
||||
style={{ color: seg.rate_ratio > 1.15 ? '#fb4934' : seg.rate_ratio < 0.85 ? '#83a598' : '#a89984' }}
|
||||
title={`Speech-rate fit: ${seg.rate_ratio.toFixed(2)}× relative to slot${seg.rate_error ? ` (${seg.rate_error})` : ''}`}
|
||||
>
|
||||
📖 {seg.rate_ratio.toFixed(2)}×
|
||||
@@ -88,9 +85,9 @@ function DubSegmentRow({
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span style={{ width: 50, fontSize: '0.58rem', color: '#a89984' }}>{seg.speaker_id || ''}</span>
|
||||
<span className="seg-speaker">{seg.speaker_id || ''}</span>
|
||||
|
||||
<span style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
|
||||
<span className="seg-text-col">
|
||||
<input
|
||||
className="input-base segment-input"
|
||||
value={seg.text}
|
||||
@@ -109,13 +106,13 @@ function DubSegmentRow({
|
||||
}
|
||||
/>
|
||||
{seg.text_original && seg.text_original !== seg.text && (
|
||||
<span style={{ fontSize: '0.55rem', color: '#6b6657', display: 'flex', alignItems: 'center', gap: 4, padding: '0 4px', overflow: 'hidden' }}>
|
||||
<span style={{ opacity: 0.8, textTransform: 'uppercase', fontWeight: 600, fontSize: '0.5rem', color: '#7c6f64' }}>orig</span>
|
||||
<span style={{ flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={seg.text_original}>
|
||||
<span className="seg-orig-row">
|
||||
<span className="seg-orig-label">orig</span>
|
||||
<span className="seg-orig-text" title={seg.text_original}>
|
||||
{seg.text_original}
|
||||
</span>
|
||||
{overBudget && (
|
||||
<span style={{ color: '#fabd2f', fontSize: '0.5rem' }}>
|
||||
<span className="seg-budget-warn">
|
||||
{Math.round((seg.text.length / seg.text_original.length) * 100)}%
|
||||
</span>
|
||||
)}
|
||||
@@ -123,7 +120,7 @@ function DubSegmentRow({
|
||||
onClick={() => onRestore(seg.id)}
|
||||
disabled={disabled}
|
||||
title="Restore original text"
|
||||
style={{ background: 'none', border: 'none', color: '#83a598', cursor: 'pointer', padding: 0, fontSize: '0.55rem' }}
|
||||
className="seg-restore-btn"
|
||||
>
|
||||
↺
|
||||
</button>
|
||||
@@ -132,8 +129,7 @@ function DubSegmentRow({
|
||||
</span>
|
||||
|
||||
<select
|
||||
className="input-base segment-input"
|
||||
style={{ width: 45, fontSize: '0.55rem', padding: '1px 2px' }}
|
||||
className="input-base seg-lang-select"
|
||||
value={seg.target_lang || ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onEditField(seg.id, 'target_lang', e.target.value)}
|
||||
@@ -145,8 +141,7 @@ function DubSegmentRow({
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="input-base"
|
||||
style={{ width: 90, fontSize: '0.6rem', padding: '1px 3px' }}
|
||||
className="input-base seg-profile-select"
|
||||
value={seg.profile_id || ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onEditField(seg.id, 'profile_id', e.target.value)}
|
||||
@@ -171,13 +166,11 @@ function DubSegmentRow({
|
||||
title={`${Math.round((seg.gain ?? 1.0) * 100)}%`}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onEditField(seg.id, 'gain', Number(e.target.value) / 100)}
|
||||
style={{
|
||||
width: 30, height: 2, padding: 0, margin: 0,
|
||||
accentColor: (seg.gain ?? 1.0) > 1.2 ? '#fb4934' : (seg.gain ?? 1.0) < 0.5 ? '#83a598' : '#a89984',
|
||||
}}
|
||||
className="seg-gain-slider"
|
||||
style={{ accentColor: (seg.gain ?? 1.0) > 1.2 ? '#fb4934' : (seg.gain ?? 1.0) < 0.5 ? '#83a598' : '#a89984' }}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: 1, width: 54 }}>
|
||||
<div className="seg-actions">
|
||||
<button
|
||||
className="segment-play"
|
||||
disabled={disabled}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/* Table body specific styles — chrome comes from ui/Table. */
|
||||
|
||||
.dub-segment-table__header {
|
||||
padding: 3px 4px !important;
|
||||
gap: 3px !important;
|
||||
}
|
||||
|
||||
.dub-segment-table__body { flex: 1; min-height: 0; }
|
||||
|
||||
.dub-segment-table__select-all {
|
||||
|
||||
@@ -8,13 +8,13 @@ const BASE_ROW_HEIGHT = 28;
|
||||
const ROW_HEIGHT_WITH_ORIG = 44;
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: 'time', label: 'Time', width: 55 },
|
||||
{ key: 'spkr', label: 'Spkr', width: 50 },
|
||||
{ key: 'time', label: 'Time', width: 50 },
|
||||
{ key: 'spkr', label: 'Spkr', width: 45 },
|
||||
{ key: 'text', label: 'Text', flex: 1 },
|
||||
{ key: 'lang', label: 'Lang', width: 45 },
|
||||
{ key: 'voice', label: 'Voice', width: 90 },
|
||||
{ key: 'vol', label: 'Vol', width: 30, title: 'Volume (0–200%)' },
|
||||
{ key: 'act', label: '', width: 54 },
|
||||
{ key: 'lang', label: 'Lang', width: 42 },
|
||||
{ key: 'voice', label: 'Voice', width: 60 },
|
||||
{ key: 'vol', label: 'Vol', width: 40, title: 'Volume (0–200%)' },
|
||||
{ key: 'act', label: '', width: 42 },
|
||||
];
|
||||
|
||||
export default function DubSegmentTable({
|
||||
@@ -124,6 +124,7 @@ export default function DubSegmentTable({
|
||||
</Table.Toolbar>
|
||||
|
||||
<Table.Header
|
||||
className="dub-segment-table__header"
|
||||
columns={COLUMNS}
|
||||
leading={
|
||||
<span className="dub-segment-table__select-all">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import './WaveformErrorBoundary.css';
|
||||
|
||||
export default class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
@@ -24,43 +25,19 @@ export default class ErrorBoundary extends React.Component {
|
||||
|
||||
const msg = this.state.error?.message || String(this.state.error);
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 32, fontFamily: 'var(--font-sans)',
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: 520, width: '100%',
|
||||
padding: 22, textAlign: 'center',
|
||||
background: 'var(--chrome-bg)',
|
||||
border: '1px solid color-mix(in srgb, var(--chrome-severity-err) 35%, transparent)',
|
||||
borderLeft: '2px solid var(--chrome-severity-err)',
|
||||
borderRadius: 'var(--chrome-radius-pill)',
|
||||
boxShadow: 'none',
|
||||
}}>
|
||||
<AlertCircle size={32} color="var(--chrome-severity-err)" style={{ marginBottom: 10 }} />
|
||||
<h2 style={{
|
||||
fontFamily: 'var(--font-serif)', fontStyle: 'italic', fontSize: '1.6rem', fontWeight: 400,
|
||||
color: 'var(--chrome-fg)', margin: '0 0 6px', letterSpacing: '-0.01em',
|
||||
}}>
|
||||
<div className="errbnd-wrap">
|
||||
<div className="errbnd-card">
|
||||
<AlertCircle size={32} color="var(--chrome-severity-err)" className="errbnd-icon" />
|
||||
<h2 className="errbnd-title">
|
||||
This tab hit a snag.
|
||||
</h2>
|
||||
<p style={{ color: 'var(--chrome-fg-muted)', fontSize: '0.82rem', margin: '0 0 12px', lineHeight: 1.5 }}>
|
||||
<p className="errbnd-desc">
|
||||
Don't worry — the rest of the app still works. You can switch tabs, or try again below.
|
||||
</p>
|
||||
<pre style={{
|
||||
textAlign: 'left', fontSize: '0.72rem', color: 'var(--chrome-severity-err)',
|
||||
background: 'var(--chrome-hover-bg)', padding: '8px 10px', borderRadius: 'var(--chrome-radius-pill)',
|
||||
border: '1px solid var(--chrome-border)',
|
||||
maxHeight: 140, overflow: 'auto', margin: '0 0 14px',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
}}>{msg}</pre>
|
||||
<pre className="errbnd-trace">{msg}</pre>
|
||||
<button
|
||||
onClick={this.reset}
|
||||
className="btn-primary"
|
||||
style={{
|
||||
padding: '6px 14px', fontSize: '0.78rem', fontWeight: 500,
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
className="btn-primary errbnd-retry"
|
||||
>
|
||||
<RefreshCw size={12} /> Try again
|
||||
</button>
|
||||
|
||||
@@ -334,3 +334,29 @@
|
||||
display: inline-flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* ── License notice ─────────────────────────────────────────── */
|
||||
.export-modal__license-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px var(--space-3);
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: var(--chrome-label-size);
|
||||
letter-spacing: var(--chrome-label-track);
|
||||
color: var(--chrome-fg-dim);
|
||||
border-top: 1px solid var(--chrome-border);
|
||||
}
|
||||
.export-modal__license-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--chrome-accent);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
padding: 0;
|
||||
}
|
||||
.export-modal__license-link:hover {
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useMemo, useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
Film, Volume2, FileText, Package, Music, Layers, Download,
|
||||
Check, Globe, Zap, X,
|
||||
Check, Globe, Zap, X, Building2,
|
||||
} from 'lucide-react';
|
||||
import { Button, Segmented, Badge } from '../ui';
|
||||
import './ExportModal.css';
|
||||
@@ -36,6 +36,7 @@ export default function ExportModal({
|
||||
triggerDownload,
|
||||
handleDubDownload, handleDubAudioDownload, handleAudioExport,
|
||||
segmentCount = 0,
|
||||
onEnterprise,
|
||||
}) {
|
||||
const [tab, setTab] = useState('video');
|
||||
|
||||
@@ -374,6 +375,12 @@ export default function ExportModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Commercial license notice */}
|
||||
<div className="export-modal__license-notice">
|
||||
<Building2 size={11} />
|
||||
<span>Commercial use requires a <button type="button" className="export-modal__license-link" onClick={() => { onClose(); onEnterprise?.(); }}>license</button>.</span>
|
||||
</div>
|
||||
|
||||
{/* Summary footer */}
|
||||
<div className="export-modal__summary">
|
||||
<div className="export-modal__summary-left">
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/* ── Floating Status Pill ───────────────────────────────────────────────
|
||||
Always-on-top pill that tracks long-running operations (ASR model load,
|
||||
dubbing progress, export). Inspired by VoiceBox 0.5.0's CapturePill.
|
||||
─────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.floating-pill {
|
||||
position: fixed;
|
||||
top: 48px;
|
||||
right: 16px;
|
||||
z-index: var(--z-toast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-3) var(--space-5) var(--space-3) var(--space-4);
|
||||
min-width: 220px;
|
||||
max-width: 360px;
|
||||
|
||||
background: var(--color-bg-elev-1);
|
||||
backdrop-filter: var(--glass-blur-md);
|
||||
-webkit-backdrop-filter: var(--glass-blur-md);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: var(--radius-pill);
|
||||
box-shadow: var(--shadow-lg), 0 0 0 1px rgba(0,0,0,0.15);
|
||||
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-fg);
|
||||
|
||||
/* Slide-in animation */
|
||||
animation: pill-enter 0.35s var(--ease-spring) both;
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.floating-pill--exiting {
|
||||
animation: pill-exit 0.25s var(--ease-out) both;
|
||||
}
|
||||
|
||||
@keyframes pill-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(24px) scale(0.92);
|
||||
filter: blur(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pill-exit {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(16px) scale(0.95);
|
||||
filter: blur(2px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.floating-pill { animation: none; opacity: 1; }
|
||||
.floating-pill--exiting { animation: none; opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── Stage indicator dot ────────────────────────────────────────────── */
|
||||
|
||||
.floating-pill__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
animation: pill-dot-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.floating-pill__dot--loading-model { background: var(--color-accent); }
|
||||
.floating-pill__dot--transcribing { background: var(--color-info); }
|
||||
.floating-pill__dot--translating { background: var(--color-brand); }
|
||||
.floating-pill__dot--generating { background: var(--color-warn); }
|
||||
.floating-pill__dot--exporting { background: var(--color-success); }
|
||||
.floating-pill__dot--refining { background: var(--color-info); }
|
||||
.floating-pill__dot--recording { background: var(--color-danger); }
|
||||
.floating-pill__dot--done { background: var(--color-success); animation: none; }
|
||||
.floating-pill__dot--error { background: var(--color-danger); animation: none; }
|
||||
|
||||
@keyframes pill-dot-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(0.7); }
|
||||
}
|
||||
|
||||
/* ── Content area ───────────────────────────────────────────────────── */
|
||||
|
||||
.floating-pill__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.floating-pill__label {
|
||||
font-weight: var(--weight-medium);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.floating-pill__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-fg-muted);
|
||||
}
|
||||
|
||||
.floating-pill__timer {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--color-fg-subtle);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.floating-pill__error {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-danger);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Mini progress bar ──────────────────────────────────────────────── */
|
||||
|
||||
.floating-pill__progress {
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-bg-elev-2);
|
||||
overflow: hidden;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.floating-pill__progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: var(--color-brand);
|
||||
transition: width 0.3s var(--ease-out);
|
||||
}
|
||||
|
||||
.floating-pill__progress-fill--indeterminate {
|
||||
width: 40% !important;
|
||||
animation: pill-progress-sweep 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pill-progress-sweep {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(300%); }
|
||||
}
|
||||
|
||||
/* ── Dismiss button ─────────────────────────────────────────────────── */
|
||||
|
||||
.floating-pill__dismiss {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-fg-subtle);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background var(--dur-fast) var(--ease-out),
|
||||
color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.floating-pill__dismiss:hover {
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: var(--color-fg);
|
||||
}
|
||||
|
||||
/* ── Done stage — green tint ────────────────────────────────────────── */
|
||||
|
||||
.floating-pill--done {
|
||||
border-color: rgba(142, 192, 124, 0.25);
|
||||
}
|
||||
|
||||
.floating-pill--done .floating-pill__label {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
/* ── Error stage — red tint ─────────────────────────────────────────── */
|
||||
|
||||
.floating-pill--error {
|
||||
border-color: rgba(251, 73, 52, 0.25);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { X, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { useAppStore } from '../store';
|
||||
import './FloatingPill.css';
|
||||
|
||||
/**
|
||||
* FloatingPill — always-on-top status indicator for long-running operations.
|
||||
*
|
||||
* Inspired by VoiceBox 0.5.0's CapturePill. Walks through a state machine
|
||||
* (loading-model → transcribing → translating → generating → done) with a
|
||||
* live elapsed timer, mini progress bar, and dismiss button.
|
||||
*
|
||||
* Reads entirely from the pillSlice in the Zustand store — any part of the
|
||||
* app can trigger it via `useAppStore.getState().showPill(...)`.
|
||||
*/
|
||||
|
||||
function formatElapsed(ms) {
|
||||
const secs = Math.floor(ms / 1000);
|
||||
const mins = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
if (mins > 0) return `${mins}:${String(s).padStart(2, '0')}`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
const STAGE_LABELS = {
|
||||
'loading-model': '🧠',
|
||||
'recording': '🎙️',
|
||||
'transcribing': '📝',
|
||||
'translating': '🌐',
|
||||
'generating': '🔊',
|
||||
'exporting': '📦',
|
||||
'refining': '✨',
|
||||
'done': '✅',
|
||||
'error': '❌',
|
||||
};
|
||||
|
||||
export default function FloatingPill() {
|
||||
const visible = useAppStore(s => s.visible);
|
||||
const stage = useAppStore(s => s.stage);
|
||||
const label = useAppStore(s => s.label);
|
||||
const progress = useAppStore(s => s.progress);
|
||||
const startedAt = useAppStore(s => s.startedAt);
|
||||
const error = useAppStore(s => s.error);
|
||||
const cancellable = useAppStore(s => s.cancellable);
|
||||
const dismissPill = useAppStore(s => s.dismissPill);
|
||||
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [exiting, setExiting] = useState(false);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
// Elapsed timer
|
||||
useEffect(() => {
|
||||
if (!startedAt || stage === 'done' || stage === 'error' || stage === 'idle') {
|
||||
setElapsed(0);
|
||||
return;
|
||||
}
|
||||
const tick = () => setElapsed(Date.now() - startedAt);
|
||||
tick();
|
||||
timerRef.current = setInterval(tick, 1000);
|
||||
return () => clearInterval(timerRef.current);
|
||||
}, [startedAt, stage]);
|
||||
|
||||
// Handle dismiss with exit animation
|
||||
const handleDismiss = () => {
|
||||
setExiting(true);
|
||||
setTimeout(() => {
|
||||
setExiting(false);
|
||||
dismissPill();
|
||||
}, 250);
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const stageEmoji = STAGE_LABELS[stage] || '⏳';
|
||||
const isDone = stage === 'done';
|
||||
const isError = stage === 'error';
|
||||
const isActive = !isDone && !isError && stage !== 'idle';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'floating-pill',
|
||||
exiting ? 'floating-pill--exiting' : '',
|
||||
isDone ? 'floating-pill--done' : '',
|
||||
isError ? 'floating-pill--error' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{/* Stage indicator dot */}
|
||||
<span className={`floating-pill__dot floating-pill__dot--${stage}`} />
|
||||
|
||||
{/* Content */}
|
||||
<div className="floating-pill__content">
|
||||
<span className="floating-pill__label">
|
||||
{stageEmoji} {label}
|
||||
</span>
|
||||
|
||||
{/* Meta row: timer + progress text */}
|
||||
<div className="floating-pill__meta">
|
||||
{isActive && elapsed > 0 && (
|
||||
<span className="floating-pill__timer">{formatElapsed(elapsed)}</span>
|
||||
)}
|
||||
{progress !== null && isActive && (
|
||||
<span>{Math.round(progress)}%</span>
|
||||
)}
|
||||
{isError && error && (
|
||||
<span className="floating-pill__error" title={error}>{error}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mini progress bar */}
|
||||
{isActive && (
|
||||
<div className="floating-pill__progress">
|
||||
<div
|
||||
className={[
|
||||
'floating-pill__progress-fill',
|
||||
progress === null ? 'floating-pill__progress-fill--indeterminate' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
style={progress !== null ? { width: `${progress}%` } : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dismiss / cancel button */}
|
||||
<button
|
||||
className="floating-pill__dismiss"
|
||||
onClick={handleDismiss}
|
||||
title={cancellable ? 'Cancel' : 'Dismiss'}
|
||||
aria-label={cancellable ? 'Cancel operation' : 'Dismiss status'}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Globe, Fingerprint, Wand2, Film, FolderOpen, RefreshCw, Settings2, ChevronRight, Zap } from 'lucide-react';
|
||||
import { Globe, Fingerprint, Wand2, Film, FolderOpen, RefreshCw, Settings2, ChevronRight, Zap, Building2 } from 'lucide-react';
|
||||
import { Button, Badge } from '../ui';
|
||||
|
||||
const VIEW_META = {
|
||||
launchpad: { label: 'Launchpad', Icon: Globe, accent: '#f3a5b6', kicker: 'Studio' },
|
||||
clone: { label: 'Voice Clone', Icon: Fingerprint, accent: '#d3869b', kicker: 'Studio' },
|
||||
design: { label: 'Voice Design', Icon: Wand2, accent: '#8ec07c', kicker: 'Studio' },
|
||||
dub: { label: 'Dubbing', Icon: Film, accent: '#fe8019', kicker: 'Studio' },
|
||||
projects: { label: 'Projects', Icon: FolderOpen, accent: '#83a598', kicker: 'Library' },
|
||||
settings: { label: 'Settings', Icon: Settings2, accent: '#fabd2f', kicker: 'Preferences' },
|
||||
launchpad: { label: 'Launchpad', Icon: Globe, accent: '#f3a5b6', kicker: 'Studio' },
|
||||
clone: { label: 'Voice Clone', Icon: Fingerprint, accent: '#d3869b', kicker: 'Studio' },
|
||||
design: { label: 'Voice Design', Icon: Wand2, accent: '#8ec07c', kicker: 'Studio' },
|
||||
dub: { label: 'Dubbing', Icon: Film, accent: '#fe8019', kicker: 'Studio' },
|
||||
projects: { label: 'Projects', Icon: FolderOpen, accent: '#83a598', kicker: 'Library' },
|
||||
settings: { label: 'Settings', Icon: Settings2, accent: '#fabd2f', kicker: 'Preferences' },
|
||||
enterprise: { label: 'Commercial License', Icon: Building2, accent: '#fe8019', kicker: 'Licensing' },
|
||||
};
|
||||
|
||||
function WaveBars({ color = '#f3a5b6', active }) {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/* ═══ Keyboard Cheatsheet Modal ═══ */
|
||||
.kcs-overlay {
|
||||
position: fixed; inset: 0; z-index: 9999;
|
||||
background: rgba(0,0,0,0.7);
|
||||
display: flex; align-items: center; justify-content: center; padding: 24px;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.kcs-panel {
|
||||
width: min(720px, 90vw); max-height: 82vh; overflow: auto;
|
||||
padding: 22px;
|
||||
background: var(--chrome-bg);
|
||||
border: 1px solid var(--chrome-border-strong);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
}
|
||||
.kcs-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.kcs-header__left { display: flex; align-items: center; gap: 10px; }
|
||||
.kcs-title {
|
||||
margin: 0; font-family: var(--font-serif); font-style: italic;
|
||||
font-weight: 400; font-size: 1.5rem; color: var(--chrome-fg);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.kcs-close {
|
||||
background: none; border: none; color: var(--chrome-fg-muted); cursor: pointer;
|
||||
}
|
||||
.kcs-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.kcs-section-title {
|
||||
font-family: var(--font-mono); font-weight: 600;
|
||||
font-size: var(--chrome-label-size);
|
||||
text-transform: uppercase; letter-spacing: var(--chrome-label-track);
|
||||
color: var(--chrome-fg-muted); margin-bottom: 10px; padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
}
|
||||
.kcs-items { display: flex; flex-direction: column; gap: 6px; }
|
||||
.kcs-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 10px; font-family: var(--font-sans);
|
||||
}
|
||||
.kcs-desc { color: var(--chrome-fg-muted); font-size: 0.8rem; }
|
||||
.kcs-keys { display: flex; gap: 3px; flex-shrink: 0; }
|
||||
.kcs-key-group { display: flex; gap: 2px; }
|
||||
.kcs-or {
|
||||
color: var(--chrome-fg-dim); align-self: center; font-size: 0.7rem;
|
||||
}
|
||||
.kcs-kbd {
|
||||
display: inline-flex; align-items: center; gap: 2px;
|
||||
padding: 2px 8px;
|
||||
min-width: 28px; height: 22px;
|
||||
background: var(--chrome-hover-bg);
|
||||
border: 1px solid var(--chrome-border-strong);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
color: var(--chrome-fg);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem; font-weight: 500;
|
||||
}
|
||||
.kcs-footer {
|
||||
margin-top: 18px; text-align: center;
|
||||
color: var(--chrome-fg-dim); font-family: var(--font-sans); font-size: 0.72rem;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Command, X } from 'lucide-react';
|
||||
import './KeyboardCheatsheet.css';
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
@@ -44,85 +45,39 @@ const SECTIONS = [
|
||||
];
|
||||
|
||||
function Kbd({ children }) {
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 2,
|
||||
padding: '2px 8px',
|
||||
minWidth: 28, height: 22,
|
||||
background: 'var(--chrome-hover-bg)',
|
||||
border: '1px solid var(--chrome-border-strong)',
|
||||
borderRadius: 'var(--chrome-radius-pill)',
|
||||
color: 'var(--chrome-fg)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.7rem', fontWeight: 500,
|
||||
boxShadow: 'none',
|
||||
}}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
return <span className="kcs-kbd">{children}</span>;
|
||||
}
|
||||
|
||||
export default function KeyboardCheatsheet({ open, onClose }) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
backdropFilter: 'none', WebkitBackdropFilter: 'none',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
|
||||
fontFamily: 'var(--font-sans)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: 'min(720px, 90vw)', maxHeight: '82vh', overflow: 'auto',
|
||||
padding: 22,
|
||||
background: 'var(--chrome-bg)',
|
||||
border: '1px solid var(--chrome-border-strong)',
|
||||
borderRadius: 'var(--chrome-radius-pill)',
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div onClick={onClose} className="kcs-overlay">
|
||||
<div onClick={(e) => e.stopPropagation()} className="kcs-panel">
|
||||
<div className="kcs-header">
|
||||
<div className="kcs-header__left">
|
||||
<Command size={16} color="var(--chrome-accent)" />
|
||||
<h2 style={{
|
||||
margin: 0, fontFamily: 'var(--font-serif)', fontStyle: 'italic',
|
||||
fontWeight: 400, fontSize: '1.5rem', color: 'var(--chrome-fg)',
|
||||
letterSpacing: '-0.01em',
|
||||
}}>
|
||||
Keyboard shortcuts
|
||||
</h2>
|
||||
<h2 className="kcs-title">Keyboard shortcuts</h2>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--chrome-fg-muted)', cursor: 'pointer' }}>
|
||||
<button onClick={onClose} className="kcs-close">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 18 }}>
|
||||
<div className="kcs-grid">
|
||||
{SECTIONS.map((sec) => (
|
||||
<div key={sec.title}>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-mono)', fontWeight: 600,
|
||||
fontSize: 'var(--chrome-label-size)',
|
||||
textTransform: 'uppercase', letterSpacing: 'var(--chrome-label-track)',
|
||||
color: 'var(--chrome-fg-muted)', marginBottom: 10, paddingBottom: 6,
|
||||
borderBottom: '1px solid var(--chrome-border)',
|
||||
}}>{sec.title}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div className="kcs-section-title">{sec.title}</div>
|
||||
<div className="kcs-items">
|
||||
{sec.items.map(([keys, desc]) => (
|
||||
<div key={keys} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, fontFamily: 'var(--font-sans)' }}>
|
||||
<span style={{ color: 'var(--chrome-fg-muted)', fontSize: '0.8rem' }}>{desc}</span>
|
||||
<span style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
|
||||
<div key={keys} className="kcs-row">
|
||||
<span className="kcs-desc">{desc}</span>
|
||||
<span className="kcs-keys">
|
||||
{keys.split(' / ').map((group, i, arr) => (
|
||||
<React.Fragment key={group}>
|
||||
<span style={{ display: 'flex', gap: 2 }}>
|
||||
<span className="kcs-key-group">
|
||||
{group.split('+').map((k) => <Kbd key={k}>{k}</Kbd>)}
|
||||
</span>
|
||||
{i < arr.length - 1 && <span style={{ color: 'var(--chrome-fg-dim)', alignSelf: 'center', fontSize: '0.7rem' }}>or</span>}
|
||||
{i < arr.length - 1 && <span className="kcs-or">or</span>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
@@ -133,7 +88,7 @@ export default function KeyboardCheatsheet({ open, onClose }) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 18, textAlign: 'center', color: 'var(--chrome-fg-dim)', fontFamily: 'var(--font-sans)', fontSize: '0.72rem' }}>
|
||||
<div className="kcs-footer">
|
||||
Press <Kbd>?</Kbd> any time to open this.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.logs-footer__title {
|
||||
color: #a89984;
|
||||
@@ -157,11 +158,73 @@
|
||||
}
|
||||
|
||||
/* Action buttons on the right */
|
||||
.logs-footer__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logs-footer__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* Discord button */
|
||||
.logs-footer__discord {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
color: #7289da;
|
||||
opacity: 0.6;
|
||||
transition: color 0.15s, opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
.logs-footer__discord:hover {
|
||||
opacity: 1;
|
||||
color: #5865F2;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Glowing donate heart */
|
||||
.logs-footer__donate {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
color: #d3869b;
|
||||
margin-left: 4px;
|
||||
transition: color 0.15s, transform 0.15s;
|
||||
animation: heart-glow 2.5s ease-in-out infinite;
|
||||
}
|
||||
.logs-footer__donate:hover {
|
||||
color: #f3a5b6;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
.logs-footer__donate svg {
|
||||
fill: rgba(211, 134, 155, 0.25);
|
||||
filter: drop-shadow(0 0 4px rgba(211, 134, 155, 0.35));
|
||||
}
|
||||
.logs-footer__donate:hover svg {
|
||||
fill: rgba(211, 134, 155, 0.5);
|
||||
filter: drop-shadow(0 0 8px rgba(211, 134, 155, 0.6));
|
||||
}
|
||||
@keyframes heart-glow {
|
||||
0%, 100% { opacity: 0.7; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.08); }
|
||||
}
|
||||
.logs-footer__icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ChevronUp, ChevronDown, RefreshCw, Trash2, Copy, Bug, X,
|
||||
AlertTriangle, AlertCircle, Info, FileText,
|
||||
AlertTriangle, AlertCircle, Info, FileText, Heart,
|
||||
} from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import {
|
||||
systemLogs, systemLogsTauri, clearSystemLogs, clearTauriLogs,
|
||||
} from '../api/system';
|
||||
import { clearSystemLogs, clearTauriLogs } from '../api/system';
|
||||
import { useSystemLogs, useTauriLogs, useClearLogs, useClearTauriLogs } from '../api/hooks';
|
||||
import { getFrontendLogs, clearFrontendLogs } from '../utils/consoleBuffer';
|
||||
import { Segmented } from '../ui';
|
||||
import { useAppStore } from '../store';
|
||||
@@ -115,6 +114,32 @@ function SourcePill({ source, counts, active, onClick }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Seasonal / random donate heart ──────────────────────────────────────
|
||||
// Christmas (Dec), Diwali (~Oct-Nov), Valentine's (Feb), Eid (~Mar-Apr),
|
||||
// default pool rotates daily based on day-of-year.
|
||||
const HEART_POOL = ['❤️', '🩷', '💜', '💙', '🧡', '💛', '🩵', '💖', '💗'];
|
||||
const SEASONAL = [
|
||||
{ month: 12, emoji: '🎄', color: '#e74c3c', title: 'Merry Christmas! Support this project' },
|
||||
{ month: 2, emoji: '💝', color: '#ff6b81', title: 'Happy Valentine\'s! Support this project' },
|
||||
// Diwali window — roughly Kartik Amavasya (Oct–Nov)
|
||||
{ month: 10, emoji: '🪔', color: '#f5a623', title: 'Happy Diwali! Support this project' },
|
||||
{ month: 11, emoji: '✨', color: '#f5a623', title: 'Happy Diwali! Support this project' },
|
||||
];
|
||||
|
||||
function DonateHeart() {
|
||||
const now = new Date();
|
||||
const month = now.getMonth() + 1;
|
||||
const dayOfYear = Math.floor((now - new Date(now.getFullYear(), 0, 0)) / 86400000);
|
||||
|
||||
const seasonal = SEASONAL.find(s => s.month === month);
|
||||
if (seasonal) {
|
||||
return <span style={{ fontSize: 14, lineHeight: 1 }} title={seasonal.title}>{seasonal.emoji}</span>;
|
||||
}
|
||||
// Rotate through the pool daily
|
||||
const pick = HEART_POOL[dayOfYear % HEART_POOL.length];
|
||||
return <span style={{ fontSize: 14, lineHeight: 1 }}>{pick}</span>;
|
||||
}
|
||||
|
||||
export default function LogsFooter() {
|
||||
// Always start collapsed on every launch — per-session toggling works
|
||||
// but nothing persists. Kill the legacy key on the way out so users
|
||||
@@ -153,19 +178,22 @@ export default function LogsFooter() {
|
||||
};
|
||||
}, [collapsed, height]);
|
||||
|
||||
const fetchBackend = useCallback(async () => {
|
||||
try {
|
||||
const r = await systemLogs(300);
|
||||
setLines(prev => ({ ...prev, backend: r.lines || [] }));
|
||||
} catch { /* backend may be warming up — don't spam toasts */ }
|
||||
}, []);
|
||||
// ── TanStack Query for backend + tauri logs ────────────────────────────
|
||||
const backendLogs = useSystemLogs(300, true);
|
||||
const tauriLogs = useTauriLogs(300, true);
|
||||
|
||||
const fetchTauri = useCallback(async () => {
|
||||
try {
|
||||
const r = await systemLogsTauri(300);
|
||||
setLines(prev => ({ ...prev, tauri: r.lines || [] }));
|
||||
} catch { /* tauri log may not exist in dev */ }
|
||||
}, []);
|
||||
// Sync query data into local state for the rendering pipeline
|
||||
useEffect(() => {
|
||||
if (backendLogs.data) {
|
||||
setLines(prev => ({ ...prev, backend: backendLogs.data.lines || [] }));
|
||||
}
|
||||
}, [backendLogs.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tauriLogs.data) {
|
||||
setLines(prev => ({ ...prev, tauri: tauriLogs.data.lines || [] }));
|
||||
}
|
||||
}, [tauriLogs.data]);
|
||||
|
||||
const pullFrontend = useCallback(() => {
|
||||
const raw = getFrontendLogs();
|
||||
@@ -177,18 +205,18 @@ export default function LogsFooter() {
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([fetchBackend(), fetchTauri()]);
|
||||
backendLogs.refetch();
|
||||
tauriLogs.refetch();
|
||||
pullFrontend();
|
||||
setLoading(false);
|
||||
}, [fetchBackend, fetchTauri, pullFrontend]);
|
||||
}, [backendLogs, tauriLogs, pullFrontend]);
|
||||
|
||||
// Poll on a slow interval (badges update without user action), faster
|
||||
// when the panel is open + focused on a source.
|
||||
// Frontend logs still need a local interval (no API, reads from buffer)
|
||||
useEffect(() => {
|
||||
refreshAll();
|
||||
const slow = setInterval(refreshAll, collapsed ? 8000 : 3000);
|
||||
return () => clearInterval(slow);
|
||||
}, [refreshAll, collapsed]);
|
||||
pullFrontend();
|
||||
const iv = setInterval(pullFrontend, collapsed ? 8000 : 3000);
|
||||
return () => clearInterval(iv);
|
||||
}, [pullFrontend, collapsed]);
|
||||
|
||||
// Auto-scroll to bottom when new lines arrive and panel is open.
|
||||
useEffect(() => {
|
||||
@@ -313,25 +341,43 @@ export default function LogsFooter() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="logs-footer__actions">
|
||||
<button className="logs-footer__icon-btn" onClick={refreshAll} disabled={loading} title="Refresh">
|
||||
<RefreshCw size={12} className={loading ? 'spinner' : ''} />
|
||||
</button>
|
||||
<button className="logs-footer__icon-btn" onClick={onCopy} title="Copy visible log">
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
<button className="logs-footer__icon-btn" onClick={onClear} title="Clear">
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
<button className="logs-footer__icon-btn logs-footer__icon-btn--report" onClick={onReportIssue} title="Report issue (copy diagnostic)">
|
||||
<Bug size={12} />
|
||||
</button>
|
||||
<button className="logs-footer__icon-btn" onClick={() => setCollapsed(true)} title="Close">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="logs-footer__right">
|
||||
{!collapsed && (
|
||||
<div className="logs-footer__actions">
|
||||
<button className="logs-footer__icon-btn" onClick={refreshAll} disabled={loading} title="Refresh">
|
||||
<RefreshCw size={12} className={loading ? 'spinner' : ''} />
|
||||
</button>
|
||||
<button className="logs-footer__icon-btn" onClick={onCopy} title="Copy visible log">
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
<button className="logs-footer__icon-btn" onClick={onClear} title="Clear">
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
<button className="logs-footer__icon-btn logs-footer__icon-btn--report" onClick={onReportIssue} title="Report issue (copy diagnostic)">
|
||||
<Bug size={12} />
|
||||
</button>
|
||||
<button className="logs-footer__icon-btn" onClick={() => setCollapsed(true)} title="Close">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="logs-footer__discord"
|
||||
onClick={() => { import('../api/external').then(m => m.openExternal('https://discord.gg/aRRdVj3de7')); }}
|
||||
title="Join our Discord"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.947 2.418-2.157 2.418z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="logs-footer__donate"
|
||||
onClick={() => useAppStore.getState().setMode?.('donate')}
|
||||
title="Support this project"
|
||||
>
|
||||
<DonateHeart />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/* ═══ CheckpointBanner extracted styles ═══ */
|
||||
.ckpt-banner {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 12px; margin-bottom: 6px;
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
background: var(--chrome-bg);
|
||||
border: 1px solid var(--chrome-border);
|
||||
}
|
||||
.ckpt-icon { flex-shrink: 0; }
|
||||
.ckpt-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
|
||||
.ckpt-head { display: flex; align-items: baseline; gap: 6px; }
|
||||
.ckpt-title {
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: var(--chrome-label-size);
|
||||
letter-spacing: var(--chrome-label-track);
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
.ckpt-count {
|
||||
font-family: var(--chrome-font-mono);
|
||||
font-size: var(--chrome-label-size);
|
||||
color: var(--chrome-fg-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ckpt-hint { font-size: 0.64rem; color: var(--chrome-fg-muted); line-height: 1.35; }
|
||||
|
||||
/* ═══ DirectionDialog extracted styles ═══ */
|
||||
.dir-preview-actions { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
|
||||
.dir-clear-btn { margin-right: auto; }
|
||||
.dir-rate-up { color: var(--color-brand); }
|
||||
.dir-rate-down { color: var(--color-info); }
|
||||
.dir-error { color: var(--color-warn); font-size: 0.7rem; }
|
||||
|
||||
/* ═══ SetupWizard preflight extracted styles ═══ */
|
||||
.swiz-loading {
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
justify-content: center; padding: 20px;
|
||||
color: var(--color-fg-muted);
|
||||
}
|
||||
.swiz-checklist { display: flex; flex-direction: column; gap: 6px; }
|
||||
.swiz-check-icon { flex-shrink: 0; padding-top: 2px; }
|
||||
.swiz-check-footer { display: flex; justify-content: flex-end; padding-top: 4px; }
|
||||
.swiz-missing { text-align: center; font-size: 0.78rem; margin: 0; }
|
||||
.swiz-status-loading {
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
justify-content: center; color: var(--color-fg-muted);
|
||||
}
|
||||
|
||||
/* ═══ App.jsx startup / wizard extracted styles ═══ */
|
||||
.app-startup {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
min-height: 100vh; flex-direction: column; gap: 12px;
|
||||
color: #a89984; font-size: 13px;
|
||||
}
|
||||
.app-startup__title { font-size: 18px; color: #ebdbb2; }
|
||||
.app-wizard-wrap {
|
||||
min-height: calc(100vh - var(--logs-footer-height, 28px));
|
||||
max-height: calc(100vh - var(--logs-footer-height, 28px));
|
||||
width: 100%; overflow: hidden;
|
||||
background: var(--color-bg, #1d2021);
|
||||
position: relative; display: flex; flex-direction: column;
|
||||
}
|
||||
.app-wizard-dragstrip {
|
||||
position: fixed; top: 0; left: 0; right: 0;
|
||||
height: 28px; z-index: 10;
|
||||
}
|
||||
.app-lazy-fallback { padding: 12px; color: #6b6657; font-size: 0.7rem; }
|
||||
|
||||
/* ═══ CompareModal extracted ═══ */
|
||||
.compare-textarea--noresize { resize: none; }
|
||||
|
||||
/* ═══ AudioTrimmer play button ═══ */
|
||||
.audio-trimmer__play-btn {
|
||||
color: var(--color-success);
|
||||
border-color: rgba(142,192,124,0.3);
|
||||
background: rgba(142,192,124,0.08);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
.multi-lang {
|
||||
position: relative;
|
||||
}
|
||||
.multi-lang__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
}
|
||||
.multi-lang__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
background: var(--chrome-hover-bg);
|
||||
border: 1px solid var(--chrome-border);
|
||||
border-radius: 999px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
color: var(--chrome-fg);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.multi-lang__chip-x {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--chrome-fg-muted);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.multi-lang__chip-x:hover {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.multi-lang__add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
border: 1px dashed var(--chrome-border);
|
||||
background: none;
|
||||
color: var(--chrome-fg-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.multi-lang__add:hover {
|
||||
background: var(--chrome-hover-bg);
|
||||
color: var(--chrome-fg);
|
||||
border-style: solid;
|
||||
}
|
||||
.multi-lang__summary {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
color: var(--chrome-fg-dim);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Dropdown */
|
||||
.multi-lang__drop {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: var(--z-overlay);
|
||||
margin-top: 4px;
|
||||
background: var(--chrome-bg);
|
||||
border: 1px solid var(--chrome-border-strong);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.35);
|
||||
max-height: 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: mlp-in 0.15s ease-out;
|
||||
}
|
||||
@keyframes mlp-in {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.multi-lang__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
color: var(--chrome-fg-muted);
|
||||
}
|
||||
.multi-lang__search input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--chrome-fg);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.multi-lang__list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.multi-lang__section {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--chrome-fg-dim);
|
||||
padding: 6px 10px 2px;
|
||||
}
|
||||
.multi-lang__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 5px 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--chrome-fg);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.76rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.multi-lang__option:hover {
|
||||
background: var(--chrome-hover-bg);
|
||||
}
|
||||
.multi-lang__option-code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
color: var(--chrome-accent);
|
||||
min-width: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.multi-lang__more,
|
||||
.multi-lang__empty {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--chrome-fg-dim);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { X, Search, Globe, Plus } from 'lucide-react';
|
||||
import { POPULAR_LANGS } from '../utils/constants';
|
||||
import { LANG_CODES } from '../utils/languages';
|
||||
import './MultiLangPicker.css';
|
||||
|
||||
/**
|
||||
* MultiLangPicker — chip-based multi-language selector for batch dubbing.
|
||||
*
|
||||
* Shows selected languages as removable badges. Click "+" to open a
|
||||
* searchable dropdown with Popular + All Languages sections.
|
||||
*/
|
||||
export default function MultiLangPicker({
|
||||
selected = [], // array of { lang: string, code: string }
|
||||
onChange, // (newSelected) => void
|
||||
disabled = false,
|
||||
}) {
|
||||
const [dropOpen, setDropOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const dropRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!dropOpen) return;
|
||||
const handler = (e) => {
|
||||
if (dropRef.current && !dropRef.current.contains(e.target)) setDropOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [dropOpen]);
|
||||
|
||||
// Focus search when dropdown opens
|
||||
useEffect(() => {
|
||||
if (dropOpen && inputRef.current) inputRef.current.focus();
|
||||
}, [dropOpen]);
|
||||
|
||||
const selectedCodes = useMemo(() => new Set(selected.map(s => s.code)), [selected]);
|
||||
|
||||
const addLang = (lang, code) => {
|
||||
if (selectedCodes.has(code)) return;
|
||||
onChange([...selected, { lang, code }]);
|
||||
setQuery('');
|
||||
};
|
||||
|
||||
const removeLang = (code) => {
|
||||
onChange(selected.filter(s => s.code !== code));
|
||||
};
|
||||
|
||||
const filteredLangs = useMemo(() => {
|
||||
const q = query.toLowerCase().trim();
|
||||
return LANG_CODES.filter(lc =>
|
||||
!selectedCodes.has(lc.code) &&
|
||||
(!q || lc.label.toLowerCase().includes(q) || lc.code.toLowerCase().includes(q))
|
||||
);
|
||||
}, [query, selectedCodes]);
|
||||
|
||||
const popularFiltered = useMemo(() => {
|
||||
const q = query.toLowerCase().trim();
|
||||
return POPULAR_LANGS
|
||||
.map(lang => {
|
||||
const match = LANG_CODES.find(lc => lc.label.toLowerCase() === lang.toLowerCase());
|
||||
return match ? { lang, code: match.code } : null;
|
||||
})
|
||||
.filter(item => item && !selectedCodes.has(item.code) && (!q || item.lang.toLowerCase().includes(q) || item.code.includes(q)));
|
||||
}, [query, selectedCodes]);
|
||||
|
||||
return (
|
||||
<div className="multi-lang" ref={dropRef}>
|
||||
<div className="multi-lang__chips">
|
||||
{selected.map(s => (
|
||||
<span key={s.code} className="multi-lang__chip">
|
||||
<Globe size={9} />
|
||||
<span>{s.code}</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="multi-lang__chip-x"
|
||||
onClick={() => removeLang(s.code)}
|
||||
aria-label={`Remove ${s.lang}`}
|
||||
>
|
||||
<X size={8} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="multi-lang__add"
|
||||
onClick={() => setDropOpen(!dropOpen)}
|
||||
title="Add language"
|
||||
>
|
||||
<Plus size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selected.length > 0 && (
|
||||
<div className="multi-lang__summary">
|
||||
{selected.length} language{selected.length > 1 ? 's' : ''} selected
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dropOpen && (
|
||||
<div className="multi-lang__drop">
|
||||
<div className="multi-lang__search">
|
||||
<Search size={10} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Search languages…"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="multi-lang__list">
|
||||
{popularFiltered.length > 0 && (
|
||||
<>
|
||||
<div className="multi-lang__section">Popular</div>
|
||||
{popularFiltered.map(item => (
|
||||
<button
|
||||
key={item.code}
|
||||
type="button"
|
||||
className="multi-lang__option"
|
||||
onClick={() => addLang(item.lang, item.code)}
|
||||
>
|
||||
<span className="multi-lang__option-code">{item.code}</span>
|
||||
<span>{item.lang}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div className="multi-lang__section">All Languages</div>
|
||||
{filteredLangs.slice(0, 50).map(lc => (
|
||||
<button
|
||||
key={lc.code}
|
||||
type="button"
|
||||
className="multi-lang__option"
|
||||
onClick={() => addLang(lc.label, lc.code)}
|
||||
>
|
||||
<span className="multi-lang__option-code">{lc.code}</span>
|
||||
<span>{lc.label}</span>
|
||||
</button>
|
||||
))}
|
||||
{filteredLangs.length > 50 && (
|
||||
<div className="multi-lang__more">
|
||||
+{filteredLangs.length - 50} more — type to narrow
|
||||
</div>
|
||||
)}
|
||||
{filteredLangs.length === 0 && popularFiltered.length === 0 && (
|
||||
<div className="multi-lang__empty">No matches</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Globe, Fingerprint, Wand2, Film, FolderOpen, Settings2, ArrowLeftRight,
|
||||
Library,
|
||||
} from 'lucide-react';
|
||||
|
||||
const ITEMS = [
|
||||
@@ -8,6 +9,7 @@ const ITEMS = [
|
||||
{ id: 'clone', label: 'Clone', Icon: Fingerprint, accent: '#d3869b' },
|
||||
{ id: 'design', label: 'Design', Icon: Wand2, accent: '#8ec07c' },
|
||||
{ id: 'dub', label: 'Dub', Icon: Film, accent: '#fe8019' },
|
||||
{ id: 'gallery', label: 'Gallery', Icon: Library, accent: '#b8bb26' },
|
||||
{ id: 'projects', label: 'Projects', Icon: FolderOpen, accent: '#83a598' },
|
||||
];
|
||||
const FOOTER_ITEMS = [
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/* ── Readiness Checklist ────────────────────────────────────────────────
|
||||
VoiceBox-style system readiness panel showing pass/warn/fail gates
|
||||
for ASR model, TTS engine, LLM, ffmpeg, GPU, etc.
|
||||
─────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.readiness-checklist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-5);
|
||||
background: var(--color-bg-elev-1);
|
||||
backdrop-filter: var(--glass-blur-sm);
|
||||
-webkit-backdrop-filter: var(--glass-blur-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.readiness-checklist__title {
|
||||
font-weight: var(--weight-semibold);
|
||||
font-size: var(--text-md);
|
||||
color: var(--color-fg);
|
||||
margin: 0 0 var(--space-2) 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.readiness-checklist__title-icon {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.readiness-checklist__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.readiness-checklist__item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.readiness-checklist__item:hover {
|
||||
background: var(--color-bg-elev-3);
|
||||
}
|
||||
|
||||
/* ── Status icons ───────────────────────────────────────────────────── */
|
||||
|
||||
.readiness-checklist__status {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.readiness-checklist__status--pass { color: var(--color-success); }
|
||||
.readiness-checklist__status--warn { color: var(--color-accent); }
|
||||
.readiness-checklist__status--fail { color: var(--color-danger); }
|
||||
.readiness-checklist__status--loading {
|
||||
color: var(--color-fg-subtle);
|
||||
animation: rc-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rc-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Content ────────────────────────────────────────────────────────── */
|
||||
|
||||
.readiness-checklist__label {
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--color-fg);
|
||||
}
|
||||
|
||||
.readiness-checklist__detail {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-fg-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.readiness-checklist__fix {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-accent);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Compact summary when all pass ──────────────────────────────────── */
|
||||
|
||||
.readiness-checklist__all-pass {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: rgba(142, 192, 124, 0.08);
|
||||
border: 1px solid rgba(142, 192, 124, 0.15);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-success);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import React from 'react';
|
||||
import { CheckCircle, AlertTriangle, XCircle, Loader } from 'lucide-react';
|
||||
import { usePreflight, useModelStatus } from '../api/hooks';
|
||||
import './ReadinessChecklist.css';
|
||||
|
||||
/**
|
||||
* ReadinessChecklist — VoiceBox-style system readiness panel.
|
||||
*
|
||||
* Consumes the existing /setup/preflight endpoint (OS, RAM, GPU, ffmpeg,
|
||||
* yt-dlp, network) plus /model/status, and renders a compact pass/warn/fail
|
||||
* checklist. Mirrors into Settings and renders as empty-state on the
|
||||
* launchpad when no project is loaded.
|
||||
*
|
||||
* Hides itself when all gates are green (user doesn't need to see
|
||||
* "everything is fine" every time they open the app).
|
||||
*/
|
||||
|
||||
const StatusIcon = ({ status, size = 14 }) => {
|
||||
switch (status) {
|
||||
case 'pass': return <CheckCircle size={size} />;
|
||||
case 'warn': return <AlertTriangle size={size} />;
|
||||
case 'fail': return <XCircle size={size} />;
|
||||
case 'loading': return <Loader size={size} />;
|
||||
default: return <Loader size={size} />;
|
||||
}
|
||||
};
|
||||
|
||||
export default function ReadinessChecklist({ compact = false, showWhenAllPass = false }) {
|
||||
const { data: preflight, isLoading: preflightLoading } = usePreflight();
|
||||
const { data: modelData, isLoading: modelLoading } = useModelStatus();
|
||||
|
||||
const isLoading = preflightLoading || modelLoading;
|
||||
const modelStatus = modelData?.status ?? 'idle';
|
||||
|
||||
// Build the checklist from preflight data + model status
|
||||
const checks = [];
|
||||
|
||||
// Model readiness (from /model/status)
|
||||
const modelCheck = {
|
||||
id: 'asr-model',
|
||||
label: 'ASR Model',
|
||||
status: modelStatus === 'ready' ? 'pass'
|
||||
: modelStatus === 'loading' ? 'loading'
|
||||
: modelStatus === 'error' ? 'fail'
|
||||
: 'warn',
|
||||
detail: modelStatus === 'ready' ? 'Loaded and ready'
|
||||
: modelStatus === 'loading' ? 'Loading… (this may take 1-2 minutes on first run)'
|
||||
: modelStatus === 'error' ? 'Failed to load'
|
||||
: 'Not loaded yet — will load on first transcription',
|
||||
fix: modelStatus === 'error' ? 'Check logs for model loading errors. Try restarting.' : null,
|
||||
};
|
||||
checks.push(modelCheck);
|
||||
|
||||
// Add preflight checks
|
||||
if (preflight?.checks) {
|
||||
// Filter to the most relevant checks for the checklist
|
||||
const relevant = ['gpu', 'ffmpeg', 'yt-dlp', 'ram'];
|
||||
for (const check of preflight.checks) {
|
||||
if (relevant.includes(check.id)) {
|
||||
checks.push(check);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LLM configuration (check for translate endpoint)
|
||||
const llmCheck = {
|
||||
id: 'llm',
|
||||
label: 'LLM (Cinematic)',
|
||||
status: 'warn',
|
||||
detail: 'Configure TRANSLATE_BASE_URL for Cinematic translation quality',
|
||||
fix: 'Set TRANSLATE_BASE_URL and TRANSLATE_API_KEY environment variables. Works with Ollama, OpenAI, LM Studio, etc.',
|
||||
};
|
||||
// If we have preflight and there's a network check passing, LLM is at least possible
|
||||
if (preflight?.checks) {
|
||||
const netCheck = preflight.checks.find(c => c.id === 'network');
|
||||
if (netCheck?.status === 'pass') {
|
||||
llmCheck.detail = 'Optional — set TRANSLATE_BASE_URL for Cinematic quality';
|
||||
}
|
||||
}
|
||||
checks.push(llmCheck);
|
||||
|
||||
// Determine if all critical checks pass
|
||||
const allPass = checks.every(c => c.status === 'pass' || c.status === 'warn');
|
||||
const anyFail = checks.some(c => c.status === 'fail');
|
||||
const criticalFails = checks.filter(c => c.status === 'fail');
|
||||
|
||||
// Hide when everything is fine (unless explicitly asked to show)
|
||||
if (!showWhenAllPass && allPass && !isLoading) return null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="readiness-checklist">
|
||||
<div className="readiness-checklist__title">
|
||||
<span className="readiness-checklist__title-icon">🔍</span>
|
||||
Checking system…
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
// Compact mode: just show failing/warning items
|
||||
const issues = checks.filter(c => c.status !== 'pass');
|
||||
if (issues.length === 0) {
|
||||
return (
|
||||
<div className="readiness-checklist__all-pass">
|
||||
<CheckCircle size={14} />
|
||||
All systems ready
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="readiness-checklist">
|
||||
<ul className="readiness-checklist__list">
|
||||
{issues.map(check => (
|
||||
<li key={check.id} className="readiness-checklist__item">
|
||||
<span className={`readiness-checklist__status readiness-checklist__status--${check.status}`}>
|
||||
<StatusIcon status={check.status} />
|
||||
</span>
|
||||
<div>
|
||||
<div className="readiness-checklist__label">{check.label}</div>
|
||||
{check.fix && <div className="readiness-checklist__fix">{check.fix}</div>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="readiness-checklist">
|
||||
<div className="readiness-checklist__title">
|
||||
<span className="readiness-checklist__title-icon">
|
||||
{anyFail ? '⚠️' : '✅'}
|
||||
</span>
|
||||
System Readiness
|
||||
</div>
|
||||
<ul className="readiness-checklist__list">
|
||||
{checks.map(check => (
|
||||
<li key={check.id} className="readiness-checklist__item">
|
||||
<span className={`readiness-checklist__status readiness-checklist__status--${check.status}`}>
|
||||
<StatusIcon status={check.status} />
|
||||
</span>
|
||||
<div>
|
||||
<div className="readiness-checklist__label">{check.label}</div>
|
||||
<div className="readiness-checklist__detail">{check.detail}</div>
|
||||
{check.fix && <div className="readiness-checklist__fix">💡 {check.fix}</div>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -111,6 +111,11 @@
|
||||
.sidebar__empty-title { font-size: 0.82rem; margin: 0 0 var(--space-2); color: var(--chrome-fg); font-weight: 500; letter-spacing: 0.02em; }
|
||||
.sidebar__empty-sub { font-size: 0.7rem; margin: 0; color: var(--chrome-fg-dim); line-height: 1.5; }
|
||||
|
||||
/* Hide text-heavy blocks when sidebar is collapsed to icon-only width */
|
||||
.sidebar.is-collapsed .sidebar__empty { display: none; }
|
||||
.sidebar.is-collapsed .sidebar__subtitle { display: none; }
|
||||
.sidebar.is-collapsed .sidebar__search { display: none; }
|
||||
|
||||
/* Section label — matches the status-bar's "LOGS" uppercase treatment.
|
||||
Labels are displays (not buttons) but this one IS clickable (collapses
|
||||
the section), so the chevron on the right signals interactivity while
|
||||
|
||||
@@ -44,6 +44,7 @@ export default function Sidebar(props) {
|
||||
saveProject, loadProject, deleteProject,
|
||||
handleSelectProfile, handleDeleteProfile, handleOpenVoiceProfile,
|
||||
handleUnlockProfile, handleLockProfile, handlePreviewVoice,
|
||||
onOpenVoicePreview,
|
||||
restoreHistory, restoreDubHistory,
|
||||
handleSaveHistoryAsProfile,
|
||||
handleNativeExport, revealInFolder,
|
||||
@@ -260,6 +261,15 @@ export default function Sidebar(props) {
|
||||
<button className="history-action-btn" onClick={(e) => { e.stopPropagation(); handleSelectProfile(proj); }}>
|
||||
<Check size={10} /> Select
|
||||
</button>
|
||||
{onOpenVoicePreview && (
|
||||
<button
|
||||
className="history-action-btn accent"
|
||||
onClick={(e) => { e.stopPropagation(); onOpenVoicePreview(proj.id); }}
|
||||
title="Open interactive voice preview"
|
||||
>
|
||||
<Volume2 size={10} /> Try
|
||||
</button>
|
||||
)}
|
||||
{proj.is_locked ? (
|
||||
<button className="history-action-btn accent history-action-icon" onClick={(e) => { e.stopPropagation(); handleUnlockProfile(proj.id); }} title="Unlock">
|
||||
<Unlock size={10} />
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
.voice-preview {
|
||||
position: fixed;
|
||||
bottom: 56px;
|
||||
right: 16px;
|
||||
z-index: 900;
|
||||
width: 320px;
|
||||
background: var(--chrome-bg);
|
||||
border: 1px solid var(--chrome-border-strong);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: voice-preview-in 0.2s ease-out;
|
||||
}
|
||||
@keyframes voice-preview-in {
|
||||
from { opacity: 0; transform: translateY(12px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.voice-preview__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
}
|
||||
.voice-preview__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
.voice-preview__close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--chrome-fg-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.voice-preview__close:hover {
|
||||
background: var(--chrome-hover-bg);
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
|
||||
.voice-preview__body {
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.voice-preview__select {
|
||||
font-size: 0.78rem;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.voice-preview__text {
|
||||
font-size: 0.78rem;
|
||||
padding: 8px;
|
||||
resize: none;
|
||||
line-height: 1.4;
|
||||
min-height: 48px;
|
||||
}
|
||||
.voice-preview__audio {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.voice-preview__audio::-webkit-media-controls-panel {
|
||||
background: var(--chrome-hover-bg);
|
||||
}
|
||||
|
||||
.voice-preview__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 14px 10px;
|
||||
border-top: 1px solid var(--chrome-border);
|
||||
}
|
||||
.voice-preview__hint {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
color: var(--chrome-fg-dim);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { Volume2, Play, Square, Loader, X, Mic } from 'lucide-react';
|
||||
import { generateSpeech } from '../api/generate';
|
||||
import { PRESETS } from '../utils/constants';
|
||||
import { Button } from '../ui';
|
||||
import './VoicePreview.css';
|
||||
|
||||
/**
|
||||
* VoicePreview — floating "try a voice" card.
|
||||
*
|
||||
* Opens as a bottom-right popover. User picks a voice profile, types a
|
||||
* sentence, hits Play → hears TTS output instantly (8 inference steps for
|
||||
* speed). The result is disposable — it doesn't save to history.
|
||||
*/
|
||||
const DEFAULT_TEXT = 'Hello! This is a preview of how I sound in this voice.';
|
||||
|
||||
export default function VoicePreview({
|
||||
open,
|
||||
onClose,
|
||||
profiles = [],
|
||||
initialProfileId = '',
|
||||
fileToMediaUrl,
|
||||
}) {
|
||||
const [text, setText] = useState(DEFAULT_TEXT);
|
||||
const [voiceId, setVoiceId] = useState(initialProfileId);
|
||||
const [audioUrl, setAudioUrl] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const audioRef = useRef(null);
|
||||
const abortRef = useRef(null);
|
||||
|
||||
// Sync initialProfileId when it changes (e.g. clicking preview on a different profile)
|
||||
React.useEffect(() => {
|
||||
if (initialProfileId) setVoiceId(initialProfileId);
|
||||
}, [initialProfileId]);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!text.trim()) return;
|
||||
setLoading(true);
|
||||
setAudioUrl(null);
|
||||
|
||||
const ac = new AbortController();
|
||||
abortRef.current = ac;
|
||||
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('text', text);
|
||||
fd.append('num_step', '8'); // fast preview
|
||||
fd.append('guidance_scale', '2.0');
|
||||
fd.append('speed', '1.0');
|
||||
fd.append('denoise', 'true');
|
||||
fd.append('postprocess_output', 'true');
|
||||
|
||||
let profileId = voiceId;
|
||||
let instruct = '';
|
||||
|
||||
if (profileId.startsWith('preset:')) {
|
||||
const pr = PRESETS.find(p => p.id === profileId.replace('preset:', ''));
|
||||
if (pr) {
|
||||
instruct = Object.values(pr.attrs).filter(v => v !== 'Auto').join(', ');
|
||||
}
|
||||
profileId = '';
|
||||
} else {
|
||||
const match = profiles.find(p => p.id === profileId);
|
||||
if (match?.instruct) instruct = match.instruct;
|
||||
}
|
||||
|
||||
if (profileId) fd.append('profile_id', profileId);
|
||||
if (instruct) fd.append('instruct', instruct);
|
||||
|
||||
const res = await generateSpeech(fd, { signal: ac.signal });
|
||||
if (!res.ok) throw new Error(`TTS failed: ${res.status}`);
|
||||
|
||||
const blob = await res.blob();
|
||||
const urls = await fileToMediaUrl(blob, null);
|
||||
setAudioUrl(urls.audioUrl);
|
||||
|
||||
// Auto-play
|
||||
setTimeout(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.play().catch(() => {});
|
||||
}
|
||||
}, 50);
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('Preview generation failed:', err);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [text, voiceId, profiles, fileToMediaUrl]);
|
||||
|
||||
const handleStop = () => {
|
||||
abortRef.current?.abort();
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
}
|
||||
setPlaying(false);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="voice-preview">
|
||||
<div className="voice-preview__head">
|
||||
<span className="voice-preview__title">
|
||||
<Volume2 size={13} /> Voice Preview
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="voice-preview__close"
|
||||
onClick={onClose}
|
||||
aria-label="Close preview"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="voice-preview__body">
|
||||
<select
|
||||
className="input-base voice-preview__select"
|
||||
value={voiceId}
|
||||
onChange={e => setVoiceId(e.target.value)}
|
||||
>
|
||||
<option value="">Default voice</option>
|
||||
{profiles.filter(p => !p.instruct).length > 0 && (
|
||||
<optgroup label="Clone Profiles">
|
||||
{profiles.filter(p => !p.instruct).map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{profiles.filter(p => !!p.instruct).length > 0 && (
|
||||
<optgroup label="Designed Voices">
|
||||
{profiles.filter(p => !!p.instruct).map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{PRESETS.length > 0 && (
|
||||
<optgroup label="Presets">
|
||||
{PRESETS.map(p => (
|
||||
<option key={p.id} value={`preset:${p.id}`}>{p.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
|
||||
<textarea
|
||||
className="input-base voice-preview__text"
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Type something to hear…"
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
{audioUrl && (
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={audioUrl}
|
||||
className="voice-preview__audio"
|
||||
controls
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onEnded={() => setPlaying(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="voice-preview__foot">
|
||||
{loading ? (
|
||||
<Button variant="ghost" size="sm" onClick={handleStop} leading={<Square size={10} />}>
|
||||
Stop
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleGenerate}
|
||||
disabled={!text.trim()}
|
||||
loading={loading}
|
||||
leading={!loading && <Play size={10} />}
|
||||
>
|
||||
{audioUrl ? 'Regenerate' : 'Preview'}
|
||||
</Button>
|
||||
)}
|
||||
<span className="voice-preview__hint">8 steps · fast preview</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/* ═══ WaveformTimeline extracted layout styles ═══ */
|
||||
.wfm-layout {
|
||||
display: flex; flex-direction: column; flex: 1; min-height: 0;
|
||||
}
|
||||
.wfm-stack { display: flex; flex-direction: column; gap: 4px; flex: 1; min-height: 0; }
|
||||
.wfm-video-preview {
|
||||
flex: 0 0 auto; aspect-ratio: 16 / 9; max-height: 55%;
|
||||
background: #000; border-radius: 4px; overflow: hidden;
|
||||
border: 1px solid rgba(255,255,255,0.05); display: flex;
|
||||
}
|
||||
.wfm-wave-wrap {
|
||||
position: relative; overflow: hidden; flex: 1 1 auto; min-height: 140px;
|
||||
}
|
||||
.wfm-wave-inner {
|
||||
height: 100%; min-height: 140px; border-radius: 4px; width: 100%; overflow: hidden;
|
||||
}
|
||||
.wfm-loading {
|
||||
position: absolute; inset: 0; display: flex; align-items: center;
|
||||
justify-content: center; background: rgba(0,0,0,0.45);
|
||||
border-radius: 4px; z-index: 3; gap: 6px;
|
||||
}
|
||||
.wfm-loading__text { font-size: 0.65rem; color: #a89984; }
|
||||
.wfm-overlay {
|
||||
position: absolute; inset: 0; border-radius: 4px; z-index: 4;
|
||||
background: rgba(29,32,33,0.85); backdrop-filter: blur(3px);
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; gap: 6px; padding: 8px;
|
||||
}
|
||||
.wfm-controls { flex-shrink: 0; margin-top: 3px; }
|
||||
.wfm-error {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 8px; background: rgba(0,0,0,0.15); border-radius: 4px;
|
||||
border: 1px solid rgba(255,255,255,0.04); color: #a89984; font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* ═══ ErrorBoundary extracted styles ═══ */
|
||||
.errbnd-wrap {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
padding: 32px; font-family: var(--font-sans);
|
||||
}
|
||||
.errbnd-card {
|
||||
max-width: 520px; width: 100%; padding: 22px; text-align: center;
|
||||
background: var(--chrome-bg);
|
||||
border: 1px solid color-mix(in srgb, var(--chrome-severity-err) 35%, transparent);
|
||||
border-left: 2px solid var(--chrome-severity-err);
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
}
|
||||
.errbnd-icon { margin-bottom: 10px; }
|
||||
.errbnd-title {
|
||||
font-family: var(--font-serif); font-style: italic;
|
||||
font-size: 1.6rem; font-weight: 400;
|
||||
color: var(--chrome-fg); margin: 0 0 6px; letter-spacing: -0.01em;
|
||||
}
|
||||
.errbnd-desc {
|
||||
color: var(--chrome-fg-muted); font-size: 0.82rem;
|
||||
margin: 0 0 12px; line-height: 1.5;
|
||||
}
|
||||
.errbnd-trace {
|
||||
text-align: left; font-size: 0.72rem; color: var(--chrome-severity-err);
|
||||
background: var(--chrome-hover-bg); padding: 8px 10px;
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
border: 1px solid var(--chrome-border);
|
||||
max-height: 140px; overflow: auto; margin: 0 0 14px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.errbnd-retry {
|
||||
padding: 6px 14px; font-size: 0.78rem; font-weight: 500;
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import RegionsPlugin from 'wavesurfer.js/dist/plugins/regions.esm.js';
|
||||
import { Play, Pause, ZoomIn, ZoomOut, SkipBack, Loader } from 'lucide-react';
|
||||
import './WaveformErrorBoundary.css';
|
||||
|
||||
const REGION_COLORS = [
|
||||
'rgba(211,134,155,0.3)',
|
||||
@@ -266,7 +267,13 @@ export default function WaveformTimeline({
|
||||
|
||||
// ── Zoom ────────────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (wsRef.current && ready) wsRef.current.zoom(zoom);
|
||||
if (wsRef.current && ready) {
|
||||
try {
|
||||
wsRef.current.zoom(zoom);
|
||||
} catch (err) {
|
||||
console.warn('WaveSurfer zoom failed:', err);
|
||||
}
|
||||
}
|
||||
}, [zoom, ready]);
|
||||
|
||||
// ── Sync regions — skips when dragging or fingerprint unchanged ─────────────
|
||||
@@ -325,11 +332,7 @@ export default function WaveformTimeline({
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="waveform-timeline">
|
||||
<div style={{
|
||||
display:'flex', alignItems:'center', justifyContent:'center',
|
||||
padding:8, background:'rgba(0,0,0,0.15)', borderRadius:4,
|
||||
border:'1px solid rgba(255,255,255,0.04)', color:'#a89984', fontSize:'0.7rem',
|
||||
}}>
|
||||
<div className="wfm-error">
|
||||
⚠ Could not load audio from this file
|
||||
</div>
|
||||
</div>
|
||||
@@ -337,50 +340,36 @@ export default function WaveformTimeline({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="waveform-timeline" style={{display:'flex', flexDirection:'column', flex:1, minHeight:0}}>
|
||||
<div className="waveform-timeline wfm-layout">
|
||||
{/* Video + Waveform stacked vertically */}
|
||||
<div style={{display:'flex', flexDirection:'column', gap:4, flex:1, minHeight:0}}>
|
||||
<div className="wfm-stack">
|
||||
{/* Video preview — pinned to its aspect ratio so we don't letterbox
|
||||
into huge black bars. Waveform gets the remaining height. */}
|
||||
{videoSrc && (
|
||||
<div
|
||||
ref={videoContainerRef}
|
||||
style={{
|
||||
flex:'0 0 auto', aspectRatio:'16 / 9', maxHeight:'55%',
|
||||
background:'#000', borderRadius:4, overflow:'hidden',
|
||||
border:'1px solid rgba(255,255,255,0.05)', display: 'flex',
|
||||
}}
|
||||
className="wfm-video-preview"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Waveform — fills the rest. This is the actual editing surface. */}
|
||||
<div style={{position:'relative', overflow:'hidden', flex:'1 1 auto', minHeight:140}}>
|
||||
<div className="wfm-wave-wrap">
|
||||
<div
|
||||
ref={waveContainerRef}
|
||||
className="waveform-container"
|
||||
style={{height:'100%', minHeight:140, borderRadius:4, width:'100%', overflow:'hidden'}}
|
||||
className="waveform-container wfm-wave-inner"
|
||||
/>
|
||||
|
||||
{/* Loading shimmer */}
|
||||
{!ready && !loadError && (
|
||||
<div style={{
|
||||
position:'absolute', inset:0, display:'flex', alignItems:'center',
|
||||
justifyContent:'center', background:'rgba(0,0,0,0.45)',
|
||||
borderRadius:4, zIndex:3, gap:6,
|
||||
}}>
|
||||
<div className="wfm-loading">
|
||||
<Loader className="spinner" size={12} color="#d3869b"/>
|
||||
<span style={{fontSize:'0.65rem', color:'#a89984'}}>Loading waveform…</span>
|
||||
<span className="wfm-loading__text">Loading waveform…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay slot — transcription / dubbing progress */}
|
||||
{overlayContent && (
|
||||
<div style={{
|
||||
position:'absolute', inset:0, borderRadius:4, zIndex:4,
|
||||
background:'rgba(29,32,33,0.85)', backdropFilter:'blur(3px)',
|
||||
display:'flex', flexDirection:'column', alignItems:'center',
|
||||
justifyContent:'center', gap:6, padding:8,
|
||||
}}>
|
||||
<div className="wfm-overlay">
|
||||
{overlayContent}
|
||||
</div>
|
||||
)}
|
||||
@@ -388,7 +377,7 @@ export default function WaveformTimeline({
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="waveform-controls" style={{flexShrink:0, marginTop:3}}>
|
||||
<div className="waveform-controls wfm-controls">
|
||||
<div className="waveform-controls-left">
|
||||
<button className="waveform-btn" onClick={() => seekTo(0)} title="Restart"><SkipBack size={11}/></button>
|
||||
<button className="waveform-btn waveform-btn-play" onClick={togglePlay} disabled={!ready}>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* useRealtimeEvents — WebSocket connection to /ws/events for live sidebar updates.
|
||||
*
|
||||
* Connects once on mount, automatically reconnects with exponential backoff,
|
||||
* and dispatches invalidation signals to the parent callbacks.
|
||||
*
|
||||
* Events from backend:
|
||||
* { kind: "projects", action: "created"|"updated"|"deleted", id: "..." }
|
||||
* { kind: "profiles", action: "created"|"updated"|"locked"|"unlocked"|"deleted", id: "..." }
|
||||
* { kind: "dub_history", action: "saved"|"deleted", id: "..." }
|
||||
* { kind: "export_history", action: "exported"|"recorded", id: "..." }
|
||||
* { kind: "ping" } // keepalive, ignored
|
||||
*/
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { API } from '../api/client';
|
||||
|
||||
const WS_EVENTS_URL = API.replace(/^http/, 'ws') + '/ws/events';
|
||||
|
||||
/**
|
||||
* @param {Object} handlers - Map of event kind → callback
|
||||
* @param {Function} handlers.projects - Called when projects list changes
|
||||
* @param {Function} handlers.profiles - Called when profiles list changes
|
||||
* @param {Function} handlers.dub_history - Called when dub history changes
|
||||
* @param {Function} handlers.export_history - Called when export history changes
|
||||
*/
|
||||
export default function useRealtimeEvents(handlers) {
|
||||
const wsRef = useRef(null);
|
||||
const handlersRef = useRef(handlers);
|
||||
const reconnectTimerRef = useRef(null);
|
||||
const retryCountRef = useRef(0);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
// Keep handlers ref current without causing reconnects
|
||||
useEffect(() => { handlersRef.current = handlers; });
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!mountedRef.current) return;
|
||||
// Don't double-connect
|
||||
if (wsRef.current && wsRef.current.readyState <= 1) return;
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(WS_EVENTS_URL);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
retryCountRef.current = 0;
|
||||
console.debug('[ws/events] connected');
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const event = JSON.parse(e.data);
|
||||
const kind = event.kind;
|
||||
if (kind === 'ping') return; // keepalive, ignore
|
||||
|
||||
const handler = handlersRef.current?.[kind];
|
||||
if (handler) {
|
||||
handler(event);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[ws/events] bad message:', e.data, err);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (e) => {
|
||||
wsRef.current = null;
|
||||
if (!mountedRef.current) return;
|
||||
// Exponential backoff: 2s, 4s, 8s, 16s, max 60s
|
||||
const delay = Math.min(2000 * Math.pow(2, retryCountRef.current), 60_000);
|
||||
retryCountRef.current++;
|
||||
if (retryCountRef.current <= 5) {
|
||||
console.debug(`[ws/events] closed (code=${e.code}), reconnecting in ${delay}ms`);
|
||||
}
|
||||
reconnectTimerRef.current = setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// onerror is always followed by onclose, so we just let onclose handle reconnect
|
||||
ws.close();
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[ws/events] connection failed:', err);
|
||||
const delay = Math.min(1000 * Math.pow(2, retryCountRef.current), 30_000);
|
||||
retryCountRef.current++;
|
||||
reconnectTimerRef.current = setTimeout(connect, delay);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null; // prevent reconnect on unmount
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [connect]);
|
||||
}
|
||||