Compare commits
193
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4254ed6d8 | ||
|
|
58e0d9021b | ||
|
|
33ce88e465 | ||
|
|
10a0fc27f0 | ||
|
|
63ade08b6f | ||
|
|
fc7021e2b4 | ||
|
|
1038a487a8 | ||
|
|
19b9b4a136 | ||
|
|
d9bf251665 | ||
|
|
5bc9f7856a | ||
|
|
8428517d95 | ||
|
|
6caabf3b0b | ||
|
|
6b802beaf9 | ||
|
|
2ed38c476e | ||
|
|
078bc06428 | ||
|
|
99f93164be | ||
|
|
af245a45ee | ||
|
|
dbbad3d58e | ||
|
|
8667b34ca5 | ||
|
|
bcd2e53175 | ||
|
|
f18a7adddf | ||
|
|
a968bc57b3 | ||
|
|
7ac5e5ab3d | ||
|
|
f2cfdaa430 | ||
|
|
a768e84ffe | ||
|
|
4648c23256 | ||
|
|
1e96748402 | ||
|
|
169e36d5b6 | ||
|
|
135dd0a3a7 | ||
|
|
ef51c6b75d | ||
|
|
24252712d2 | ||
|
|
b49d2954d5 | ||
|
|
bf8da941d1 | ||
|
|
ca3bf8367f | ||
|
|
0c255dfabd | ||
|
|
4008499ea5 | ||
|
|
e32bc94248 | ||
|
|
fc8db259a9 | ||
|
|
0921292319 | ||
|
|
9afc9db287 | ||
|
|
3b64692eed | ||
|
|
30307ae883 | ||
|
|
9dbf45ca2c | ||
|
|
0e7ee3d912 | ||
|
|
140e9262b9 | ||
|
|
18a0c11c23 | ||
|
|
a0b84a6b61 | ||
|
|
8c67b10942 | ||
|
|
760e9e5ac7 | ||
|
|
25498dd17b | ||
|
|
16020c2178 | ||
|
|
e4471008b4 | ||
|
|
2823d6a8ea | ||
|
|
57150ede64 | ||
|
|
df46b71be2 | ||
|
|
a41e66e0f0 | ||
|
|
2b2bfe1a7f | ||
|
|
f728071819 | ||
|
|
59a487e742 | ||
|
|
ef9bda2e17 | ||
|
|
e8a6950898 | ||
|
|
43d47f4d36 | ||
|
|
93bbfd64ea | ||
|
|
c973b1c983 | ||
|
|
c4530b4919 | ||
|
|
b4b10c1431 | ||
|
|
99218e2c34 | ||
|
|
b759ae7749 | ||
|
|
b2f5de328e | ||
|
|
f2dd6c95db | ||
|
|
ee3e147401 | ||
|
|
af7f1ceb3d | ||
|
|
0cc22ad803 | ||
|
|
62b0e05c1d | ||
|
|
aed50121b4 | ||
|
|
079be2fbe5 | ||
|
|
f463276ca5 | ||
|
|
5a1ada0757 | ||
|
|
6396cc2b3f | ||
|
|
997ba9e673 | ||
|
|
a438f5db0e | ||
|
|
f72ab3a3b2 | ||
|
|
1d30a50c1c | ||
|
|
c36df0ddf0 | ||
|
|
85cb7242ea | ||
|
|
c547da351c | ||
|
|
602be02a17 | ||
|
|
f395c90aa1 | ||
|
|
01cb810c65 | ||
|
|
f4e5d7b9ab | ||
|
|
caedcde0c6 | ||
|
|
2a36a24612 | ||
|
|
610303ec11 | ||
|
|
3a114d62dd | ||
|
|
65e6c275c6 | ||
|
|
9a90fe41be | ||
|
|
d55838542e | ||
|
|
9c0a5469cc | ||
|
|
c7a8d24af6 | ||
|
|
7d963cf297 | ||
|
|
b9cb6c204f | ||
|
|
6157c1717b | ||
|
|
c3b54393c4 | ||
|
|
f0bb3fb8b2 | ||
|
|
157f2987dc | ||
|
|
426bd1ecea | ||
|
|
d34490fd15 | ||
|
|
9b9d4d6579 | ||
|
|
eee35ab771 | ||
|
|
0095c4a089 | ||
|
|
ef30dbece7 | ||
|
|
a9062c2fa1 | ||
|
|
b7ebd6e54d | ||
|
|
cd58bbded0 | ||
|
|
b9183bb2a6 | ||
|
|
e9495beb8b | ||
|
|
409dd016ce | ||
|
|
db10e4b70f | ||
|
|
3613b3d39b | ||
|
|
4277859a44 | ||
|
|
eefc45ec49 | ||
|
|
2f5a52f9eb | ||
|
|
729cad8912 | ||
|
|
86f9eda65f | ||
|
|
32c96eeee4 | ||
|
|
f5d52e479e | ||
|
|
eafd94d0a6 | ||
|
|
2b5e3cdbc6 | ||
|
|
2229a68cfc | ||
|
|
0581fb69fd | ||
|
|
85a368a08b | ||
|
|
a8c57c816b | ||
|
|
360d1d29c8 | ||
|
|
7366555c95 | ||
|
|
b8ff2721a5 | ||
|
|
da26a240e2 | ||
|
|
fb3d9b7139 | ||
|
|
6c47ae7b21 | ||
|
|
fda73384f0 | ||
|
|
84add8b279 | ||
|
|
8b510d73db | ||
|
|
b1194c6223 | ||
|
|
75f8924222 | ||
|
|
2a6d089f4a | ||
|
|
5d134f22ec | ||
|
|
08af0a971e | ||
|
|
ff0ce4d37d | ||
|
|
1ab03067cf | ||
|
|
7a2f86066b | ||
|
|
d91beef0fd | ||
|
|
574b634688 | ||
|
|
62ab62fc57 | ||
|
|
ecbd152c43 | ||
|
|
fec2e7b5f3 | ||
|
|
04bc9cedac | ||
|
|
e397e10d64 | ||
|
|
8e1da3c22f | ||
|
|
b92cc3bb79 | ||
|
|
d757f160fa | ||
|
|
a9ac8b1b46 | ||
|
|
f88bfecabc | ||
|
|
753891adc9 | ||
|
|
4efde9ce4e | ||
|
|
ae2fa83b02 | ||
|
|
a671004cb7 | ||
|
|
ed6fca46a9 | ||
|
|
65d37288ce | ||
|
|
293b0812f4 | ||
|
|
b441a486cc | ||
|
|
d4bf1fe9a9 | ||
|
|
5574cbbc16 | ||
|
|
13eae6ff02 | ||
|
|
fd5e78cdab | ||
|
|
67c316e81b | ||
|
|
81fb585557 | ||
|
|
3675d750d6 | ||
|
|
bbbf185cf3 | ||
|
|
ed746bab57 | ||
|
|
847ca6d44c | ||
|
|
784494dc3c | ||
|
|
6c42b50fe3 | ||
|
|
2594abebf8 | ||
|
|
e4f00bc564 | ||
|
|
6a2ada2f11 | ||
|
|
9ac1045917 | ||
|
|
31d90def83 | ||
|
|
d5c33eda27 | ||
|
|
36a38bdbb9 | ||
|
|
02c3a651ac | ||
|
|
fa148ecb55 | ||
|
|
f172d0c3be | ||
|
|
fcac8e1bae | ||
|
|
4e6c36848c |
+69
-436
@@ -1,444 +1,77 @@
|
||||
# PR-gated continuous integration — runs backend pytest + frontend node:test
|
||||
# + TypeScript typecheck on every pull request and push to main. Keeps the
|
||||
# heavy 4-platform Tauri bundle off this path (that's release.yml on tag
|
||||
# push) so PRs turn around in a few minutes instead of ~40.
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
'on':
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# Run all JavaScript actions on Node 24 (GH deprecates Node 20 in Sep 2026).
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
|
||||
jobs:
|
||||
test:
|
||||
name: Tests (backend + frontend)
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
# Same restricted-network resilience the smoke matrix already sets. This
|
||||
# job resolves the same direct-URL dependency and had none of it, which
|
||||
# is why it was the one that kept dying (see scripts/uv-sync-retry.sh).
|
||||
UV_HTTP_TIMEOUT: "120"
|
||||
UV_HTTP_RETRIES: "5"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
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/*.
|
||||
- name: Setup Node 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- 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)
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
|
||||
with:
|
||||
packages: ffmpeg
|
||||
version: 1.0
|
||||
|
||||
- name: Install Python deps
|
||||
# `--all-extras` installs optional engine deps (e.g. `supertonic`)
|
||||
# so their tests can exercise the real import path, not the
|
||||
# "package not installed" fallback. Smoke job below stays on bare
|
||||
# `uv sync` because smoke only hits /health + fixture profiles.
|
||||
#
|
||||
# Retried because one dependency — en-core-web-sm — resolves to a
|
||||
# direct GitHub release URL, and github.com intermittently answers
|
||||
# `http2 error: refused stream before processing any application
|
||||
# logic`. uv's own 3 retries all land inside the same few seconds and
|
||||
# fail together, which has cost otherwise-green runs (#1517, #1518).
|
||||
# Backing off between whole attempts is what actually clears it.
|
||||
run: bash scripts/uv-sync-retry.sh --all-extras
|
||||
|
||||
# HF_HUB_OFFLINE=1 is a recurrence guard, not an optimization: a test
|
||||
# that reaches huggingface.co fails fast and loud instead of silently
|
||||
# downloading model weights mid-suite (the preload_model() Hub-probe
|
||||
# bug pulled the full 2.3 GB k2-fsa/OmniVoice checkpoint into every
|
||||
# networked empty-cache run before it was caught). All legitimate HF
|
||||
# interactions in tests are stubbed; anything that trips this is a
|
||||
# test-isolation bug.
|
||||
- name: Run pytest
|
||||
run: uv run --no-sync pytest tests/ -q --tb=short
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1"
|
||||
|
||||
# Docs-drift CI gate (Phase 1 INST-06). The validator extracts code
|
||||
# blocks tagged `<!-- validate -->` from docs/install/*.md and asserts
|
||||
# each line appears in scripts/desktop-prod.sh after normalisation.
|
||||
# Its own correctness is enforced by tests/scripts/test_validate_install_docs.py
|
||||
# (checker B-5) — those tests run in the previous step.
|
||||
- name: Validate install docs against desktop-prod.sh
|
||||
run: python scripts/validate-install-docs.py
|
||||
|
||||
# The AppImage launcher decides which WebKitGTK actually runs — the wrong
|
||||
# answer is a permanently blank window on Linux (#56, #961, #1258), and
|
||||
# the only place that logic is exercised is this shell harness. It had
|
||||
# never been wired into CI, so its cases were a regression test nothing
|
||||
# ran. Cheap (pure bash, stubs pkg-config) and it gates the class.
|
||||
- name: AppImage launcher (AppRun) unit tests
|
||||
run: |
|
||||
bash frontend/src-tauri/appimage/AppRun.test.sh
|
||||
bash scripts/inject-apprun.test.sh
|
||||
bash scripts/verify-apprun-bundle.test.sh
|
||||
|
||||
# `backend/tests/` mounts routers on bare FastAPI apps (no heavy main
|
||||
# import chain) with a hermetic data dir from its conftest.py. It no
|
||||
# longer stubs sys.modules, so mixed sessions with tests/ are safe;
|
||||
# the separate session is kept for cheaper, clearer CI output.
|
||||
- name: Run pytest (backend/tests, isolated)
|
||||
run: uv run --no-sync pytest backend/tests/ -q --tb=short
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1" # same no-silent-downloads guard as tests/
|
||||
|
||||
# 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
|
||||
# --frozen-lockfile so a frontend/package.json change that forgets to
|
||||
# regenerate the root bun.lock fails HERE (fast) instead of only in the
|
||||
# Docker build (deploy/Dockerfile), which is what reddened main on #485.
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
# checkJs is true in tsconfig for IDE feedback, but 947 pre-existing
|
||||
# JS errors remain. Override to false in CI so only .ts files block.
|
||||
# Sourced from `typecheck:ci` in frontend/package.json so the release
|
||||
# workflow runs an identical command — drift broke v0.3.x release runs.
|
||||
- name: Frontend typecheck
|
||||
working-directory: frontend
|
||||
run: bun run typecheck:ci
|
||||
|
||||
# oxlint gate — fast Rust linter, blocks on errors so lint debt can't
|
||||
# re-accumulate (warnings, incl. the react-compiler advisories in
|
||||
# `lint:hooks`, are non-blocking). See frontend/.oxlintrc.json.
|
||||
- name: Frontend lint (oxlint)
|
||||
working-directory: frontend
|
||||
run: bun run lint
|
||||
|
||||
# oxfmt format gate — JS/TS/JSX only (CSS/JSON/Tauri excluded; see
|
||||
# frontend/.oxfmtrc.json). `bun run format` fixes locally.
|
||||
- name: Frontend format check (oxfmt)
|
||||
working-directory: frontend
|
||||
run: bun run format:check
|
||||
|
||||
# `bun run test` (frontend/package.json), not `bunx vitest` — bunx
|
||||
# resolves by npm package name and can miss workspace-hoisted bins,
|
||||
# then falls back to fetching from npm (#962 class).
|
||||
- name: Run Vitest (frontend)
|
||||
working-directory: frontend
|
||||
run: bun run test
|
||||
|
||||
# Legacy node:test runner for tests/frontend/*.test.mjs
|
||||
- name: Run frontend node:test (legacy)
|
||||
working-directory: frontend
|
||||
run: node --experimental-strip-types --no-warnings --test ../tests/frontend/*.test.mjs
|
||||
|
||||
# Production-bundle blank-screen gate. Everything above runs UN-minified
|
||||
# (dev server + Vitest/jsdom), so a crash that exists ONLY in the minified
|
||||
# release bundle — a TDZ reorder that throws before React mounts — passes
|
||||
# every check and ships a black screen. That is how v0.3.22 went out (#1178),
|
||||
# and it recurred pre-0.3.23. This builds the real dist/ and asserts the app
|
||||
# actually mounts into #root. See frontend/e2e-prod/prod-bundle-smoke.spec.ts.
|
||||
# (The in-app root <ErrorBoundary> in main-app.jsx catches such throws at
|
||||
# runtime; this gate stops them reaching a release in the first place.)
|
||||
- name: Install Playwright chromium
|
||||
working-directory: frontend
|
||||
run: bunx playwright install --with-deps chromium
|
||||
- name: Production-bundle smoke — no blank screen
|
||||
working-directory: frontend
|
||||
run: bun run test:prod-bundle
|
||||
|
||||
# ── Cross-platform Tauri shell check ────────────────────────────────────
|
||||
# Catches platform-specific Rust regressions on PR (cfg(target_os=...)
|
||||
# gates, missing Windows/macOS deps, etc.) without spending the 15+ min
|
||||
# per-platform that a full `tauri build` takes. `cargo check` is the
|
||||
# lightest gate that exercises type-checking + linking for each target,
|
||||
# and `cargo test --lib` runs the shell's unit tests natively on each OS.
|
||||
# Full bundling stays in release.yml on tag push.
|
||||
tauri-cross-platform:
|
||||
name: Tauri shell check (${{ matrix.label }})
|
||||
needs: test
|
||||
diagnostic:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-14
|
||||
label: macOS
|
||||
rust_target: aarch64-apple-darwin
|
||||
- os: windows-2022
|
||||
label: Windows
|
||||
rust_target: x86_64-pc-windows-msvc
|
||||
- os: ubuntu-24.04
|
||||
label: Linux
|
||||
rust_target: x86_64-unknown-linux-gnu
|
||||
runs-on: ${{ matrix.os }}
|
||||
rust_target:
|
||||
- x86_64-pc-windows-msvc
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust (stable)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.rust_target }}
|
||||
|
||||
# Per-target cache key so we don't conflict with the release matrix.
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: frontend/src-tauri -> target
|
||||
key: ${{ matrix.rust_target }}-check
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
|
||||
# Linux is the only host with non-trivial Tauri build deps —
|
||||
# webkit2gtk + libayatana-appindicator + xdo. Mirror release.yml.
|
||||
- name: Linux system deps
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
build-essential curl wget file libxdo-dev libssl-dev \
|
||||
libayatana-appindicator3-dev librsvg2-dev \
|
||||
libasound2-dev
|
||||
|
||||
- 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
|
||||
# --frozen-lockfile so a frontend/package.json change that forgets to
|
||||
# regenerate the root bun.lock fails HERE (fast) instead of only in the
|
||||
# Docker build (deploy/Dockerfile), which is what reddened main on #485.
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
# tauri-build's setup hook reads tauri.conf.json's `frontendDist`
|
||||
# ("../dist"), which only exists after a frontend build. Without this,
|
||||
# `cargo check` would fail on a fresh checkout because the embedded
|
||||
# asset map can't resolve.
|
||||
- name: Build frontend (for tauri.conf.json frontendDist)
|
||||
working-directory: frontend
|
||||
run: bun run build
|
||||
|
||||
- name: Cargo check (Tauri shell)
|
||||
working-directory: frontend/src-tauri
|
||||
run: cargo check --target ${{ matrix.rust_target }} --message-format=short
|
||||
|
||||
# `cargo check` never compiles #[cfg(test)] code, so without this the
|
||||
# shell's unit tests (crash.rs, reset.rs, commands.rs, …) neither build
|
||||
# nor run anywhere in CI. --lib scopes it to the unit tests; each
|
||||
# matrix target equals its host triple, so the test binary runs
|
||||
# natively. Codegen is warmed by the rust-cache above.
|
||||
- name: Cargo test (Tauri shell unit tests)
|
||||
working-directory: frontend/src-tauri
|
||||
run: cargo test --lib --target ${{ matrix.rust_target }} --message-format=short
|
||||
|
||||
# Backend-lifecycle fault-injection harness: real child processes die
|
||||
# scripted deaths through the OMNIVOICE_BACKEND_CMD seam, and each
|
||||
# scenario asserts the user-visible diagnosis names the actual cause
|
||||
# (port conflict / traceback root cause / spawn failure / timeout /
|
||||
# crash-loop exhaustion / signal 9 / deliberate replace / deferred-
|
||||
# startup step). Serial: the scenarios share process-global state
|
||||
# (env vars, crash store, kill-intended flag) by design.
|
||||
- name: Cargo test (backend lifecycle harness)
|
||||
working-directory: frontend/src-tauri
|
||||
run: cargo test --test backend_lifecycle --target ${{ matrix.rust_target }} --message-format=short -- --test-threads=1
|
||||
|
||||
# ── Cross-platform Python runtime smoke (Phase 0 GATE-02) ───────────────
|
||||
# Loads the frozen tests/fixtures/omnivoice_data/ fixture and boots the
|
||||
# FastAPI app in-process via TestClient on macOS/Windows/Linux. Catches
|
||||
# platform-specific Python import / path bugs that the Linux-only `test`
|
||||
# job above misses. Narrow scope (tests/smoke/ only) — full pytest stays
|
||||
# on Linux until Phase 1's INST-01 lands setuptools for WhisperX.
|
||||
smoke-matrix:
|
||||
name: Smoke (${{ matrix.label }})
|
||||
needs: test
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-14
|
||||
label: macOS
|
||||
backend_supported: true
|
||||
- os: macos-15-intel
|
||||
label: macOS Intel
|
||||
backend_supported: false
|
||||
- os: windows-2022
|
||||
label: Windows
|
||||
backend_supported: true
|
||||
- os: ubuntu-22.04
|
||||
label: Linux
|
||||
backend_supported: true
|
||||
runs-on: ${{ matrix.os }}
|
||||
# Priced for a COLD `uv sync`, on every platform.
|
||||
#
|
||||
# The previous split (Windows 25, Linux/macOS 10) came from a warm-cache
|
||||
# measurement — Linux and macOS finish in ~65 s when setup-uv restores its
|
||||
# cache, so 10 looked generous. Then run 30439640107 hit
|
||||
# "Failed to restore: Cache service responded with 400", Linux installed
|
||||
# torch from scratch, and the leg was killed at 10m17s. The 65 s was the
|
||||
# cache, not the platform.
|
||||
#
|
||||
# A cache miss is not rare enough to treat as an outage (GitHub's cache
|
||||
# service 400s, a lockfile change invalidates the key, a new runner image
|
||||
# starts empty), and a timeout here is self-perpetuating: the leg dies
|
||||
# before the post-step saves the cache, so the next run is cold too.
|
||||
# 25 everywhere is still bounded — a genuinely wedged job is caught in
|
||||
# minutes, not hours — and warm runs land nowhere near it.
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
# Restricted-network resilience (RESEARCH Pitfall #6) — keeps uv from
|
||||
# giving up on the first slow PyPI / python-build-standalone fetch.
|
||||
UV_HTTP_TIMEOUT: "120"
|
||||
UV_HTTP_RETRIES: "5"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
||||
# ffmpeg + libsndfile are needed by soundfile / audio fixtures even
|
||||
# though the silence WAV doesn't decode anything heavy — keeps test
|
||||
# collection from import-erroring on optional audio modules.
|
||||
- name: System deps (macOS)
|
||||
if: runner.os == 'macOS' && matrix.backend_supported
|
||||
run: brew install ffmpeg libsndfile || true
|
||||
|
||||
- name: System deps (Windows)
|
||||
if: runner.os == 'Windows' && matrix.backend_supported
|
||||
shell: bash
|
||||
run: |
|
||||
# The community chocolatey feed 50x's intermittently (broke PR runs on
|
||||
# 2026-07-20 and 2026-07-28) — retry with backoff before failing.
|
||||
#
|
||||
# Test the OUTCOME, not choco's exit code. On 2026-07-28 the feed
|
||||
# returned 503, choco reported "Unable to find package 'ffmpeg'" and
|
||||
# "installed 0/0 packages" — and still exited 0. The `&& break` that
|
||||
# was supposed to guard this fired on the first attempt, no retry ran,
|
||||
# and the job died one line later on `ffmpeg: command not found`.
|
||||
# A retry that trusts a lying exit code is not a retry.
|
||||
for i in 1 2 3; do
|
||||
choco install ffmpeg -y --no-progress || true
|
||||
hash -r 2>/dev/null || true
|
||||
if command -v ffmpeg >/dev/null 2>&1; then break; fi
|
||||
# No backoff after the last attempt — there is no fourth try to
|
||||
# wait for, and sleeping 90s only delays an already-doomed job.
|
||||
if [ "$i" -eq 3 ]; then
|
||||
echo "choco failed to produce ffmpeg after 3 attempts"
|
||||
break
|
||||
fi
|
||||
echo "choco attempt $i did not produce ffmpeg — retrying in $((i * 30))s"
|
||||
sleep $((i * 30))
|
||||
done
|
||||
# Chocolatey is one distribution channel, not the dependency. When
|
||||
# its feed is down across every retry (2026-08-13: three attempts,
|
||||
# three 'installed 0/1'), fall back to the static gyan.dev release
|
||||
# build GitHub mirror — the same binary, no feed in the path.
|
||||
if ! command -v ffmpeg >/dev/null 2>&1; then
|
||||
echo "::warning::choco feed down — falling back to static ffmpeg build"
|
||||
curl -fsSL --retry 3 -o /tmp/ffmpeg.zip \
|
||||
https://github.com/GyanD/codexffmpeg/releases/download/7.1/ffmpeg-7.1-essentials_build.zip
|
||||
unzip -q /tmp/ffmpeg.zip -d /tmp/ffmpeg
|
||||
bindir=$(dirname "$(find /tmp/ffmpeg -name ffmpeg.exe | head -1)")
|
||||
echo "$bindir" >> "$GITHUB_PATH"
|
||||
export PATH="$bindir:$PATH"
|
||||
fi
|
||||
ffmpeg -version
|
||||
|
||||
- name: System deps (Linux)
|
||||
if: runner.os == 'Linux' && matrix.backend_supported
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
|
||||
with:
|
||||
packages: ffmpeg libsndfile1
|
||||
version: 1.0
|
||||
|
||||
- name: Install Python deps (including PocketTTS)
|
||||
# PocketTTS is an opt-in engine, but installing its pinned extra here
|
||||
# proves that the same dependency set resolves on every supported local
|
||||
# backend host. The Intel-Mac leg separately pins the documented
|
||||
# unsupported contract: its UI is a remote-backend client only (#889).
|
||||
if: matrix.backend_supported
|
||||
run: bash scripts/uv-sync-retry.sh --extra pockettts
|
||||
|
||||
- name: Verify the documented Intel Mac contract
|
||||
if: ${{ !matrix.backend_supported }}
|
||||
shell: bash
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import tomllib
|
||||
|
||||
assert platform.system() == "Darwin"
|
||||
assert platform.machine() == "x86_64"
|
||||
root = Path.cwd()
|
||||
project = tomllib.loads((root / "pyproject.toml").read_text("utf-8"))
|
||||
extra = project["project"]["optional-dependencies"]["pockettts"]
|
||||
assert extra == [
|
||||
"pocket-tts==2.1.0 ; sys_platform != 'darwin' or platform_machine != 'x86_64'"
|
||||
]
|
||||
docs = (root / "docs/install/macos.md").read_text("utf-8")
|
||||
assert "Intel Macs are not supported" in docs
|
||||
PY
|
||||
|
||||
- name: Run smoke tests
|
||||
if: matrix.backend_supported
|
||||
run: uv run --no-sync pytest tests/smoke/ -q --tb=short
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1" # same no-silent-downloads guard as the main pytest job
|
||||
HF_HUB_CACHE: ${{ runner.temp }}/pockettts-empty-hf-cache
|
||||
|
||||
# Artifact commits depend on native Windows rename/replace semantics;
|
||||
# Linux emulation cannot exercise sharing rules or path parsing.
|
||||
- name: Remote-worker artifact paths (Windows)
|
||||
if: runner.os == 'Windows' && matrix.backend_supported
|
||||
run: uv run --no-sync pytest tests/test_worker_upload_server.py tests/test_worker_server_integrity.py -q --tb=short
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1"
|
||||
HF_HUB_CACHE: ${{ runner.temp }}/worker-artifact-empty-hf-cache
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: frontend/src-tauri -> target
|
||||
- run: bun install --frozen-lockfile
|
||||
- name: Bundle uv (${{ matrix.rust_target }})
|
||||
shell: bash
|
||||
env:
|
||||
UV_VERSION: 0.11.7
|
||||
TRIPLE: ${{ matrix.rust_target }}
|
||||
run: "set -euo pipefail\nmkdir -p frontend/src-tauri/binaries\ncase \"$TRIPLE\" in\n aarch64-apple-darwin|x86_64-apple-darwin|x86_64-unknown-linux-gnu)\n\
|
||||
\ ARCHIVE=\"tar.gz\"\n ;;\n x86_64-pc-windows-msvc)\n ARCHIVE=\"zip\"\n ;;\n *)\n echo\
|
||||
\ \"Unsupported target for uv bundling: $TRIPLE\"\n exit 1\n ;;\nesac\nURL=\"https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${TRIPLE}.${ARCHIVE}\"\
|
||||
\necho \"Fetching $URL\"\nWORK=$(mktemp -d)\nif [ \"$ARCHIVE\" = \"zip\" ]; then\n curl -fsSL \"$URL\"\
|
||||
\ -o \"$WORK/uv.zip\"\n unzip -j -o \"$WORK/uv.zip\" -d \"$WORK\"\n mv \"$WORK/uv.exe\" \"frontend/src-tauri/binaries/uv-${TRIPLE}.exe\"\
|
||||
\nelse\n curl -fsSL \"$URL\" | tar -xz -C \"$WORK\"\n mv \"$WORK/uv-${TRIPLE}/uv\" \"frontend/src-tauri/binaries/uv-${TRIPLE}\"\
|
||||
\n chmod +x \"frontend/src-tauri/binaries/uv-${TRIPLE}\"\nfi\nls -la \"frontend/src-tauri/binaries/\"\n"
|
||||
- name: Bundle ffmpeg + ffprobe (${{ matrix.rust_target }})
|
||||
shell: bash
|
||||
env:
|
||||
TRIPLE: ${{ matrix.rust_target }}
|
||||
FFMPEG_BTBN_VERSION: latest
|
||||
run: "set -euo pipefail\nBINDIR=\"frontend/src-tauri/binaries\"\nmkdir -p \"$BINDIR\"\nWORK=$(mktemp -d)\n\
|
||||
\ncase \"$TRIPLE\" in\n aarch64-apple-darwin|x86_64-apple-darwin)\n # evermeet.cx ships each binary\
|
||||
\ as a separate .zip containing\n # a single x86_64 Mach-O executable \u2014 natively correct on the\n\
|
||||
\ # Intel leg, and runs via Rosetta 2 on the arm64 leg. Both\n # darwin TRIPLEs therefore bundle the\
|
||||
\ same payload; only the\n # sidecar filename suffix differs.\n for TOOL in ffmpeg ffprobe; do\n \
|
||||
\ if [ \"$TOOL\" = \"ffmpeg\" ]; then\n URL=\"https://evermeet.cx/ffmpeg/getrelease/zip\"\n \
|
||||
\ else\n URL=\"https://evermeet.cx/ffmpeg/getrelease/${TOOL}/zip\"\n fi\n echo \"Fetching\
|
||||
\ $TOOL from evermeet.cx\"\n curl -fsSL \"$URL\" -o \"$WORK/${TOOL}.zip\"\n unzip -o -j \"$WORK/${TOOL}.zip\"\
|
||||
\ -d \"$WORK\"\n mv \"$WORK/${TOOL}\" \"$BINDIR/${TOOL}-${TRIPLE}\"\n chmod +x \"$BINDIR/${TOOL}-${TRIPLE}\"\
|
||||
\n done\n ;;\n x86_64-unknown-linux-gnu)\n URL=\"https://github.com/BtbN/FFmpeg-Builds/releases/download/${FFMPEG_BTBN_VERSION}/ffmpeg-master-${FFMPEG_BTBN_VERSION}-linux64-gpl.tar.xz\"\
|
||||
\n echo \"Fetching ffmpeg from BtbN (linux64) \u2014 version=${FFMPEG_BTBN_VERSION}\"\n curl -fsSL\
|
||||
\ \"$URL\" -o \"$WORK/ffmpeg.tar.xz\"\n tar -xJf \"$WORK/ffmpeg.tar.xz\" -C \"$WORK\"\n # Archive\
|
||||
\ extracts to ffmpeg-master-latest-linux64-gpl/bin/\n EXTRACTED=$(find \"$WORK\" -type d -name \"bin\"\
|
||||
\ | head -1)\n mv \"$EXTRACTED/ffmpeg\" \"$BINDIR/ffmpeg-${TRIPLE}\"\n mv \"$EXTRACTED/ffprobe\"\
|
||||
\ \"$BINDIR/ffprobe-${TRIPLE}\"\n chmod +x \"$BINDIR/ffmpeg-${TRIPLE}\" \"$BINDIR/ffprobe-${TRIPLE}\"\
|
||||
\n ;;\n x86_64-pc-windows-msvc)\n URL=\"https://github.com/BtbN/FFmpeg-Builds/releases/download/${FFMPEG_BTBN_VERSION}/ffmpeg-master-${FFMPEG_BTBN_VERSION}-win64-gpl.zip\"\
|
||||
\n echo \"Fetching ffmpeg from BtbN (win64) \u2014 version=${FFMPEG_BTBN_VERSION}\"\n curl -fsSL \"\
|
||||
$URL\" -o \"$WORK/ffmpeg.zip\"\n unzip -o \"$WORK/ffmpeg.zip\" -d \"$WORK\"\n EXTRACTED=$(find \"\
|
||||
$WORK\" -type f -name \"ffmpeg.exe\" | head -1)\n EXTRACTED_DIR=$(dirname \"$EXTRACTED\")\n mv \"\
|
||||
$EXTRACTED_DIR/ffmpeg.exe\" \"$BINDIR/ffmpeg-${TRIPLE}.exe\"\n mv \"$EXTRACTED_DIR/ffprobe.exe\" \"\
|
||||
$BINDIR/ffprobe-${TRIPLE}.exe\"\n ;;\n *)\n echo \"\u26A0 No ffmpeg bundling for target: $TRIPLE\
|
||||
\ (will download at first run)\"\n ;;\nesac\nls -la \"$BINDIR/\"\n"
|
||||
- name: Reproduce full-resource MSI build twice
|
||||
shell: pwsh
|
||||
run: ./scripts/diagnose-full-windows-wix.ps1
|
||||
- name: Preserve authoring diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: full-windows-wix-diagnostics
|
||||
path: full-wix-diagnostic-artifacts/
|
||||
retention-days: 3
|
||||
|
||||
@@ -604,6 +604,21 @@ jobs:
|
||||
fi
|
||||
done < /tmp/stale.txt
|
||||
|
||||
# A retried job reuses its version and can collide with installers it
|
||||
# uploaded before a later step failed. Keep other versions/arches intact;
|
||||
# macOS versionless updater archives are scoped by release tag and arch.
|
||||
- name: Clear this target's installer assets on retry
|
||||
if: github.run_attempt > 1
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
|
||||
RELEASE_TARGET: ${{ matrix.rust_target }}
|
||||
run: |
|
||||
VERSION=$(python -c 'import json; print(json.load(open("frontend/package.json"))["version"])')
|
||||
python scripts/clear-release-rerun-assets.py \
|
||||
--tag "$RELEASE_TAG" --version "$VERSION" --target "$RELEASE_TARGET"
|
||||
|
||||
- name: Build + release (Tauri)
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
@@ -659,6 +674,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
python ../scripts/render-per-user-wix.py \
|
||||
--source src-tauri/wix/main.wxs \
|
||||
--system-wxs src-tauri/target/${{ matrix.rust_target }}/release/wix/x64/main.wxs \
|
||||
--output src-tauri/target/wix-per-user/main.wxs
|
||||
bunx tauri build --target ${{ matrix.rust_target }} --bundles msi \
|
||||
--config src-tauri/tauri.per-user.conf.json
|
||||
|
||||
+10
@@ -170,3 +170,13 @@ bin/omnivoice-tts-linux-aarch64
|
||||
# committed (they ship with the app); the per-language source WAVs are just the
|
||||
# inputs scripts/render_dub_demo_audio.py hands to scripts/build_dub_demo.sh.
|
||||
backend/assets/samples/demo/dubbing/*.src.wav
|
||||
|
||||
# Stray sqlite session artifacts (`<db-path>.ses`). An in-memory DB yields the
|
||||
# literal name `:memory:.ses`, and a path containing `:` cannot be checked out
|
||||
# on Windows at all — committing one fails every Windows CI job at the git
|
||||
# checkout step, before a single test runs. Guarded by
|
||||
# tests/test_no_windows_hostile_paths.py.
|
||||
*.ses
|
||||
|
||||
# Generated Windows MSI diagnostic logs and installer payloads
|
||||
/wix-diagnostic-artifacts/
|
||||
|
||||
@@ -25,6 +25,8 @@ regexes = [
|
||||
'''^hf_QWERTYUIOPasdfghjklZXCVBNM0123456789xyzAB$''',
|
||||
# NLLB generation length argument, not the value of a credential.
|
||||
'''^max_length=400$''',
|
||||
# Dubbing pane split-position localStorage key, not a credential.
|
||||
'''^omnivoice\.dubSplit\.v1$''',
|
||||
# cryptography's Ed25519 private-key type name, not key material.
|
||||
'''^Ed25519PrivateKey$''',
|
||||
]
|
||||
|
||||
+69
-1
@@ -10,17 +10,84 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
|
||||
**Highlights**
|
||||
|
||||
- Voice modes now lead the workspace as full-width tabs, with the active engine above them and Synthesize pinned below the scrolling form (#1823)
|
||||
- The desktop app builds and opens from a fresh clone again (#1818) — thanks @flutterkage2k!
|
||||
- GPUs with less VRAM than the engine needs no longer get half the compute-time budget a CPU gets (#1806) — thanks @VishvakR!
|
||||
- Gallery voice previews play again — the quality guard was rejecting good renders as silent (#1819) — thanks @flutterkage2k!
|
||||
- Tilde-separated number ranges are spoken clearly without running their endpoints together (#1821) — thanks @flutterkage2k!
|
||||
- Voice modes use themed tabs, with Synthesize and Convert pinned below their scrolling forms (#1823)
|
||||
- Fix current-user Windows installer validation and nested resource cleanup (#1873)
|
||||
|
||||
- Voice cloning now starts with a clear upload-or-record choice, reveals recording and reference details only when needed, and keeps sampling controls under Production Overrides (#1817)
|
||||
- The first-run welcome line uses an instruction accepted by OmniVoice and VoiceDesign engines (#1861) — thanks @psiberfunk!
|
||||
|
||||
### Changed
|
||||
|
||||
- Casting uses responsive SVG voice cards and searchable speaker menus that stay above surrounding panels (#1823)
|
||||
- Dubbing aligns output settings, brings review status forward, and simplifies transcript and glossary editing; Launchpad files and voices reflow into responsive grids (#1823)
|
||||
- Transcript segments use three readable rows for text, timing/status and voice controls, with heights that adapt to wrapping (#1823)
|
||||
- Dragging the waveform pans horizontally while a click still seeks, keeping the timed transcript aligned (#1823)
|
||||
- Bulk segment editing uses searchable voice and language menus, readable language names and a responsive selection toolbar (#1823)
|
||||
- Dubbing overlays playback controls on video, combines waveform and transcript in a compact timeline, and removes header/action background fills (#1823)
|
||||
- Dubbing uses compact casting, translation and output controls with responsive rows to leave more room for editing (#1823)
|
||||
- Export uses grouped format settings, themed track menus and switches, with a pinned filename summary and download action (#1823)
|
||||
- Dubbing output settings use icon-labelled switches, themed track and speaker menus, and clearer timing/transcript controls (#1823)
|
||||
- Casting voice menus use searchable themed options with SVG preset icons instead of native dropdowns (#1823)
|
||||
- Dubbing groups casting and translation controls with readable labels, SVG icons, searchable menus, and compact timeline spacing (#1823)
|
||||
- Production Overrides use readable icon-labelled controls and accessible Denoise/Postprocess switches (#1823)
|
||||
- Expanded navigation uses a theme-accent tint with subtle static wave gradients (#1823)
|
||||
- Convert groups source audio, target voice, and timing options into clearer controls; design choices include theme-matched SVG icons (#1823)
|
||||
- The expandable sidebar reveals workspace labels with restrained active states; language menus adapt to multiple columns on wider screens (#1823)
|
||||
- Voice design and recording use themed, keyboard-accessible selectors with clearer spacing and labels (#1823)
|
||||
- Voice tabs and upload/record controls have subtle SVG motion; Text adds clipboard paste and the upload area fills available height (#1823)
|
||||
- The title-bar label cycles through active speech, transcription, and LLM engines; bundled model labels correctly say OmniVoice (#1823)
|
||||
- The top-bar Engines panel groups Speech, Transcription, and LLM choices into tabs, with compact memory controls and no duplicate pickers (#1823)
|
||||
### Added
|
||||
|
||||
### Docs
|
||||
|
||||
### Fixed
|
||||
|
||||
- Install documentation help now prints correctly on Windows consoles using legacy encodings (#1815) — thanks @dajiaohuang!
|
||||
- Saved transcriptions with missing or invalid timestamps now remain readable (#1799) — thanks @yunaremaia and @tvbht!
|
||||
- Copying a saved transcription now uses the shared clipboard helper and reports failed copies accurately (#1803) — thanks @tvbht!
|
||||
|
||||
- Voice reference preparation reclaims allocator memory before one bounded retry, then reports persistent GPU out-of-memory failures (#1811)
|
||||
- `bun run desktop` now opens on a fresh clone: the Vite alias for `@tauri-apps/plugin-dialog` no longer assumes a nested `frontend/node_modules`, which bun's workspace hoisting leaves empty (#1818) — thanks @flutterkage2k!
|
||||
|
||||
- Slow backend startups remain running with progress updates, and Retry interrupts startup without stale timeout failures (#1809)
|
||||
- Backend connection errors report crashes only when recorded evidence exists, and diagnostic waits honor cancellation (#1810)
|
||||
- A CUDA or ROCm GPU with less VRAM than the engine needs now gets the CPU compute-time budget instead of the shorter accelerated one, since it pages to system RAM and renders slower than the CPU would — applied to local generation, voice conversion, and remote worker deadlines alike (#1806) — thanks @VishvakR!
|
||||
- Gallery previews no longer fail with "the voice engine returned no audible audio" on perfectly good renders: the degenerate-buzz guard measured spectral flatness over the whole clip (so the value tracked clip length) against a threshold calibrated on a synthetic signal, and rejected real speech in every language tested (#1819) — thanks @flutterkage2k!
|
||||
- Speak tilde separators in integer, signed, and decimal ranges in English, Korean, Japanese, and Chinese (#1821) — thanks @flutterkage2k!
|
||||
|
||||
- Keep recording and conversion work safe while switching methods, synchronize dubbing language controls, and localize timeline controls and timing warnings (#1841)
|
||||
|
||||
- Dubbing playback starts before waveform decoding, automatic cast names are readable, and transcript timestamps have more room (#1823)
|
||||
- The title-bar engine button stays compact and stable while cycling labels, with engine names aligned right (#1823)
|
||||
- Long dubbing segment errors wrap in a bounded scrollable notice instead of widening the editor (#1823)
|
||||
- Voice dropdowns match their field width, use theme accents, and show recent voices only once (#1823)
|
||||
- Language menus no longer show a pale frame around their search header (#1823)
|
||||
- The notification count stays inside the title bar instead of clipping above the bell (#1823)
|
||||
- The workspace engine menu opens beside its button instead of at the opposite edge of the page (#1823)
|
||||
- Cloning reuses the dubbing language picker with flags, search, and single selection, opening above the pinned synthesis controls (#1823)
|
||||
- The first-run welcome line uses an instruction accepted by OmniVoice and VoiceDesign engines (#1861) — thanks @psiberfunk!
|
||||
- The header status dot now honors OS Reduce Motion instead of pulsing regardless (#1862) — thanks @psiberfunk!
|
||||
- Onboarding reads Hugging Face tokens locally, preserves Windows CLI logins, and requires successful discovery before replacing saved credentials (#1852) — thanks @psiberfunk!
|
||||
- The logs panel no longer reports “All clear” before log retrieval succeeds or while logs contain warnings or errors (#1870) — thanks @motodriver!
|
||||
- MOSS accelerator routing and status match runtime selection, with CPU fallback when device probing fails (#1830) — thanks @li-lizhe!
|
||||
- Confucius accelerator routing tolerates failed device probes, and dots.tts keeps safe default precision on non-CUDA hosts (#1831) — thanks @li-lizhe!
|
||||
- On macOS, the header status dot and kicker no longer render underneath the overlaid traffic lights (#1863) — thanks @psiberfunk!
|
||||
- The capture widget can hide after recording and recover from being left visible while idle (#1865) — thanks @psiberfunk!
|
||||
|
||||
- macOS retains the shared desktop window sizing, resize limits, and file-drop behavior when native chrome is applied (#1865) — thanks @psiberfunk!
|
||||
|
||||
- On macOS, the header no longer shows Windows-style minimize/maximize/close buttons alongside the native traffic lights (#1865) — thanks @psiberfunk!
|
||||
|
||||
- Release retries replace their own partially uploaded installers without colliding with existing assets (#1871)
|
||||
- Timed-out voice engines finish process cleanup before retrying, and old timeout callbacks cannot kill replacement engines (#1872)
|
||||
|
||||
|
||||
- Fast macOS process exits no longer turn a completed shutdown into a permission error (#1809)
|
||||
|
||||
## [0.5.2] — 2026-09-02
|
||||
|
||||
@@ -61,6 +128,7 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
- The generation compute-time budget is now a Settings control (Performance & Device) instead of an env-var-only setting the timeout error recommended with no UI path — the error copy points there too, and long CPU/MPS renders get an upfront heads-up before they start (#1787)
|
||||
- Windows: the backend can now start when the install path contains non-English characters (e.g. a CJK username) on a non-UTF-8 system code page — a new or broken Python environment now builds at an ASCII-safe path automatically (a healthy existing one is never relocated), and a specific error message names the cause and a working fix if the interpreter still crashes in `site` (#1783)
|
||||
- Exports and other native-picker actions no longer 403 with "Invalid or expired desktop authorization" when the desktop app and backend resolve different data directories, e.g. dev mode or a custom data folder (#1781)
|
||||
|
||||
@@ -57,7 +57,13 @@
|
||||
| **Storage** | Voices, projects, settings, and outputs stay on the machine by default |
|
||||
| **License** | AGPL-3.0 application; downloaded models keep their upstream terms |
|
||||
|
||||
The Voice workspace starts with the active engine selector and three tabs: **From audio** for cloning, **By design** for creating a voice, and **Convert** for speech-to-speech conversion. Each tab displays its own workflow below. In From audio and By design, Synthesize Audio stays at the bottom while the form scrolls above it.
|
||||
The Voice workspace starts with three tabs: **From audio** for cloning, **By design** for creating a voice, and **Convert** for speech-to-speech conversion. Each tab displays its own workflow, with Synthesize Audio or Convert pinned below the scrolling form. The top-bar **Engines** panel combines engine selection, loaded models, and unload/flush controls; <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd> opens it. The searchable language picker shares Dubbing’s flags and language list layout, selects one output language, and retains Auto and the full cloning catalogue. Language options flow into multiple columns when space allows. Expand **Workspaces** in the sidebar to reveal navigation labels; Escape collapses it.
|
||||
|
||||
Dubbing places playback controls over the video with background blur and combines the waveform and timed transcript in one compact editing surface. Drag the zoomed waveform left or right to pan; click to seek. Translation language and ISO-code controls stay synchronized; Auto clears any previous language code and dialect. Transcript items group editable text, timing and status, and voice controls into three readable rows that wrap with the panel width. Output Options stays compact with the active settings shown in its summary; expand it to change output, timing, or voice matching. Transcript, glossary, and paste controls share a toolbar above the segment editor. Project details, workflow steps, and Generate/Verify/Export actions use an unfilled header.
|
||||
|
||||
Output settings use aligned rows; review status appears before the collapsible transcript and glossary. Glossary terms have labelled entry fields and an explicit edit action. Launchpad arranges recent files and saved voices side by side when space allows, with responsive card grids and visible Open actions.
|
||||
|
||||
The casting board shows icon-based voice cards and searchable selectors for each speaker. Drag a card onto a speaker or choose a voice from that speaker’s menu.
|
||||
|
||||
<a id="install"></a>
|
||||
|
||||
@@ -131,7 +137,7 @@ The desktop launcher configures Python dependencies on first run via `uv` automa
|
||||
|---|---|
|
||||
| **Voice Cloning** | Zero-shot synthesis from a short reference clip ([guide](docs/engines/README.md)) |
|
||||
| **Voice Design** | Create a voice from age, accent, pitch, style, and delivery instructions ([expressive speech](docs/expressive-speech.md)) |
|
||||
| **Video Dubbing** | Transcribe, translate, preserve speakers, synthesize, and export video ([export guide](docs/dubbing/export.md)) |
|
||||
| **Video Dubbing** | Transcribe, translate, preserve speakers, synthesize, and export video; compact translation settings include track selection, and completed dubs flag timing issues for review ([export guide](docs/dubbing/export.md)) |
|
||||
| **Stories and audiobooks** | Multi-voice scripts · EPUB/PDF import · chapter rendering · `.m4b` export |
|
||||
| **[Dictation Widget](docs/features/dictation.md)** | System-wide shortcut, live transcription, optional local-LLM cleanup |
|
||||
| **Vocal Isolation** | Demucs speech/background separation |
|
||||
|
||||
@@ -57,11 +57,12 @@ _PREVIEW_SEED = 42
|
||||
# 32 reliably converges to speech across the gallery's instruct/script space
|
||||
# at a one-time (cached) render cost.
|
||||
_PREVIEW_NUM_STEP = 32
|
||||
# Spectral-flatness floor below which a render is a degenerate tonal artifact
|
||||
# rather than speech. Real, mastered speech sits ~0.04–0.07; a tonal buzz
|
||||
# collapses to <0.005. 0.015 separates the two with wide margin and sits well
|
||||
# below even breathy/whisper voices (which are broadband → high flatness).
|
||||
_DEGENERATE_FLATNESS = 0.015
|
||||
# Reject near-pure tonal artifacts using mean framed spectral flatness.
|
||||
# Calibrated against the tracked speech demos exercised by
|
||||
# test_archetype_preview_quality.py: the quietest (Mandarin dubbing, 44.1 kHz)
|
||||
# measures ~7.7e-6, while the worst tested tonal buzz measures ~3.3e-9.
|
||||
# 1e-7 leaves >10x margin on both sides without rejecting low-flatness speech.
|
||||
_DEGENERATE_FLATNESS = 1e-7
|
||||
|
||||
|
||||
def _preview_key(a: dict) -> str:
|
||||
@@ -248,24 +249,46 @@ def _is_blank_audio(audio_tensor) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
_FLATNESS_FRAME = 1024
|
||||
_FLATNESS_HOP = 512
|
||||
#: Frames quieter than this fraction of the loudest frame's energy are the gaps
|
||||
#: between words, not speech; their spectrum is the noise floor and averaging it
|
||||
#: in drags the measurement toward the value of whatever silence sounds like.
|
||||
_FLATNESS_FRAME_FLOOR = 1e-4
|
||||
|
||||
|
||||
def _spectral_flatness(audio_tensor) -> Optional[float]:
|
||||
"""Geometric-mean / arithmetic-mean of the power spectrum.
|
||||
"""Mean per-frame geometric-mean / arithmetic-mean of the power spectrum.
|
||||
|
||||
~1.0 for broadband noise, →0 for a pure tone. The degenerate diffusion
|
||||
renders this guards against are near-pure tonal buzzes (flatness <0.005),
|
||||
distinct from both silence (caught by ``_is_blank_audio``) and real speech
|
||||
(~0.04+). Returns ``None`` if it can't be computed so callers don't act on
|
||||
a bad measurement.
|
||||
renders this guards against are near-pure tonal buzzes, distinct from both
|
||||
silence (caught by ``_is_blank_audio``) and real speech. Returns ``None``
|
||||
if it can't be computed so callers don't act on a bad measurement.
|
||||
|
||||
Measured over short frames and averaged — the standard definition. A single
|
||||
FFT of the whole clip (what this used to do) is not the same quantity: its
|
||||
frequency resolution grows with clip length, so speech harmonics carve
|
||||
ever-deeper nulls into the spectrum and the geometric mean collapses. That
|
||||
made the result depend on how long the clip was rather than on what it
|
||||
sounded like, and put real speech below the rejection threshold.
|
||||
"""
|
||||
try:
|
||||
import torch
|
||||
|
||||
t = audio_tensor if isinstance(audio_tensor, torch.Tensor) else torch.as_tensor(audio_tensor)
|
||||
t = t.detach().to("cpu", dtype=torch.float32).flatten()
|
||||
if t.numel() < 1024 or not torch.isfinite(t).all():
|
||||
t = t.detach().to("cpu", dtype=torch.float32)
|
||||
if t.ndim > 1:
|
||||
t = t.mean(dim=0)
|
||||
t = t.flatten()
|
||||
if t.numel() < _FLATNESS_FRAME or not torch.isfinite(t).all():
|
||||
return None
|
||||
spec = torch.fft.rfft(t * torch.hann_window(t.numel())).abs().pow(2) + 1e-12
|
||||
return float(torch.exp(torch.mean(torch.log(spec))) / torch.mean(spec))
|
||||
frames = t.unfold(0, _FLATNESS_FRAME, _FLATNESS_HOP)
|
||||
spec = torch.fft.rfft(frames * torch.hann_window(_FLATNESS_FRAME)).abs().pow(2) + 1e-12
|
||||
energy = spec.sum(dim=1)
|
||||
spec = spec[energy > energy.max() * _FLATNESS_FRAME_FLOOR]
|
||||
if spec.shape[0] == 0:
|
||||
return None
|
||||
return float((torch.exp(spec.log().mean(dim=1)) / spec.mean(dim=1)).mean())
|
||||
except Exception: # never let the checker itself block a render
|
||||
return None
|
||||
|
||||
|
||||
@@ -804,16 +804,23 @@ def _oom_friendly_reraise(e):
|
||||
) from e
|
||||
|
||||
|
||||
def _generate_timeout_s(text: str, *, execution_device=None) -> float:
|
||||
def _generate_timeout_s(text: str, *, execution_device=None, min_vram_gb=0.0) -> float:
|
||||
"""Wall-clock budget for one generate, scaled to the request.
|
||||
|
||||
Thin alias for the canonical helper, which moved to
|
||||
``services.model_manager.generate_timeout_s`` (#1190) so /v1/audio/speech,
|
||||
batch, dub and archetype previews share it instead of each re-deriving (or,
|
||||
as they did, silently keeping the flat 300s).
|
||||
|
||||
``min_vram_gb`` is the engine's declared VRAM floor. A GPU below it pages to
|
||||
system RAM and renders slower than this machine's CPU, so it must not be
|
||||
budgeted as fast hardware (#1804) — the same figure the dispatch already
|
||||
hands the guard so a timeout message can name the card (#1226/#1222).
|
||||
"""
|
||||
from services.model_manager import generate_timeout_s
|
||||
return generate_timeout_s(text, execution_device=execution_device)
|
||||
return generate_timeout_s(
|
||||
text, execution_device=execution_device, min_vram_gb=min_vram_gb,
|
||||
)
|
||||
|
||||
|
||||
def _run_inference(
|
||||
@@ -1718,7 +1725,8 @@ async def generate_speech(
|
||||
local=gpu_gateway.LocalCall(
|
||||
_remote_only_local_call(_target_label),
|
||||
what="TTS generate",
|
||||
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"]),
|
||||
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"],
|
||||
min_vram_gb=_engine_min_vram_gb),
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
),
|
||||
remote=_remote_call,
|
||||
@@ -2012,7 +2020,8 @@ async def generate_speech(
|
||||
),
|
||||
what="TTS generate",
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"]),
|
||||
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"],
|
||||
min_vram_gb=_engine_min_vram_gb),
|
||||
on_abandon=release,
|
||||
)
|
||||
)
|
||||
@@ -2032,7 +2041,8 @@ async def generate_speech(
|
||||
),
|
||||
what="TTS generate",
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"]),
|
||||
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"],
|
||||
min_vram_gb=_engine_min_vram_gb),
|
||||
on_abandon=release,
|
||||
)
|
||||
)
|
||||
@@ -2072,7 +2082,8 @@ async def generate_speech(
|
||||
# Budget scaled to THIS chunk (#1190) — the flat
|
||||
# 300s here is what made long streamed renders fail
|
||||
# even after the v0.3.22 scaled budget shipped.
|
||||
timeout=_generate_timeout_s(chunk_text, execution_device=_routing["effective_device"]),
|
||||
timeout=_generate_timeout_s(chunk_text, execution_device=_routing["effective_device"],
|
||||
min_vram_gb=_engine_min_vram_gb),
|
||||
on_abandon=release,
|
||||
)
|
||||
)
|
||||
@@ -2232,7 +2243,8 @@ async def generate_speech(
|
||||
_REMOTE_OP,
|
||||
local=gpu_gateway.LocalCall(
|
||||
_local_render, what="TTS generate",
|
||||
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"]),
|
||||
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"],
|
||||
min_vram_gb=_engine_min_vram_gb),
|
||||
min_vram_gb=_engine_min_vram_gb,
|
||||
on_abandon=release,
|
||||
),
|
||||
|
||||
@@ -35,11 +35,11 @@ class _HFTokenBody(BaseModel):
|
||||
token: str = Field(..., min_length=1, description="HuggingFace access token")
|
||||
|
||||
|
||||
def _state_response() -> dict:
|
||||
def _state_response(*, validate: bool = False) -> dict:
|
||||
"""Return the same shape the React panel renders. Never includes raw token."""
|
||||
from services import token_resolver
|
||||
|
||||
s = token_resolver.state()
|
||||
s = token_resolver.state(validate=validate)
|
||||
return {
|
||||
"active": s["active"],
|
||||
"sources": [asdict(row) for row in s["sources"]],
|
||||
@@ -65,8 +65,7 @@ def save_hf_token(body: _HFTokenBody):
|
||||
|
||||
@router.delete("/hf-token")
|
||||
def clear_hf_token(also_clear_hf_cli: bool = Query(False)):
|
||||
"""Clear the App-source token. Optionally also call huggingface_hub.logout
|
||||
to clear the canonical HF file. Returns the updated cascade state."""
|
||||
"""Clear the App token and optionally recognized local Hub token files."""
|
||||
from services import token_resolver
|
||||
try:
|
||||
token_resolver.clear_app_token(also_clear_hf_cli=also_clear_hf_cli)
|
||||
@@ -82,13 +81,12 @@ def get_hf_token_state(fresh: bool = Query(False)):
|
||||
|
||||
``fresh=1`` drops the resolver's whoami validation cache first so the
|
||||
response re-runs whoami for every source — this is what the panel's
|
||||
"Test now" button sends. Plain GETs (panel mounts) keep the 300s cache
|
||||
so repeat Settings visits don't hammer the HF API.
|
||||
"Test now" button sends. Plain GETs only inspect local token presence.
|
||||
"""
|
||||
from services import token_resolver
|
||||
if fresh:
|
||||
token_resolver.invalidate_cache()
|
||||
return _state_response()
|
||||
return _state_response(validate=fresh)
|
||||
|
||||
|
||||
# ── Performance settings (INST-12) ────────────────────────────────────────
|
||||
@@ -150,7 +148,7 @@ def _compute_device_state() -> dict:
|
||||
caps = device_caps.detect_host_caps()
|
||||
env_pin = (os.environ.get("OMNIVOICE_DEVICE") or "").strip().lower()
|
||||
auto_family = next(
|
||||
(f for f in ("cuda", "rocm", "xpu", "mps") if f in caps.available_families),
|
||||
(f for f in device_caps.ACCELERATOR_PRIORITY if f in caps.available_families),
|
||||
"cpu",
|
||||
)
|
||||
value = device_caps.requested_device_override()
|
||||
|
||||
@@ -910,7 +910,7 @@ async def set_env_var(body: dict):
|
||||
Persistent keys (proxy, FFMPEG_PATH, translation provider keys, …) are
|
||||
saved to ``prefs.json`` so they survive backend restarts (restored at
|
||||
startup in ``main.py``). HF_TOKEN is persisted via
|
||||
``huggingface_hub.login()`` (and cleared via ``logout()``). Other keys
|
||||
``huggingface_hub.login()`` (and cleared with the shared token-file helper). Other keys
|
||||
are set on ``os.environ`` for the running process.
|
||||
|
||||
The loopback-origin gate that previously lived inline here is now applied
|
||||
@@ -985,14 +985,14 @@ async def set_env_var(body: dict):
|
||||
# Mirror the persistence on clear — wipe the saved token file too.
|
||||
if key == "HF_TOKEN":
|
||||
try:
|
||||
from huggingface_hub import logout as _hf_logout
|
||||
_hf_logout()
|
||||
logger.info("HF token cleared from $HF_HOME/token via logout()")
|
||||
except Exception as e:
|
||||
logger.warning("Could not clear HF token file: %s", e)
|
||||
from services.token_resolver import clear_hf_cli_tokens
|
||||
clear_hf_cli_tokens()
|
||||
logger.info("Local Hugging Face token files cleared")
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Could not clear local Hugging Face token files") from None
|
||||
|
||||
# HF_TOKEN persistence is handled above via huggingface_hub.login()/
|
||||
# logout() — it never touches prefs.json. Everything else in
|
||||
# clear_hf_cli_tokens() — it never touches prefs.json. Everything else in
|
||||
# PERSISTENT_KEYS (proxy, FFMPEG_PATH, translation provider keys, …) is
|
||||
# saved to prefs.json so it survives backend restarts (restored at
|
||||
# startup in main.py). Non-persistent keys stay process-local.
|
||||
@@ -1100,7 +1100,7 @@ def asr_backends():
|
||||
def hf_token_state():
|
||||
"""Return the 3-source HF token cascade state for the Settings UI
|
||||
(Wave 2 React panel consumes this). Never returns the raw token —
|
||||
only a masked preview, whoami username, and per-source validity.
|
||||
only a masked preview and local presence; no outbound validation.
|
||||
"""
|
||||
from dataclasses import asdict
|
||||
from services import token_resolver
|
||||
|
||||
@@ -323,7 +323,9 @@ async def convert_speech(
|
||||
audio_tensor = await run_on_gpu_pool_guarded(
|
||||
_render,
|
||||
what="Voice convert",
|
||||
timeout=_generate_timeout_s(text),
|
||||
timeout=_generate_timeout_s(
|
||||
text, min_vram_gb=getattr(type(backend), "min_vram_gb", 0.0),
|
||||
),
|
||||
min_vram_gb=getattr(type(backend), "min_vram_gb", 0.0),
|
||||
)
|
||||
except GpuPoolBusyError as e:
|
||||
|
||||
@@ -15,6 +15,18 @@ def get_app_data_dir():
|
||||
return os.path.expanduser("~/.omnivoice")
|
||||
|
||||
|
||||
def _configured_hf_token_path():
|
||||
"""Match Hub's token location without importing or refreshing credentials."""
|
||||
default_cache = os.path.join(os.path.expanduser("~"), ".cache")
|
||||
hf_home = os.environ.get("HF_HOME", os.path.join(os.environ.get("XDG_CACHE_HOME", default_cache), "huggingface"))
|
||||
return os.path.expandvars(os.path.expanduser(os.environ.get("HF_TOKEN_PATH", os.path.join(hf_home, "token"))))
|
||||
|
||||
|
||||
# Snapshot recognized locations before automatic model-cache redirection.
|
||||
# Explicit cache/token overrides restrict clearing to their selected location.
|
||||
HF_CLI_TOKEN_PATHS = (_configured_hf_token_path(),)
|
||||
|
||||
|
||||
def _ensure_short_hf_cache_on_windows():
|
||||
"""Redirect HuggingFace cache to a short path on Windows.
|
||||
|
||||
@@ -38,6 +50,14 @@ def _ensure_short_hf_cache_on_windows():
|
||||
return
|
||||
short_cache = os.path.join(local_app, "OmniVoice", "hf_cache")
|
||||
os.makedirs(short_cache, exist_ok=True)
|
||||
if "HF_TOKEN_PATH" not in os.environ:
|
||||
global HF_CLI_TOKEN_PATHS
|
||||
canonical = HF_CLI_TOKEN_PATHS[0]
|
||||
legacy = os.path.join(short_cache, "token")
|
||||
HF_CLI_TOKEN_PATHS = tuple(dict.fromkeys((canonical, legacy)))
|
||||
# Keep existing app-written logins usable without copying credentials.
|
||||
selected = canonical if os.path.exists(canonical) or not os.path.exists(legacy) else legacy
|
||||
os.environ.setdefault("HF_TOKEN_PATH", selected)
|
||||
os.environ["HF_HOME"] = short_cache
|
||||
os.environ["HF_HUB_CACHE"] = short_cache
|
||||
|
||||
|
||||
+2
-1
@@ -274,7 +274,8 @@ _BASE_SCHEMA = """
|
||||
started_at REAL,
|
||||
finished_at REAL,
|
||||
lease_expires_at REAL,
|
||||
grace_expires_at REAL
|
||||
grace_expires_at REAL,
|
||||
deadlines_json TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_attempts_task ON remote_task_attempts(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_attempts_worker ON remote_task_attempts(worker_id, state);
|
||||
|
||||
@@ -35,7 +35,8 @@ import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
DeviceFamily = Literal["cuda", "rocm", "mps", "xpu", "cpu"]
|
||||
DeviceFamily = Literal["cuda", "rocm", "mps", "xpu", "npu", "cpu"]
|
||||
ACCELERATOR_PRIORITY = ("cuda", "rocm", "xpu", "npu", "mps")
|
||||
|
||||
# Stable substring stamped onto notes that represent a real kernel-launch risk
|
||||
# (arch/driver mismatch) — as opposed to advisory notes (multi-GPU, VRAM query
|
||||
@@ -533,9 +534,13 @@ def _probe() -> HostCaps:
|
||||
# is the whole truth in that case (CodeRabbit, #1425).
|
||||
notes.extend(why_no_gpu(torch))
|
||||
|
||||
# ── Intel XPU via IPEX ───────────────────────────────────────────────
|
||||
# Older builds register XPU through IPEX; modern torch exposes it directly.
|
||||
try:
|
||||
import intel_extension_for_pytorch # noqa: F401
|
||||
except Exception:
|
||||
# Optional IPEX may be absent or incompatible; still probe native torch XPU.
|
||||
pass
|
||||
try:
|
||||
if hasattr(torch, "xpu") and torch.xpu.is_available():
|
||||
detected.append("xpu")
|
||||
if not device_name:
|
||||
@@ -546,7 +551,23 @@ def _probe() -> HostCaps:
|
||||
pass
|
||||
notes.append("XPU VRAM not queried (unreliable across IPEX versions)")
|
||||
except Exception:
|
||||
# IPEX absent or XPU probe failed — no XPU on this host.
|
||||
# XPU probe failed — no usable XPU on this host.
|
||||
pass
|
||||
|
||||
# Vendor extensions may register an NPU with torch. Probe only an already
|
||||
# registered backend; never install or import an optional vendor package.
|
||||
try:
|
||||
if hasattr(torch, "npu") and torch.npu.is_available():
|
||||
detected.append("npu")
|
||||
if not device_name:
|
||||
try:
|
||||
device_name = torch.npu.get_device_name(0)
|
||||
except Exception:
|
||||
# An unavailable display name does not invalidate a usable NPU.
|
||||
pass
|
||||
notes.append("NPU VRAM not queried")
|
||||
except Exception:
|
||||
# Missing or broken vendor backends mean no usable NPU; continue probing.
|
||||
pass
|
||||
|
||||
# ── Apple Silicon MPS ────────────────────────────────────────────────
|
||||
@@ -579,7 +600,7 @@ def _probe() -> HostCaps:
|
||||
|
||||
# Preferred family by priority; cpu when nothing accelerated was detected.
|
||||
family: DeviceFamily = "cpu"
|
||||
for pref in ("cuda", "rocm", "xpu", "mps"):
|
||||
for pref in ACCELERATOR_PRIORITY:
|
||||
if pref in detected:
|
||||
family = pref # type: ignore[assignment]
|
||||
break
|
||||
|
||||
@@ -56,15 +56,15 @@ class Confucius4Backend(SubprocessBackend):
|
||||
|
||||
id = "confucius4-tts"
|
||||
display_name = (
|
||||
"Confucius4-TTS (LLM, 14 langs, cross-lingual zero-shot clone, CUDA/CPU, Apache-2.0)"
|
||||
"Confucius4-TTS (LLM, 14 langs, cross-lingual zero-shot clone, Apache-2.0)"
|
||||
)
|
||||
supports_voice_design = False # timbre comes from a reference clip
|
||||
# Upstream vocoder rate (config target_sample_rate) — confirmed 22 050 Hz by
|
||||
# a live run (2026-07-02); still re-read from the sidecar's ready/audio frames.
|
||||
_DEFAULT_SAMPLE_RATE = 22050
|
||||
# CUDA fast path + CPU fallback, both exercised (CPU end-to-end validated).
|
||||
# No MPS claim — upstream has no Metal path.
|
||||
gpu_compat = ("cuda", "cpu")
|
||||
# Match device propagation into upstream .to(device). XPU/NPU routing is
|
||||
# contract-tested, not a claim of physical-hardware synthesis validation.
|
||||
gpu_compat = ("cuda", "rocm", "xpu", "npu", "cpu")
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> tuple[bool, str]:
|
||||
|
||||
@@ -104,7 +104,7 @@ def _ensure_clone_on_sys_path() -> None:
|
||||
|
||||
|
||||
def _load_model(stdout):
|
||||
"""Cold-construct the Confucius4 model (CUDA, else CPU — both validated)."""
|
||||
"""Cold-construct using an available torch accelerator, with CPU fallback."""
|
||||
global _model
|
||||
if _model is not None:
|
||||
return _model
|
||||
@@ -115,7 +115,18 @@ def _load_model(stdout):
|
||||
import torch
|
||||
from confuciustts.cli.inference import ConfuciusTTS # type: ignore[import-not-found]
|
||||
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
try:
|
||||
# Existing manually provisioned venvs may predate torch.accelerator.
|
||||
current_accelerator = getattr(getattr(torch, "accelerator", None), "current_accelerator", None)
|
||||
if current_accelerator is None:
|
||||
device = torch.device("cuda") if torch.cuda.is_available() else None
|
||||
else:
|
||||
device = current_accelerator(check_available=True)
|
||||
device = device.type if device is not None else "cpu" # 'cuda', 'npu', 'mps', 'xpu', 'cpu'
|
||||
except Exception:
|
||||
device = "cpu" # Broken accelerator drivers must not block CPU loading.
|
||||
if device == "mps":
|
||||
device = "cpu" # MPS was slower than CPU in the existing validation run
|
||||
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": 50})
|
||||
|
||||
_model = ConfuciusTTS(config_path=_config_path(), device=device)
|
||||
|
||||
@@ -115,7 +115,12 @@ def _load_runtime(stdout):
|
||||
from dots_tts.runtime import DotsTtsRuntime # type: ignore[import-not-found]
|
||||
|
||||
repo = os.environ.get("OMNIVOICE_DOTS_TTS_MODEL", _DEFAULT_REPO)
|
||||
default_precision = "bfloat16" if torch.cuda.is_available() else "float32"
|
||||
# Match DotsTtsRuntime's own CUDA/CPU selection. Its _check_torch_env
|
||||
# rejects half precision without CUDA, even when an XPU/NPU is available.
|
||||
try:
|
||||
default_precision = "bfloat16" if torch.cuda.is_available() else "float32"
|
||||
except Exception:
|
||||
default_precision = "float32" # Probe failure must not force half precision.
|
||||
precision = os.environ.get("OMNIVOICE_DOTS_TTS_PRECISION", default_precision)
|
||||
optimize = os.environ.get("OMNIVOICE_DOTS_TTS_OPTIMIZE", "0") == "1"
|
||||
|
||||
|
||||
@@ -29,15 +29,11 @@ Do NOT import ``main.py`` from the parent process — it runs under a
|
||||
different venv (``transformers==5.0.0``) and importing it in-process would
|
||||
re-introduce the exact conflict this isolation exists to avoid.
|
||||
|
||||
Hardware honesty (cross-platform rule): MOSS-TTS-v1.5's upstream documents
|
||||
only CUDA and CPU. There is **no documented or tested MPS path** — the
|
||||
custom ``trust_remote_code`` modelling code and the separate audio
|
||||
tokenizer are unverified on Apple Silicon. We therefore advertise
|
||||
``gpu_compat = ("cuda", "cpu")`` and the sidecar selects ``cuda`` when
|
||||
present else ``cpu`` — it never silently routes to MPS where it might
|
||||
crash. On Apple Silicon the engine honestly resolves to CPU (slow but
|
||||
correct), and the engine is opt-in regardless, so it never becomes a
|
||||
broken default on any platform.
|
||||
Hardware routing follows the sidecar's runtime-available PyTorch accelerator:
|
||||
CUDA/ROCm, XPU, or a registered NPU. MPS remains excluded; CPU is the fallback.
|
||||
XPU/NPU routing is covered with mocked device contracts, not physical-hardware
|
||||
synthesis certification; users need a compatible torch/vendor runtime in the
|
||||
isolated engine venv.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -85,13 +81,13 @@ class MossTTSV15Backend(SubprocessBackend):
|
||||
|
||||
id = "moss-tts-v15"
|
||||
display_name = (
|
||||
"MOSS-TTS-v1.5 (8B, 31 langs, zero-shot clone, CUDA/CPU, Apache-2.0)"
|
||||
"MOSS-TTS-v1.5 (8B, 31 langs, zero-shot clone, Apache-2.0)"
|
||||
)
|
||||
supports_voice_design = False # requires ref audio for timbre cloning
|
||||
_DEFAULT_SAMPLE_RATE = 24000
|
||||
# Honest hardware surface: upstream documents CUDA + CPU only. MPS is
|
||||
# undocumented / untested, so we do NOT claim it (cross-platform rule).
|
||||
gpu_compat = ("cuda", "cpu")
|
||||
# Accelerator routing requires its matching runtime in the isolated venv.
|
||||
# MPS remains untested and is deliberately excluded.
|
||||
gpu_compat = ("cuda", "rocm", "xpu", "npu", "cpu")
|
||||
|
||||
# ── availability ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -111,7 +107,7 @@ class MossTTSV15Backend(SubprocessBackend):
|
||||
return False, (
|
||||
"MOSS-TTS-v1.5 venv not found. Set OMNIVOICE_MOSS_TTS_V15_DIR "
|
||||
"to your MOSS-TTS clone (the directory containing pyproject.toml) "
|
||||
"and restart VoiceStudio. CUDA or CPU only (no MPS). See "
|
||||
"and restart VoiceStudio. Install the matching PyTorch runtime. See "
|
||||
"docs/engines/moss-tts-v15.md for the full install walk-through."
|
||||
)
|
||||
if not MOSS_TTS_V15_SIDECAR_SCRIPT.exists():
|
||||
@@ -119,7 +115,7 @@ class MossTTSV15Backend(SubprocessBackend):
|
||||
"MOSS-TTS-v1.5 sidecar script missing at "
|
||||
f"{MOSS_TTS_V15_SIDECAR_SCRIPT} — reinstall VoiceStudio."
|
||||
)
|
||||
return True, "ok (CUDA when present, else CPU)"
|
||||
return True, "ok (runtime-available accelerator or CPU; no MPS)"
|
||||
|
||||
@classmethod
|
||||
def venv_python(cls):
|
||||
|
||||
@@ -138,11 +138,12 @@ _state = None
|
||||
def _load_model(stdout):
|
||||
"""Cold-construct the MOSS-TTS-v1.5 processor + model.
|
||||
|
||||
Device selection is CUDA-or-CPU only — MOSS's upstream documents no MPS
|
||||
path and the custom ``trust_remote_code`` modelling code is untested on
|
||||
Apple Silicon, so we never route to MPS where it might crash. dtype is
|
||||
bf16 on CUDA, fp32 on CPU (bf16 CPU ops are spotty). Emits progress
|
||||
frames so the parent can surface the multi-GB cold-load latency.
|
||||
Device selection uses the torch.accelerator API to support any backend
|
||||
(CUDA, NPU, XPU, etc.) automatically. MPS is excluded — MOSS's upstream
|
||||
``trust_remote_code`` modelling code is untested on Apple Silicon. dtype is
|
||||
bf16 on GPU-class accelerators, fp32 on CPU (bf16 CPU ops are spotty).
|
||||
Emits progress frames so the parent can surface the multi-GB cold-load
|
||||
latency.
|
||||
"""
|
||||
global _state
|
||||
if _state is not None:
|
||||
@@ -154,8 +155,22 @@ def _load_model(stdout):
|
||||
from transformers import AutoModel, AutoProcessor
|
||||
|
||||
repo, revision = _model_source()
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
dtype = torch.bfloat16 if device == "cuda" else torch.float32
|
||||
# current_accelerator() returns None on CPU-only builds (no accelerator
|
||||
# compiled in) or when no accelerator is available; fall back to "cpu".
|
||||
# Existing manually provisioned venvs may predate torch.accelerator.
|
||||
current_accelerator = getattr(getattr(torch, "accelerator", None), "current_accelerator", None)
|
||||
try:
|
||||
if current_accelerator is None:
|
||||
accel = torch.device("cuda") if torch.cuda.is_available() else None
|
||||
else:
|
||||
accel = current_accelerator(check_available=True)
|
||||
except Exception:
|
||||
# Optional drivers can fail during probing; CPU loading remains usable.
|
||||
accel = None
|
||||
device = accel.type if accel is not None else "cpu" # 'cuda', 'npu', 'mps', 'xpu', 'cpu'
|
||||
if device == "mps":
|
||||
device = "cpu" # MOSS is untested on MPS; fall back to CPU for safety
|
||||
dtype = torch.bfloat16 if device != "cpu" else torch.float32
|
||||
# "sdpa" works on CUDA + CPU and needs no extra dep. flash_attention_2
|
||||
# (Ampere+ CUDA, optional flash-attn) is opt-in via env.
|
||||
attn = os.environ.get("OMNIVOICE_MOSS_TTS_V15_ATTN", "sdpa")
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Retain the dispatch-time deadline policy across worker/control-plane loss."""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0011_remote_attempt_deadlines"
|
||||
down_revision = "0010_remote_worker_schema"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
columns = op.get_bind().execute(sa.text("PRAGMA table_info(remote_task_attempts)"))
|
||||
if not any(row[1] == "deadlines_json" for row in columns):
|
||||
op.add_column("remote_task_attempts", sa.Column("deadlines_json", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("remote_task_attempts", "deadlines_json")
|
||||
@@ -31,6 +31,30 @@ class RoutingResult(TypedDict):
|
||||
routing_reason: str | None # raw, pre-scrub
|
||||
|
||||
|
||||
def under_provisioned_vram(caps: HostCaps, min_vram_gb: float = 0.0) -> bool:
|
||||
"""Is this host's DEDICATED VRAM below the engine's declared floor?
|
||||
|
||||
The one definition of "under-provisioned", shared by everything that acts
|
||||
on the verdict: the routing caveat below, the timeout guidance, and — since
|
||||
#1804 — the compute-time budget itself (``model_manager
|
||||
.generate_timeout_s``). It was written out inline in each of them, which is
|
||||
how the budget came to disagree with the warning printed next to it.
|
||||
|
||||
Dedicated-VRAM families ONLY. On MPS, ``HostCaps.vram_gb`` is a heuristic
|
||||
(system RAM / 2, see device_caps) for a UNIFIED memory pool; comparing it
|
||||
against a floor measured on discrete CUDA hardware would tell every 8 GB Mac
|
||||
its 4 GB "VRAM" is too small for an engine that runs fine there. A VRAM
|
||||
figure of 0 means the probe failed — don't guess from it. A floor of 0 means
|
||||
the engine declares none, and inventing one is worse than staying quiet.
|
||||
"""
|
||||
if not min_vram_gb or min_vram_gb <= 0:
|
||||
return False
|
||||
if getattr(caps, "family", None) not in ("cuda", "rocm"):
|
||||
return False
|
||||
vram_gb = float(getattr(caps, "vram_gb", 0.0) or 0.0)
|
||||
return 0 < vram_gb < float(min_vram_gb)
|
||||
|
||||
|
||||
def _caveat(caps: HostCaps, min_vram_gb: float = 0.0) -> str | None:
|
||||
"""A caveat string for an otherwise-accelerated host, or None.
|
||||
|
||||
@@ -51,16 +75,7 @@ def _caveat(caps: HostCaps, min_vram_gb: float = 0.0) -> str | None:
|
||||
for note in caps.notes:
|
||||
if KERNEL_RISK_MARKER in note:
|
||||
return f"{caps.family.upper()} selected, but: {note}"
|
||||
# Dedicated-VRAM families ONLY. On MPS, HostCaps.vram_gb is a heuristic
|
||||
# (system RAM / 2, see device_caps) for a UNIFIED memory pool — comparing
|
||||
# it against a floor measured on discrete CUDA hardware would tell every
|
||||
# 8 GB Mac its 4 GB "VRAM" is too small for an engine that runs fine there.
|
||||
# Different memory model, different (unmeasured) floor; don't guess.
|
||||
if (
|
||||
caps.family in ("cuda", "rocm")
|
||||
and min_vram_gb > 0
|
||||
and 0 < caps.vram_gb < min_vram_gb
|
||||
):
|
||||
if under_provisioned_vram(caps, min_vram_gb):
|
||||
device = caps.device_name or caps.family.upper()
|
||||
return (
|
||||
f"{device} has {caps.vram_gb:.1f} GB VRAM; this engine wants about "
|
||||
@@ -208,5 +223,5 @@ def routing_fields(
|
||||
|
||||
__all__ = [
|
||||
"RoutingStatus", "RoutingResult", "resolve_routing", "routing_fields",
|
||||
"routing_notice", "header_safe_reason",
|
||||
"routing_notice", "header_safe_reason", "under_provisioned_vram",
|
||||
]
|
||||
|
||||
@@ -525,6 +525,7 @@ class GpuPoolBusyError(TimeoutError):
|
||||
|
||||
def generate_timeout_s(
|
||||
text: "str | None", *, engine: object = None, execution_device: "str | None" = None,
|
||||
min_vram_gb: float = 0.0,
|
||||
) -> float:
|
||||
"""THE wall-clock execution budget for one synthesis job, scaled to input.
|
||||
|
||||
@@ -543,11 +544,26 @@ def generate_timeout_s(
|
||||
characters past a 1200-character free allowance — generous enough for
|
||||
CPU-class hardware, still bounded (a wedged job is caught in minutes, not
|
||||
hours).
|
||||
|
||||
#1804: "accelerated" is not one performance class. A card with less VRAM
|
||||
than the engine declares it needs pages to system RAM over PCIe and renders
|
||||
SLOWER than the same machine's CPU would — yet, judged by device family
|
||||
alone, it was handed HALF the CPU budget. That inversion is what three 4 GB
|
||||
reporters hit (#1226 GTX 1650 Ti, #1222 Quadro P2000, #1804 GTX 1650), all
|
||||
on the engine that declares a 6 GB floor. Every layer already knew: routing
|
||||
raises a caveat, the preflight toast warns, and the timeout message names
|
||||
the card. Only the budget ignored it. So an under-provisioned accelerator
|
||||
now floors at the CPU budget — the class of hardware it actually performs
|
||||
like. ``min_vram_gb`` is the engine's declared floor; callers that pass
|
||||
``engine`` get it read off the engine automatically.
|
||||
"""
|
||||
base = GPU_JOB_TIMEOUT_S
|
||||
try:
|
||||
from core.device_caps import detect_host_caps
|
||||
family = execution_device or detect_host_caps().family
|
||||
caps = detect_host_caps()
|
||||
family = execution_device or caps.family
|
||||
if not min_vram_gb and engine is not None:
|
||||
min_vram_gb = float(getattr(engine, "min_vram_gb", 0.0) or 0.0)
|
||||
if execution_device is None and engine is not None:
|
||||
from services.engine_routing import resolve_routing
|
||||
compat = getattr(engine, "gpu_compat", None)
|
||||
@@ -556,10 +572,7 @@ def generate_timeout_s(
|
||||
if tuple(compat) == ("cpu",):
|
||||
family = "cpu"
|
||||
else:
|
||||
family = resolve_routing(
|
||||
compat, detect_host_caps(),
|
||||
float(getattr(engine, "min_vram_gb", 0.0) or 0.0),
|
||||
)["effective_device"]
|
||||
family = resolve_routing(compat, caps, min_vram_gb)["effective_device"]
|
||||
universal_override = (
|
||||
_GENERATE_TIMEOUT_EXPLICIT
|
||||
or GPU_JOB_TIMEOUT_S != _CONFIGURED_GPU_JOB_TIMEOUT_S
|
||||
@@ -573,6 +586,13 @@ def generate_timeout_s(
|
||||
)
|
||||
if family == "cpu" and (cpu_explicit or not universal_override):
|
||||
base = CPU_JOB_TIMEOUT_S
|
||||
elif not universal_override and family in ("cuda", "rocm"):
|
||||
from services.engine_routing import under_provisioned_vram
|
||||
|
||||
if under_provisioned_vram(caps, min_vram_gb):
|
||||
# `max`, never a plain assignment: an operator who raised the
|
||||
# accelerated budget above the CPU one must not have it cut.
|
||||
base = max(base, CPU_JOB_TIMEOUT_S)
|
||||
except Exception:
|
||||
# Device probing is advisory here; the configured universal bound is
|
||||
# still safe when a platform probe is unavailable during startup.
|
||||
@@ -1076,6 +1096,7 @@ def _timeout_guidance(
|
||||
"""
|
||||
family = "cuda" # conservative default: GPU wording if the probe fails
|
||||
device_name, vram_gb = "", 0.0
|
||||
_caps = None # a failed probe stays None; under_provisioned_vram() reads it safely
|
||||
try:
|
||||
from core.device_caps import detect_host_caps
|
||||
_caps = detect_host_caps()
|
||||
@@ -1123,11 +1144,9 @@ def _timeout_guidance(
|
||||
# a threshold applied without knowing whose job it is would confidently
|
||||
# misdiagnose most of them. And on MPS `vram_gb` is a unified-memory
|
||||
# heuristic (RAM/2), not a dedicated pool to compare against.
|
||||
if (
|
||||
min_vram_gb > 0
|
||||
and family in ("cuda", "rocm")
|
||||
and 0 < vram_gb < min_vram_gb
|
||||
):
|
||||
from services.engine_routing import under_provisioned_vram
|
||||
|
||||
if under_provisioned_vram(_caps, min_vram_gb):
|
||||
return common + (
|
||||
f"{device_name or 'this GPU'} has {vram_gb:.1f} GB of VRAM and "
|
||||
f"this engine wants about {min_vram_gb:.0f} GB — generations here "
|
||||
@@ -1506,14 +1525,17 @@ def get_best_device():
|
||||
# ── DirectML — universal Windows GPU (probe reports this as "cpu") ─
|
||||
# Reached only when no torch family was detected (family == "cpu"), which is
|
||||
# exactly the DirectML case — the probe classifies DirectML hosts as cpu.
|
||||
try:
|
||||
import torch_directml
|
||||
if torch_directml.device_count() > 0:
|
||||
logger.info("Using DirectML device (GPU %d)", 0)
|
||||
return str(torch_directml.device(0))
|
||||
except ImportError:
|
||||
pass
|
||||
if family == "cpu":
|
||||
try:
|
||||
import torch_directml
|
||||
if torch_directml.device_count() > 0:
|
||||
logger.info("Using DirectML device (GPU %d)", 0)
|
||||
return str(torch_directml.device(0))
|
||||
except ImportError:
|
||||
# DirectML is optional; an absent package leaves CPU available.
|
||||
pass
|
||||
|
||||
# Other families need an explicitly compatible loader (e.g. NPU sidecars).
|
||||
return "cpu"
|
||||
|
||||
_COMPILE_ERR_MODULE_PREFIXES = ("torch._dynamo", "torch._inductor", "torch.fx", "triton")
|
||||
|
||||
@@ -385,6 +385,10 @@ class SubprocessBackend(TTSBackend):
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._proc: Optional[subprocess.Popen] = None
|
||||
# A failed bounded reap must retain ownership and forbid reuse. This
|
||||
# lock is separate from _lock: the receive owner joins its watchdog.
|
||||
self._timeout_quarantine: list[subprocess.Popen] = []
|
||||
self._timeout_quarantine_lock = threading.Lock()
|
||||
# Single lock serialises spawn + every send/recv pair so two threads
|
||||
# can't interleave half-frames on the same pipe.
|
||||
self._lock = threading.Lock()
|
||||
@@ -451,6 +455,10 @@ class SubprocessBackend(TTSBackend):
|
||||
def _spawn(self) -> None:
|
||||
"""Launch the sidecar if not already running. Blocks on the ready
|
||||
handshake. Caller must hold self._lock."""
|
||||
if not self._retry_timeout_cleanup():
|
||||
raise RuntimeError(
|
||||
f"{self.id} sidecar is still stopping after a timeout; retry once it exits"
|
||||
)
|
||||
if self._proc is not None and self._proc.poll() is None:
|
||||
return # already up
|
||||
|
||||
@@ -542,6 +550,7 @@ class SubprocessBackend(TTSBackend):
|
||||
"""Idempotent. Sends {op:shutdown}; falls back to terminate/kill."""
|
||||
proc = self._proc
|
||||
if proc is None:
|
||||
self._retry_timeout_cleanup()
|
||||
return
|
||||
try:
|
||||
try:
|
||||
@@ -577,6 +586,7 @@ class SubprocessBackend(TTSBackend):
|
||||
pass
|
||||
finally:
|
||||
self._proc = None
|
||||
self._retry_timeout_cleanup()
|
||||
|
||||
def _force_kill(self) -> None:
|
||||
"""Internal: kill a sidecar that never reached the ready state."""
|
||||
@@ -777,38 +787,59 @@ class SubprocessBackend(TTSBackend):
|
||||
return msg
|
||||
|
||||
def _recv_with_timeout(self, timeout_s: float) -> Optional[dict]:
|
||||
"""Recv that aborts if the sidecar goes silent.
|
||||
"""Read one frame, finishing timeout cleanup before the caller can retry.
|
||||
|
||||
Implemented by polling the proc for liveness with a deadline. We
|
||||
don't block on a `select` of the pipe because Windows can't select
|
||||
on subprocess pipes — keeping the implementation cross-platform
|
||||
means a simpler polling loop here.
|
||||
A watchdog closes the pipe on timeout; Windows cannot select on pipes.
|
||||
EOF alone does not prove the owned process/supervisor has exited.
|
||||
"""
|
||||
# On Unix we could use selectors; on Windows the pipe is not
|
||||
# selectable. Use a watchdog thread that kills the sidecar on
|
||||
# timeout — that triggers EOF on stdout, so _recv returns None
|
||||
# and the caller raises.
|
||||
watchdog = threading.Timer(timeout_s, self._timeout_kill)
|
||||
proc = self._proc
|
||||
watchdog = threading.Timer(timeout_s, self._timeout_kill, args=(proc,))
|
||||
watchdog.daemon = True
|
||||
watchdog.start()
|
||||
try:
|
||||
return self._recv()
|
||||
finally:
|
||||
watchdog.cancel()
|
||||
# cancel() cannot stop an already-running callback. Finish its
|
||||
# bounded reap before another receive or generation starts.
|
||||
watchdog.join()
|
||||
self._touch() # any reply (or attempt) counts as recent activity
|
||||
|
||||
def _timeout_kill(self) -> None:
|
||||
proc = self._proc
|
||||
def _timeout_kill(self, proc: Optional[subprocess.Popen]) -> None:
|
||||
"""Kill only the child this receive captured, then reap its owner."""
|
||||
if proc is None:
|
||||
return
|
||||
logger.error("[%s] sidecar exceeded recv timeout; killing", self.id)
|
||||
try:
|
||||
logger.error(
|
||||
"[%s] sidecar exceeded recv timeout; killing",
|
||||
self.id,
|
||||
)
|
||||
proc.kill()
|
||||
except Exception:
|
||||
# A raced exit can make kill fail, but its owner still needs reaping.
|
||||
pass
|
||||
try:
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
# Do not discard a possibly live owner, or replace a newer _proc.
|
||||
with self._timeout_quarantine_lock:
|
||||
if not any(item is proc for item in self._timeout_quarantine):
|
||||
self._timeout_quarantine.append(proc)
|
||||
else:
|
||||
with self._timeout_quarantine_lock:
|
||||
self._timeout_quarantine = [
|
||||
item for item in self._timeout_quarantine if item is not proc
|
||||
]
|
||||
|
||||
def _retry_timeout_cleanup(self) -> bool:
|
||||
"""Retry bounded cleanup, retaining every owner that could still be live."""
|
||||
with self._timeout_quarantine_lock:
|
||||
pending = tuple(self._timeout_quarantine)
|
||||
for proc in pending:
|
||||
self._timeout_kill(proc)
|
||||
with self._timeout_quarantine_lock:
|
||||
return not self._timeout_quarantine
|
||||
|
||||
# ── stderr drain ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -112,6 +112,13 @@ _FULL_NAME_TO_CODE = {
|
||||
"vietnamese": "vi",
|
||||
"kazakh": "kz",
|
||||
"standard arabic": "ar",
|
||||
# Below: inert for num2words (absent from _NUM2WORDS_LANGS, which reads
|
||||
# digits natively for these scripts), present so _plain_lang_code can
|
||||
# resolve them for the digit-range rule.
|
||||
"korean": "ko",
|
||||
"japanese": "ja",
|
||||
"chinese": "zh",
|
||||
"mandarin chinese": "zh",
|
||||
}
|
||||
|
||||
# ISO codes whose num2words locale name differs.
|
||||
@@ -178,6 +185,82 @@ def _num2words_lang(language: Optional[str]) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _plain_lang_code(language: Optional[str]) -> Optional[str]:
|
||||
"""Resolve a request language to a bare ISO code, with no num2words gate.
|
||||
|
||||
:func:`_num2words_lang` answers "may I call num2words for this?" and so
|
||||
returns ``None`` for ko/ja/zh/th/vi. Rules that are not num2words-backed
|
||||
need the code itself, which is what this returns.
|
||||
"""
|
||||
if not language:
|
||||
return None
|
||||
s = str(language).strip().lower()
|
||||
if not s or s == "auto":
|
||||
return None
|
||||
code = _FULL_NAME_TO_CODE.get(s)
|
||||
if code:
|
||||
return code
|
||||
m = _ISO_CODE_RE.match(s)
|
||||
if m:
|
||||
return _ISO_ALIASES.get(m.group(1), m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
# ── Digit ranges ─────────────────────────────────────────────────────────────
|
||||
# "20~30" loses its separator at the engine and reads as ONE number: OmniVoice
|
||||
# says "이십삼" (23) for "20~30초". Speak the separator instead. Verified by
|
||||
# rendering each form and transcribing it back (ko, OmniVoice):
|
||||
# "20~30초" → heard "23초" ✗
|
||||
# "20에서 30초" → heard "20에서 30초" ✓
|
||||
# Only the tilde family is rewritten — those are unambiguously range marks
|
||||
# between digits. An ASCII hyphen is left alone on purpose: it also spells
|
||||
# dates, phone numbers and product codes, where "to" would be wrong.
|
||||
#: Spacing is part of the form, not decoration: a Korean postposition binds to
|
||||
#: the numeral ("20에서 30"), Japanese and Chinese set no spaces at all, and
|
||||
#: English needs them on both sides.
|
||||
_RANGE_FORM = {
|
||||
"ko": "{a}에서 {b}",
|
||||
"ja": "{a}から{b}",
|
||||
"zh": "{a}到{b}",
|
||||
"en": "{a} to {b}",
|
||||
}
|
||||
|
||||
#: ASCII tilde, wave dash, fullwidth tilde — Japanese and Korean IMEs emit the
|
||||
#: latter two, so all three have to match.
|
||||
#:
|
||||
#: Match complete signed/decimal endpoints; reject partial numbers and product
|
||||
#: codes while allowing adjacent CJK units. Guard all tilde forms so malformed
|
||||
#: chains cannot be partially rewritten, including when their separators have
|
||||
#: whitespace around them.
|
||||
_RANGE_MARKS = "~\u301c\uff5e"
|
||||
_RANGE_ENDPOINT = r"[+-]?(?:\d{1,6}(?:\.\d{1,6})?|\.\d{1,6})"
|
||||
_NUM_RANGE_RE = re.compile(
|
||||
rf"(?<![\d.,A-Za-z+{_RANGE_MARKS}-])({_RANGE_ENDPOINT})"
|
||||
rf"\s*[{_RANGE_MARKS}]\s*({_RANGE_ENDPOINT})"
|
||||
rf"(?![\d.,A-Za-z+{_RANGE_MARKS}-])"
|
||||
)
|
||||
|
||||
|
||||
def _speak_number_ranges(text: str, lang: str) -> str:
|
||||
"""Speak complete tilde ranges only for languages with a verified form."""
|
||||
form = _RANGE_FORM.get(lang)
|
||||
if not form:
|
||||
return text
|
||||
|
||||
def replace(match: re.Match) -> str:
|
||||
before, after = match.start() - 1, match.end()
|
||||
while before >= 0 and text[before].isspace():
|
||||
before -= 1
|
||||
while after < len(text) and text[after].isspace():
|
||||
after += 1
|
||||
if ((before >= 0 and text[before] in _RANGE_MARKS)
|
||||
or (after < len(text) and text[after] in _RANGE_MARKS)):
|
||||
return match.group(0)
|
||||
return form.format(a=match.group(1), b=match.group(2))
|
||||
|
||||
return _NUM_RANGE_RE.sub(replace, text)
|
||||
|
||||
|
||||
# ── Universal safety filters (all languages) ─────────────────────────────────
|
||||
|
||||
# Zero-width & bidi controls, C0/C1 controls (except \t \n \r), BOM, U+FFFD.
|
||||
@@ -487,6 +570,11 @@ def normalize_text(text: str, language: Optional[str] = None) -> str:
|
||||
if not text:
|
||||
return text or ""
|
||||
out = _safety_filters(text)
|
||||
# Runs outside the num2words gate below: ko/ja/zh keep their digits (that
|
||||
# gate returns None for them) but still need the range mark spoken.
|
||||
plain = _plain_lang_code(language)
|
||||
if plain:
|
||||
out = _outside_brackets(out, lambda t: _speak_number_ranges(t, plain))
|
||||
lang = _num2words_lang(language)
|
||||
if lang:
|
||||
if lang in _ABBREV_COMPILED:
|
||||
|
||||
@@ -4,7 +4,7 @@ Resolution priority (highest → lowest):
|
||||
|
||||
1. app — `settings_store.get_hf_token()` (encrypted in SQLite)
|
||||
2. env — `HF_TOKEN` or the legacy `HUGGING_FACE_HUB_TOKEN` env var
|
||||
3. hf-cli — `huggingface_hub.get_token()` (canonical ~/.cache/huggingface/token)
|
||||
3. hf-cli — the selected local Hub token file (`HF_TOKEN_PATH`)
|
||||
|
||||
For each candidate, the resolver calls `huggingface_hub.whoami(token=...)`
|
||||
to verify the token is live; any HTTP error (401, 403, network) skips to
|
||||
@@ -20,6 +20,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
@@ -46,7 +47,7 @@ class SourceState:
|
||||
set: bool
|
||||
masked: Optional[str]
|
||||
whoami_user: Optional[str]
|
||||
whoami_ok: bool
|
||||
whoami_ok: Optional[bool]
|
||||
|
||||
|
||||
# ── module-level cache ────────────────────────────────────────────────────
|
||||
@@ -79,19 +80,26 @@ def _read_app() -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _clean_token(value: Optional[str]) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
return value.replace("\r", "").replace("\n", "").strip() or None
|
||||
|
||||
|
||||
def _read_env() -> Optional[str]:
|
||||
# HF docs explicitly accept either name; user may have either exported.
|
||||
val = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
|
||||
return val or None
|
||||
return _clean_token(val)
|
||||
|
||||
|
||||
def _read_hf_cli() -> Optional[str]:
|
||||
try:
|
||||
import huggingface_hub
|
||||
tok = huggingface_hub.get_token()
|
||||
return tok or None
|
||||
from huggingface_hub import constants
|
||||
return _clean_token(Path(constants.HF_TOKEN_PATH).read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except Exception:
|
||||
logger.exception("huggingface_hub.get_token failed")
|
||||
logger.warning("Could not read the local Hugging Face token file")
|
||||
return None
|
||||
|
||||
|
||||
@@ -184,17 +192,17 @@ def on_401(active_source: Source) -> Optional[ResolvedToken]:
|
||||
return resolve(skip=frozenset({active_source}))
|
||||
|
||||
|
||||
def state() -> dict:
|
||||
def state(*, validate: bool = False) -> dict:
|
||||
"""Return one SourceState per priority position so the Settings UI can
|
||||
render the cascade table. Includes a masked token + whoami result;
|
||||
never includes the raw token."""
|
||||
never includes the raw token. Reads are local unless validation is explicitly requested."""
|
||||
rows: list[SourceState] = []
|
||||
active: Optional[Source] = None
|
||||
for source in _PRIORITY:
|
||||
token = _READERS[source]()
|
||||
if token:
|
||||
username = _validate(source, token)
|
||||
ok = username is not None
|
||||
username = _validate(source, token) if validate else None
|
||||
ok = (username is not None) if validate else None
|
||||
rows.append(SourceState(
|
||||
source=source,
|
||||
set=True,
|
||||
@@ -249,15 +257,30 @@ def save_app_token(token: str) -> None:
|
||||
invalidate_cache()
|
||||
|
||||
|
||||
def clear_hf_cli_tokens() -> None:
|
||||
"""Remove recognized Hub token files without refreshing or revoking tokens."""
|
||||
from core.config import HF_CLI_TOKEN_PATHS
|
||||
from huggingface_hub import constants
|
||||
|
||||
# Hub's active path remains authoritative if imported before app config.
|
||||
paths = set(HF_CLI_TOKEN_PATHS) | {constants.HF_TOKEN_PATH}
|
||||
failed = False
|
||||
for token_path in paths:
|
||||
path = Path(token_path)
|
||||
for target in (path, path.parent / "stored_tokens"):
|
||||
try:
|
||||
target.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
failed = True
|
||||
invalidate_cache()
|
||||
if failed:
|
||||
raise OSError("Could not clear all local Hugging Face token files")
|
||||
|
||||
|
||||
def clear_app_token(also_clear_hf_cli: bool = False) -> None:
|
||||
"""Remove from the encrypted settings store; optionally also call
|
||||
`huggingface_hub.logout()` to clear the canonical HF file."""
|
||||
"""Clear the encrypted app token, optionally recognized local Hub files."""
|
||||
from services import settings_store
|
||||
settings_store.clear_hf_token()
|
||||
if also_clear_hf_cli:
|
||||
try:
|
||||
import huggingface_hub
|
||||
huggingface_hub.logout()
|
||||
except Exception:
|
||||
logger.exception("huggingface_hub.logout failed (non-fatal)")
|
||||
clear_hf_cli_tokens()
|
||||
invalidate_cache()
|
||||
|
||||
@@ -566,7 +566,12 @@ def _get_clone_prompt(
|
||||
):
|
||||
"""Return a cached/precomputed ``VoiceClonePrompt`` for
|
||||
(ref_audio, ref_text, preprocess_prompt), or ``None`` to fall back to the
|
||||
inline ref path. Never raises.
|
||||
inline ref path.
|
||||
|
||||
Raises only on a device OOM that survives a cache-drop retry (#1790): the
|
||||
inline path is the same allocation on the same device, so falling back to
|
||||
it after an OOM cannot succeed and has been observed taking the whole
|
||||
process down instead. Every other failure still falls back silently.
|
||||
|
||||
``store=False`` still *reads* the cache (a hit is free) but never inserts:
|
||||
it exists for single-use references — a dub's per-segment ref clips are each
|
||||
@@ -596,10 +601,43 @@ def _get_clone_prompt(
|
||||
ref_audio, ref_text=ref_text, preprocess_prompt=preprocess_prompt
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 — fall back, never break synthesis
|
||||
logger.warning(
|
||||
"voice-clone prompt precompute failed; using inline ref: %s", e
|
||||
)
|
||||
return None
|
||||
# #1790/#1777: a GPU OOM is the one failure this fallback cannot
|
||||
# absorb. `generate()`'s inline ref path runs the SAME encode on the
|
||||
# SAME device — the docstring above says so, because producing
|
||||
# identical output is the point — so returning None after an OOM
|
||||
# guarantees a second OOM moments later, on a device with even less
|
||||
# headroom than the first attempt found. Both reporters' backends
|
||||
# then died with a Windows access violation (exit code
|
||||
# -1073741819) seconds after this exact log line, mid-generation on
|
||||
# a GPU that had just refused an 86 MiB allocation.
|
||||
#
|
||||
# An OOM here is also the most recoverable kind: the allocator is
|
||||
# typically holding reserved-but-unallocated blocks (#1790's own
|
||||
# log reports 90 MiB reserved against an 86 MiB request). Drop them
|
||||
# and try once more. If it still will not fit, raise — the failure
|
||||
# layer turns a device OOM into the actionable GPU_OOM message
|
||||
# ("close other GPU-heavy apps or unload models…"), which is a far
|
||||
# better answer than walking into a native fault.
|
||||
from core.failure import is_gpu_oom
|
||||
|
||||
if is_gpu_oom(e):
|
||||
logger.warning(
|
||||
"voice-clone prompt precompute hit a device OOM (%s) — "
|
||||
"releasing allocator caches and retrying once", e,
|
||||
)
|
||||
try:
|
||||
from services.model_manager import free_vram
|
||||
free_vram()
|
||||
except Exception: # noqa: BLE001 — reclaim is best-effort
|
||||
logger.debug("VRAM reclaim before OOM retry failed", exc_info=True)
|
||||
prompt = model.create_voice_clone_prompt(
|
||||
ref_audio, ref_text=ref_text, preprocess_prompt=preprocess_prompt
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"voice-clone prompt precompute failed; using inline ref: %s", e
|
||||
)
|
||||
return None
|
||||
if store:
|
||||
_prompt_disk_save(key, prompt)
|
||||
if not store:
|
||||
@@ -2298,9 +2336,9 @@ _INSTALL_HINTS: dict[str, str] = {
|
||||
"omnivoice-gguf":"Bundled — runs the C++ omnivoice-tts binary in bin/. Quants download lazily from Serveurperso/OmniVoice-GGUF on first generate.",
|
||||
"supertonic3": "uv sync --extra supertonic (CPU-only ONNX, 31 langs, ~400 MB model on first use; OpenRAIL-M model license)",
|
||||
"pockettts": "uv sync --extra pockettts (Kyutai, CPU-only, ~100 MB model on first use; MIT code + CC-BY-4.0 weights; HF-gated, review terms and set HF_TOKEN)",
|
||||
"moss-tts-v15": "git clone OpenMOSS/MOSS-TTS + set OMNIVOICE_MOSS_TTS_V15_DIR (own venv, transformers==5.0; 8B, ~16 GB weights; CUDA/CPU, no MPS; Apache-2.0)",
|
||||
"moss-tts-v15": "git clone OpenMOSS/MOSS-TTS + set OMNIVOICE_MOSS_TTS_V15_DIR (own venv, transformers==5.0; 8B, ~16 GB weights; CUDA/ROCm/XPU/NPU/CPU, no MPS; Apache-2.0)",
|
||||
"dots-tts": "git clone rednote-hilab/dots.tts + set OMNIVOICE_DOTS_TTS_DIR (own venv, transformers==4.57; 2B, ~9 GB weights; CUDA/CPU, Linux/macOS only — no Windows; Apache-2.0)",
|
||||
"confucius4-tts":"git clone netease-youdao/Confucius4-TTS + set OMNIVOICE_CONFUCIUS4_TTS_DIR (own Python 3.10 venv; 14-lang cross-lingual zero-shot clone; ~5 GB weights auto-download; CUDA/CPU, no MPS; Apache-2.0)",
|
||||
"confucius4-tts":"git clone netease-youdao/Confucius4-TTS + set OMNIVOICE_CONFUCIUS4_TTS_DIR (own Python 3.10 venv; 14-lang cross-lingual zero-shot clone; ~5 GB weights auto-download; CUDA/ROCm/XPU/NPU/CPU, no MPS; Apache-2.0)",
|
||||
}
|
||||
|
||||
|
||||
@@ -2371,7 +2409,7 @@ def list_backends() -> list[dict]:
|
||||
"one_click_install": bool, # services.sidecar_install can provision it in-app
|
||||
"last_error": Optional[str], # cached most-recent failure
|
||||
"isolation_mode": "in-process" | "subprocess",
|
||||
"gpu_compat": list[str], # subset of {cuda, rocm, mps, xpu, cpu}
|
||||
"gpu_compat": list[str], # subset of {cuda, rocm, mps, xpu, npu, cpu}
|
||||
"supports_cloning": Optional[bool], # True/False from the class attr; None when
|
||||
# model-dependent (property, e.g. mlx-audio)
|
||||
"effective_device": str, # device this engine uses on THIS host
|
||||
|
||||
@@ -7,13 +7,15 @@ old silence-only guard missed it (the buzz is loud, not silent) so the garbage
|
||||
was cached and served.
|
||||
|
||||
These tests cover the fix *without the 5 GB model / a GPU*: they drive the pure
|
||||
``_spectral_flatness`` / ``_is_unusable_audio`` helpers with synthetic signals,
|
||||
and assert the render constants didn't regress. The real end-to-end render is
|
||||
verified manually (spectral flatness back in the speech range + Whisper ASR).
|
||||
``_spectral_flatness`` / ``_is_unusable_audio`` helpers with synthetic tones and
|
||||
tracked speech demo renders, and assert the render constants did not regress.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import soundfile as sf
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -39,6 +41,13 @@ def _white_noise() -> "torch.Tensor":
|
||||
return 0.5 * (torch.rand(N, generator=g) * 2 - 1)
|
||||
|
||||
|
||||
def _two_tone_buzz() -> "torch.Tensor":
|
||||
"""Two inharmonic partials — the other shape a collapsed render takes."""
|
||||
t = torch.arange(N, dtype=torch.float32) / SR
|
||||
s = torch.sin(2 * math.pi * 180.0 * t) + 0.6 * torch.sin(2 * math.pi * 361.0 * t)
|
||||
return 0.8 * s / s.abs().max()
|
||||
|
||||
|
||||
def _speech_like() -> "torch.Tensor":
|
||||
"""Broadband + harmonic + amplitude-modulated — a coarse stand-in for voiced
|
||||
speech: several harmonics (formant-ish), additive noise (consonants), and a
|
||||
@@ -85,6 +94,71 @@ def test_speech_like_is_usable():
|
||||
assert arch._is_unusable_audio(_speech_like()) is False
|
||||
|
||||
|
||||
# ── Threshold stays between the two things it has to separate ───────────────
|
||||
# Synthetic broadband speech has much higher flatness than real voiced audio.
|
||||
# Measure both sides of the threshold against actual inputs, including the
|
||||
# existing demo renders that the old thresholds rejected.
|
||||
|
||||
|
||||
def test_tonal_ceiling_is_measured_not_assumed():
|
||||
"""Derive the tonal side of the margin instead of trusting a literal.
|
||||
|
||||
A bare constant would keep passing if `_spectral_flatness` stopped scoring
|
||||
tones near zero, so measure the degenerate signals here and require the
|
||||
threshold to clear the worst of them tenfold.
|
||||
"""
|
||||
tones = [
|
||||
arch._spectral_flatness(_pure_tone(80.0)),
|
||||
arch._spectral_flatness(_pure_tone(220.0)),
|
||||
arch._spectral_flatness(_two_tone_buzz()),
|
||||
]
|
||||
assert all(t is not None for t in tones)
|
||||
assert max(tones) * 10 < arch._DEGENERATE_FLATNESS
|
||||
|
||||
|
||||
_SAMPLES = Path(__file__).resolve().parents[1] / "assets" / "samples"
|
||||
_SPEECH_FIXTURES = [
|
||||
"demo_voice.wav",
|
||||
"demo_clone_output.wav",
|
||||
*[f"voice_design/demo_voice_design_{name}.wav" for name in (
|
||||
"audiobook_uk_narrator", "aussie_podcaster", "bedtime_storyteller",
|
||||
"gravelly_villain", "indian_support_agent", "mandarin_sichuan", "us_news_anchor",
|
||||
)],
|
||||
*[f"dictation/{name}.wav" for name in (
|
||||
"en_conversational", "en_technical", "fr_reservation",
|
||||
)],
|
||||
*[f"demo/dubbing/{name}.src.wav" for name in (
|
||||
"source", "dubbed_es", "dubbed_fr", "dubbed_ja", "dubbed_zh",
|
||||
)],
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fixture", _SPEECH_FIXTURES)
|
||||
def test_real_shipped_speech_clears_quality_floor(fixture):
|
||||
# These are existing tracked demo renders, not synthesized stand-ins or
|
||||
# asserted measurements. Loading PCM needs neither a model nor a network.
|
||||
audio, _sample_rate = sf.read(_SAMPLES / fixture, dtype="float32", always_2d=True)
|
||||
speech = torch.from_numpy(audio.T)
|
||||
flatness = arch._spectral_flatness(speech)
|
||||
assert flatness is not None
|
||||
assert flatness > arch._DEGENERATE_FLATNESS * 10
|
||||
assert arch._is_unusable_audio(speech) is False
|
||||
|
||||
|
||||
def test_flatness_is_not_clip_length_dependent():
|
||||
"""Repeating a signal must not change what it measures.
|
||||
|
||||
The whole-clip FFT this replaced failed exactly here: its frequency
|
||||
resolution grew with duration, so the same audio measured 0.0229 at 3 s and
|
||||
~0 at 12 s (100% drift). Framed, the drift is under 0.1%.
|
||||
"""
|
||||
short = _speech_like()
|
||||
long = torch.cat([short] * 4)
|
||||
a, b = arch._spectral_flatness(short), arch._spectral_flatness(long)
|
||||
assert a is not None and b is not None
|
||||
assert abs(a - b) / a < 0.02
|
||||
|
||||
|
||||
# ── Constants didn't regress ────────────────────────────────────────────────
|
||||
def test_preview_render_constants():
|
||||
# 16 steps under-converged on the social script; the fix bumped it.
|
||||
|
||||
@@ -523,3 +523,163 @@ def test_generation_proxy_forwards_native_controls_and_seed():
|
||||
"class_temperature": 0.8,
|
||||
"seed": 321,
|
||||
})]
|
||||
|
||||
|
||||
def test_timeout_reaps_captured_process_before_recv_returns(monkeypatch):
|
||||
import threading
|
||||
|
||||
class Process:
|
||||
def __init__(self):
|
||||
self.killed = threading.Event()
|
||||
self.reaped = False
|
||||
self.wait_entered = threading.Event()
|
||||
self.release_wait = threading.Event()
|
||||
|
||||
def kill(self):
|
||||
self.killed.set() # EOF may arrive before the process is reaped.
|
||||
|
||||
def wait(self, timeout):
|
||||
assert timeout is not None
|
||||
self.wait_entered.set()
|
||||
assert self.release_wait.wait(2)
|
||||
self.reaped = True
|
||||
return -9
|
||||
|
||||
proc = Process()
|
||||
backend = OmniVoiceSubprocessBackend()
|
||||
backend._proc = proc
|
||||
|
||||
def recv():
|
||||
assert proc.killed.wait(2)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(backend, '_recv', recv)
|
||||
returned = threading.Event()
|
||||
results = []
|
||||
|
||||
def receive():
|
||||
results.append(backend._recv_with_timeout(0.01))
|
||||
returned.set()
|
||||
|
||||
reader = threading.Thread(target=receive)
|
||||
reader.start()
|
||||
try:
|
||||
assert proc.wait_entered.wait(2)
|
||||
assert not returned.wait(0.05), "EOF must not release the caller before process cleanup"
|
||||
finally:
|
||||
proc.release_wait.set()
|
||||
reader.join(2)
|
||||
backend._proc = None
|
||||
assert not reader.is_alive()
|
||||
assert returned.is_set()
|
||||
assert results == [None]
|
||||
assert proc.reaped
|
||||
|
||||
|
||||
def test_timeout_never_kills_a_replacement_process(monkeypatch):
|
||||
from unittest.mock import Mock
|
||||
import services.subprocess_backend as module
|
||||
|
||||
class ManualTimer:
|
||||
def __init__(self, _timeout, callback, args=()):
|
||||
self.callback = lambda: callback(*args)
|
||||
self.daemon = False
|
||||
|
||||
def start(self):
|
||||
pass
|
||||
|
||||
def cancel(self):
|
||||
pass
|
||||
|
||||
def join(self):
|
||||
pass
|
||||
|
||||
timers = []
|
||||
def timer(*args, **kwargs):
|
||||
result = ManualTimer(*args, **kwargs)
|
||||
timers.append(result)
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(module.threading, 'Timer', timer)
|
||||
backend = OmniVoiceSubprocessBackend()
|
||||
original, replacement = Mock(), Mock()
|
||||
backend._proc = original
|
||||
|
||||
def recv():
|
||||
backend._proc = replacement
|
||||
timers[0].callback()
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(backend, '_recv', recv)
|
||||
try:
|
||||
backend._recv_with_timeout(1)
|
||||
original.kill.assert_called_once()
|
||||
replacement.kill.assert_not_called()
|
||||
finally:
|
||||
backend._proc = None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", ["wait", "kill"])
|
||||
def test_timeout_quarantine_blocks_reuse_and_retains_cleanup_handle(failure):
|
||||
class StuckProcess:
|
||||
stdin = None
|
||||
def __init__(self):
|
||||
self.exited = False
|
||||
self.kill_calls = 0
|
||||
def poll(self):
|
||||
return 0 if self.exited else None
|
||||
def kill(self):
|
||||
self.kill_calls += 1
|
||||
if failure == "kill" and not self.exited:
|
||||
raise PermissionError("kill failed")
|
||||
def terminate(self):
|
||||
pass
|
||||
def wait(self, timeout):
|
||||
if not self.exited:
|
||||
raise subprocess.TimeoutExpired("stuck-sidecar", timeout)
|
||||
return 0
|
||||
|
||||
backend = OmniVoiceSubprocessBackend()
|
||||
proc = StuckProcess()
|
||||
backend._proc = proc
|
||||
try:
|
||||
backend._timeout_kill(proc)
|
||||
with pytest.raises(RuntimeError, match="still stopping"):
|
||||
backend._spawn()
|
||||
backend.shutdown()
|
||||
# Even after shutdown clears the current slot, ownership survives;
|
||||
# retry must not silently start a second process next to this one.
|
||||
before = proc.kill_calls
|
||||
with pytest.raises(RuntimeError, match="still stopping"):
|
||||
backend._spawn()
|
||||
assert proc.kill_calls > before
|
||||
finally:
|
||||
proc.exited = True
|
||||
backend.shutdown()
|
||||
|
||||
|
||||
def test_timeout_quarantine_does_not_clear_or_kill_replacement():
|
||||
from unittest.mock import Mock
|
||||
backend = OmniVoiceSubprocessBackend()
|
||||
original = Mock()
|
||||
original.wait.side_effect = subprocess.TimeoutExpired("old-sidecar", 2)
|
||||
replacement = Mock()
|
||||
replacement.poll.return_value = None
|
||||
backend._proc = replacement
|
||||
try:
|
||||
backend._timeout_kill(original)
|
||||
with pytest.raises(RuntimeError, match="still stopping"):
|
||||
backend._spawn()
|
||||
assert backend._proc is replacement
|
||||
replacement.kill.assert_not_called()
|
||||
# Once the captured owner is reaped, reuse of the healthy replacement
|
||||
# is allowed without starting or terminating another process.
|
||||
original.wait.side_effect = None
|
||||
original.wait.return_value = 0
|
||||
backend._spawn()
|
||||
assert backend._proc is replacement
|
||||
replacement.kill.assert_not_called()
|
||||
finally:
|
||||
original.wait.side_effect = None
|
||||
backend._proc = None
|
||||
backend.shutdown()
|
||||
|
||||
@@ -127,16 +127,29 @@ class Deadlines:
|
||||
|
||||
|
||||
def _base_execution_seconds(
|
||||
text: Optional[str], *, execution_device: Optional[str] = None
|
||||
text: Optional[str], *, execution_device: Optional[str] = None,
|
||||
under_provisioned: bool = False,
|
||||
) -> float:
|
||||
"""Delegate to model_manager's budget; fall back to its formula.
|
||||
|
||||
The lazy import keeps this module usable in a process that has no torch —
|
||||
the control plane schedules work it never executes.
|
||||
|
||||
``under_provisioned`` is the worker's own verdict that its card sits below
|
||||
the engine's declared VRAM floor (``ConnectedWorker.under_provisioned``).
|
||||
It floors the budget at what the same job would get on a CPU, because that
|
||||
is what a card paging to system RAM performs like (#1804). Derived by asking
|
||||
for the CPU budget rather than by probing VRAM here: this process is the
|
||||
control plane, and its hardware is not the worker's.
|
||||
"""
|
||||
target_device = str(execution_device or "cpu").lower()
|
||||
if target_device not in {"cpu", "cuda", "mps", "mlx", "directml", "rocm", "xpu"}:
|
||||
target_device = "cpu"
|
||||
if under_provisioned and target_device != "cpu":
|
||||
return max(
|
||||
_base_execution_seconds(text, execution_device=target_device),
|
||||
_base_execution_seconds(text, execution_device="cpu"),
|
||||
)
|
||||
try:
|
||||
from services import model_manager # noqa: PLC0415 — intentionally lazy
|
||||
|
||||
@@ -171,6 +184,7 @@ def for_task(
|
||||
model_downloaded: bool = True,
|
||||
input_seconds: float = 0.0,
|
||||
execution_device: Optional[str] = None,
|
||||
under_provisioned: bool = False,
|
||||
) -> Deadlines:
|
||||
"""Compute the deadlines for one attempt.
|
||||
|
||||
@@ -183,7 +197,8 @@ def for_task(
|
||||
multiplier, grace = _PROFILE[op]
|
||||
|
||||
execution = _base_execution_seconds(
|
||||
text, execution_device=execution_device
|
||||
text, execution_device=execution_device,
|
||||
under_provisioned=under_provisioned,
|
||||
) * multiplier
|
||||
# Media-length operations scale on duration, not characters.
|
||||
if input_seconds > 0:
|
||||
|
||||
@@ -32,6 +32,7 @@ import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from worker.deadlines import Deadlines
|
||||
from worker.clock import resolve
|
||||
from worker.errors import ErrorClass, WorkerError
|
||||
|
||||
@@ -224,6 +225,9 @@ class Attempt:
|
||||
stage: str = ""
|
||||
error: Optional[WorkerError] = None
|
||||
|
||||
# Snapshot the lease policy granted at dispatch, including after restart.
|
||||
deadlines: Optional[Deadlines] = None
|
||||
|
||||
def matches(self, *, session_epoch: Optional[int] = None) -> bool:
|
||||
"""Fence check: reject messages from a superseded session."""
|
||||
if session_epoch is None:
|
||||
|
||||
+50
-19
@@ -93,12 +93,11 @@ class ConnectedWorker:
|
||||
return "busy"
|
||||
return "ready"
|
||||
|
||||
def supports(self, engine: str, model_id: str, operation: str) -> bool:
|
||||
"""Can this worker run this work at all?
|
||||
def _capability_for(self, engine: str, model_id: str, operation: str):
|
||||
"""The advertised capability this task would actually be run by.
|
||||
|
||||
``supported`` alone is not enough — an engine whose weights are not on
|
||||
disk cannot start without a download, and one that is not installed
|
||||
cannot start at all. Both are capability mismatches, not failures.
|
||||
One selection rule, so the answers below cannot describe different
|
||||
capabilities of the same worker.
|
||||
"""
|
||||
for cap in self.record.capabilities:
|
||||
if cap.get("engine") != engine:
|
||||
@@ -107,23 +106,55 @@ class ConnectedWorker:
|
||||
continue
|
||||
if operation and operation not in (cap.get("operations") or [operation]):
|
||||
continue
|
||||
return bool(cap.get("supported")) and bool(cap.get("installed", True))
|
||||
return False
|
||||
return cap
|
||||
return None
|
||||
|
||||
def supports(self, engine: str, model_id: str, operation: str) -> bool:
|
||||
"""Can this worker run this work at all?
|
||||
|
||||
``supported`` alone is not enough — an engine whose weights are not on
|
||||
disk cannot start without a download, and one that is not installed
|
||||
cannot start at all. Both are capability mismatches, not failures.
|
||||
"""
|
||||
cap = self._capability_for(engine, model_id, operation)
|
||||
if cap is None:
|
||||
return False
|
||||
return bool(cap.get("supported")) and bool(cap.get("installed", True))
|
||||
|
||||
def execution_device(self, engine: str, model_id: str, operation: str) -> str:
|
||||
"""Device used by the exact capability selected for this task."""
|
||||
for cap in self.record.capabilities:
|
||||
if cap.get("engine") != engine:
|
||||
continue
|
||||
if model_id and cap.get("model_id") not in (model_id, "", None):
|
||||
continue
|
||||
if operation and operation not in (cap.get("operations") or [operation]):
|
||||
continue
|
||||
if cap.get("cpu_fallback"):
|
||||
return "cpu"
|
||||
backend = str(cap.get("backend") or "").lower()
|
||||
return backend if backend in _KNOWN_EXECUTION_DEVICES else "cpu"
|
||||
return "cpu"
|
||||
cap = self._capability_for(engine, model_id, operation)
|
||||
if cap is None:
|
||||
return "cpu"
|
||||
if cap.get("cpu_fallback"):
|
||||
return "cpu"
|
||||
backend = str(cap.get("backend") or "").lower()
|
||||
return backend if backend in _KNOWN_EXECUTION_DEVICES else "cpu"
|
||||
|
||||
def under_provisioned(self, engine: str, model_id: str, operation: str) -> bool:
|
||||
"""Is this worker's GPU below the engine's declared VRAM floor?
|
||||
|
||||
The remote half of #1804. A card under the floor pages to system RAM and
|
||||
renders slower than a CPU, so it must not be given the shorter
|
||||
accelerated deadline. Decided from the two figures the WORKER itself
|
||||
advertises (``free_memory_bytes`` / ``min_memory_bytes``, both set in
|
||||
``worker/capabilities.py``): the control plane's own VRAM says nothing
|
||||
about the machine that will run the job, so
|
||||
``engine_routing.under_provisioned_vram`` — which probes THIS host —
|
||||
cannot answer for a remote worker.
|
||||
|
||||
Same rules as that predicate otherwise: dedicated-VRAM devices only
|
||||
(unified memory is not a comparable pool), and a zero on either side
|
||||
means "unknown", never "too small".
|
||||
"""
|
||||
cap = self._capability_for(engine, model_id, operation)
|
||||
if cap is None or cap.get("cpu_fallback"):
|
||||
return False
|
||||
if str(cap.get("backend") or "").lower() not in ("cuda", "rocm"):
|
||||
return False
|
||||
floor = int(cap.get("min_memory_bytes") or 0)
|
||||
have = int(cap.get("free_memory_bytes") or 0)
|
||||
return floor > 0 and 0 < have < floor
|
||||
|
||||
def is_warm(self, engine: str, model_id: str) -> bool:
|
||||
return self.capacity.is_resident(engine, model_id)
|
||||
|
||||
@@ -652,7 +652,11 @@ class Scheduler:
|
||||
execution_device=worker.execution_device(
|
||||
task.engine, task.model_id, task.operation
|
||||
),
|
||||
under_provisioned=worker.under_provisioned(
|
||||
task.engine, task.model_id, task.operation
|
||||
),
|
||||
)
|
||||
attempt.deadlines = budget
|
||||
attempt.renew_lease(budget.accept_seconds, now=now)
|
||||
self._save(task, now=now)
|
||||
self._emit("assigned", task)
|
||||
@@ -1295,7 +1299,13 @@ class Scheduler:
|
||||
|
||||
def _budget_for(self, task: Task) -> deadline_policy.Deadlines:
|
||||
attempt = task.active_attempt
|
||||
if attempt is not None and attempt.deadlines is not None:
|
||||
return attempt.deadlines
|
||||
# A legacy attempt has no recorded device once its worker is absent.
|
||||
# Cover both configured device classes instead of assuming the shorter
|
||||
# CPU budget; for_task floors an under-provisioned GPU at max(CPU, GPU).
|
||||
worker = self.pool.get(attempt.worker_id) if attempt else None
|
||||
unknown_legacy_worker = attempt is not None and worker is None
|
||||
return deadline_policy.for_task(
|
||||
task.operation,
|
||||
text=task.params.get("text"),
|
||||
@@ -1303,7 +1313,13 @@ class Scheduler:
|
||||
input_seconds=float(task.params.get("input_seconds") or 0.0),
|
||||
execution_device=(
|
||||
worker.execution_device(task.engine, task.model_id, task.operation)
|
||||
if worker else None
|
||||
if worker else ("cuda" if unknown_legacy_worker else None)
|
||||
),
|
||||
under_provisioned=unknown_legacy_worker or bool(
|
||||
worker
|
||||
and worker.under_provisioned(
|
||||
task.engine, task.model_id, task.operation
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ from core.db import db_conn
|
||||
from core.path_security import UnsafePath, resolve_within, safe_filename
|
||||
from worker.clock import resolve
|
||||
from worker.errors import ErrorClass, WorkerError
|
||||
from worker.deadlines import Deadlines
|
||||
from worker.lifecycle import Attempt, AttemptState, PriorityClass, Task, TaskState
|
||||
|
||||
logger = logging.getLogger("omnivoice.worker")
|
||||
@@ -67,6 +68,8 @@ def _row_to_attempt(row) -> Attempt:
|
||||
state=AttemptState(row["state"]),
|
||||
created_at=float(row["created_at"]),
|
||||
)
|
||||
if row["deadlines_json"]:
|
||||
attempt.deadlines = Deadlines(**json.loads(row["deadlines_json"]))
|
||||
attempt.accepted_at = row["accepted_at"]
|
||||
attempt.started_at = row["started_at"]
|
||||
attempt.finished_at = row["finished_at"]
|
||||
@@ -635,12 +638,13 @@ def _upsert_attempts(conn, task: Task) -> None:
|
||||
"INSERT INTO remote_task_attempts "
|
||||
"(id, task_id, worker_id, session_epoch, attempt_number, state, progress, stage, "
|
||||
" error_json, created_at, accepted_at, started_at, finished_at, lease_expires_at, "
|
||||
" grace_expires_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||
" grace_expires_at, deadlines_json) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT(id) DO UPDATE SET state=excluded.state, progress=excluded.progress, "
|
||||
" stage=excluded.stage, error_json=excluded.error_json, accepted_at=excluded.accepted_at, "
|
||||
" started_at=excluded.started_at, finished_at=excluded.finished_at, "
|
||||
" lease_expires_at=excluded.lease_expires_at, grace_expires_at=excluded.grace_expires_at",
|
||||
" lease_expires_at=excluded.lease_expires_at, grace_expires_at=excluded.grace_expires_at, "
|
||||
" deadlines_json=excluded.deadlines_json",
|
||||
(
|
||||
attempt.attempt_id,
|
||||
attempt.task_id,
|
||||
@@ -657,6 +661,7 @@ def _upsert_attempts(conn, task: Task) -> None:
|
||||
attempt.finished_at,
|
||||
attempt.lease_expires_at,
|
||||
attempt.grace_expires_at,
|
||||
json.dumps(attempt.deadlines.to_dict()) if attempt.deadlines else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
"name": "omnivoice-studio-monorepo",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.4",
|
||||
"playwright": "^1.62.1",
|
||||
"playwright": "^1.63.0",
|
||||
"taze": "^19.17.2",
|
||||
"turbo": "^2.10.9",
|
||||
"turbo": "^2.10.12",
|
||||
"typescript": "^6.0.3",
|
||||
"wait-on": "^9.1.0",
|
||||
},
|
||||
@@ -83,7 +83,7 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
|
||||
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
|
||||
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.13", "", { "dependencies": { "@ai-sdk/provider": "3.0.2", "@ai-sdk/provider-utils": "4.0.5", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-g7nE4PFtngOZNZSy1lOPpkC+FAiHxqBJXqyRMEG7NUrEVZlz5goBdtHg1YgWRJIX776JTXAmbOI5JreAKVAsVA=="],
|
||||
|
||||
@@ -103,49 +103,49 @@
|
||||
|
||||
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
|
||||
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
|
||||
"@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
|
||||
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
|
||||
"@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||
"@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
|
||||
|
||||
"@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
|
||||
|
||||
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g=="],
|
||||
|
||||
"@codemirror/commands": ["@codemirror/commands@6.10.4", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg=="],
|
||||
"@codemirror/commands": ["@codemirror/commands@6.11.0", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA=="],
|
||||
|
||||
"@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="],
|
||||
|
||||
"@codemirror/lang-html": ["@codemirror/lang-html@6.4.11", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw=="],
|
||||
"@codemirror/lang-html": ["@codemirror/lang-html@6.4.12", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w=="],
|
||||
|
||||
"@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="],
|
||||
|
||||
@@ -159,19 +159,19 @@
|
||||
|
||||
"@codemirror/lint": ["@codemirror/lint@6.9.7", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="],
|
||||
|
||||
"@codemirror/state": ["@codemirror/state@6.7.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-Zbl9NyscLMZkfXPQnNAIIAFftidrA1UbcJEIMp24C0Bukc2I5T8wJS0wsXYsnDOqCFJUeJ1BITGNs5CqPDSmSg=="],
|
||||
"@codemirror/state": ["@codemirror/state@6.7.4", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-QhQIVRY+xHZDxwOSFrJ1eUMapJBUID3IdeAjf7dHO7zBUzSkyooHiodnalz5MG3iHzwixKMlAAyn7244y537EA=="],
|
||||
|
||||
"@codemirror/view": ["@codemirror/view@6.43.4", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-YImu23iyKfncJzT7sRy+rEqEhSc8RhOHqDxwy4WzXRKJwYm6iwf/9OJk5ctCAdZ6yi2ZqaGEvmf55fSVqMDrgg=="],
|
||||
"@codemirror/view": ["@codemirror/view@6.43.11", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-2+esucbQX6wB2JYi1eDvdCPFTA31BN8oSy6xCmk3G6CloV11yOvEjYk+gH7kLrP0MuHG94E8WDhjs5oMiu3+Wg=="],
|
||||
|
||||
"@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="],
|
||||
"@csstools/color-helpers": ["@csstools/color-helpers@6.1.1", "", {}, "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w=="],
|
||||
|
||||
"@csstools/css-calc": ["@csstools/css-calc@3.2.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w=="],
|
||||
"@csstools/css-calc": ["@csstools/css-calc@3.3.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ=="],
|
||||
|
||||
"@csstools/css-color-parser": ["@csstools/css-color-parser@4.1.0", "", { "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.2.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ=="],
|
||||
"@csstools/css-color-parser": ["@csstools/css-color-parser@4.2.2", "", { "dependencies": { "@csstools/color-helpers": "^6.1.1", "@csstools/css-calc": "^3.3.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg=="],
|
||||
|
||||
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="],
|
||||
|
||||
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.3", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg=="],
|
||||
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.12", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw=="],
|
||||
|
||||
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
|
||||
|
||||
@@ -181,7 +181,7 @@
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
|
||||
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="],
|
||||
|
||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||
|
||||
@@ -193,15 +193,15 @@
|
||||
|
||||
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="],
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.3", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q=="],
|
||||
|
||||
"@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="],
|
||||
"@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
|
||||
"@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
|
||||
|
||||
"@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/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
|
||||
|
||||
"@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/react-dom": ["@floating-ui/react-dom@2.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="],
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||
|
||||
@@ -221,7 +221,7 @@
|
||||
|
||||
"@hapi/pinpoint": ["@hapi/pinpoint@2.0.1", "", {}, "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q=="],
|
||||
|
||||
"@hapi/tlds": ["@hapi/tlds@1.1.6", "", {}, "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw=="],
|
||||
"@hapi/tlds": ["@hapi/tlds@1.1.7", "", {}, "sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA=="],
|
||||
|
||||
"@hapi/topo": ["@hapi/topo@6.0.2", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg=="],
|
||||
|
||||
@@ -231,17 +231,19 @@
|
||||
|
||||
"@henrygd/queue": ["@henrygd/queue@1.2.0", "", {}, "sha512-jW/BLSTpcvExDhqJGxtIPgGr2O0IFF8XUNDwEbfCfhrXT8a4xztQ9Lv6U/vbYzYC0xVWn+3zv6YnLUh3bEFUKA=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
||||
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
|
||||
|
||||
"@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
|
||||
|
||||
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||
|
||||
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||
|
||||
"@internationalized/date": ["@internationalized/date@3.12.2", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw=="],
|
||||
"@internationalized/date": ["@internationalized/date@3.12.4", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ=="],
|
||||
|
||||
"@internationalized/number": ["@internationalized/number@3.6.7", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg=="],
|
||||
"@internationalized/number": ["@internationalized/number@3.6.8", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-8UmMFia46DUt+k97zKd9fKWXcWHR+k8ae3eYzILETuT2KbIvLyOfac7zesw+sJdRAAZ7Q9pM1Mk22aXp2LD0Ig=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
@@ -249,13 +251,13 @@
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="],
|
||||
|
||||
"@lezer/css": ["@lezer/css@1.3.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q=="],
|
||||
"@lezer/css": ["@lezer/css@1.3.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g=="],
|
||||
|
||||
"@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="],
|
||||
|
||||
@@ -271,9 +273,9 @@
|
||||
|
||||
"@lezer/yaml": ["@lezer/yaml@1.0.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.4.0" } }, "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw=="],
|
||||
|
||||
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.3", "", {}, "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA=="],
|
||||
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.4", "", {}, "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
@@ -435,11 +437,11 @@
|
||||
|
||||
"@playwright/test": ["@playwright/test@1.62.1", "", { "dependencies": { "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" } }, "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ=="],
|
||||
|
||||
"@posthog/browser-common": ["@posthog/browser-common@0.5.0", "", { "dependencies": { "@posthog/core": "^1.47.0", "@posthog/types": "^1.402.2" } }, "sha512-8DaxVZS1bQPbA514RePurLNbYjei3P4jhnC206DwVv5XThmZM3QdlsXenI2ujE3pLbgQ79hYn9o1Kda8I3WK/Q=="],
|
||||
"@posthog/browser-common": ["@posthog/browser-common@0.5.2", "", { "dependencies": { "@posthog/core": "^1.48.11", "@posthog/types": "^1.405.3" } }, "sha512-8GvfEshFdeKIccuy3kpp6mDBxawQtRamMYRCwzy1r1ixLKQVLAGs3afhOf6r75yp59ZQBi5mtXQgUJ2Jz8eHuw=="],
|
||||
|
||||
"@posthog/core": ["@posthog/core@1.48.6", "", { "dependencies": { "@posthog/types": "^1.405.0" } }, "sha512-lvSO1nrxxakrAfB51fetHC29gSqdDtT+AyRrGlnc5nDSWhiBGtyqKNjDqsbnlTQoj14bAaWXS7RtzuGoTo5IsQ=="],
|
||||
"@posthog/core": ["@posthog/core@1.50.5", "", { "dependencies": { "@posthog/types": "^1.409.0" } }, "sha512-afEchuShDaVIoxAIj76kDZQ1DhfesDmgfVp+mtTzsA3wlc8DF5uoz8YjuTjnxOicWkpP5HCDqYidK/1kT125Cg=="],
|
||||
|
||||
"@posthog/types": ["@posthog/types@1.405.0", "", {}, "sha512-4rZ/taVXKQxs9Jrf7ZjlCRgrOSL69oKAgIWJQa5kRNJ6wll1UANbrJTSY+Su1e88LIG4zZVjKKKjyQHCkHdHcw=="],
|
||||
"@posthog/types": ["@posthog/types@1.409.0", "", {}, "sha512-239umoaZVb2GBaXeEyJpwFvjhrrChJH8NHCwiao23EBSu3NA6EN0MTMoaHSmMEf4yjiXEFFoYJA6FjJAXF5HGA=="],
|
||||
|
||||
"@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="],
|
||||
|
||||
@@ -519,35 +521,35 @@
|
||||
|
||||
"@replit/codemirror-css-color-picker": ["@replit/codemirror-css-color-picker@6.3.0", "", { "peerDependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-19biDANghUm7Fz7L1SNMIhK48tagaWuCOHj4oPPxc7hxPGkTVY2lU/jVZ8tsbTKQPVG7BO2CBDzs7CBwb20t4A=="],
|
||||
|
||||
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.5", "", { "os": "android", "cpu": "arm" }, "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA=="],
|
||||
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.7", "", { "os": "android", "cpu": "arm" }, "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.5", "", { "os": "android", "cpu": "arm64" }, "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig=="],
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.7", "", { "os": "android", "cpu": "arm64" }, "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww=="],
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A=="],
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw=="],
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.5", "", { "os": "linux", "cpu": "arm" }, "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg=="],
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.7", "", { "os": "linux", "cpu": "arm" }, "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA=="],
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA=="],
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA=="],
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ=="],
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ=="],
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.7", "", { "os": "linux", "cpu": "x64" }, "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA=="],
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.7", "", { "os": "linux", "cpu": "x64" }, "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.5", "", { "os": "none", "cpu": "arm64" }, "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw=="],
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.7", "", { "os": "none", "cpu": "arm64" }, "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw=="],
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw=="],
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.7", "", { "os": "win32", "cpu": "x64" }, "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||
|
||||
@@ -647,7 +649,7 @@
|
||||
|
||||
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.8", "", {}, "sha512-BfEvehNpOT75r5Ksc5xW6NZuXujTfb7nlSEyVu4XHG3gdxNg1KqXruWbDewXOUaUYIo4oRbSfkjIajz4MAT8tA=="],
|
||||
|
||||
"@tanstack/vue-virtual": ["@tanstack/vue-virtual@3.13.31", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "vue": "^2.7.0 || ^3.0.0" } }, "sha512-wZMEoSf852jQqaf3Ika1J7PiBae6341LNy/2CxmIyn0XKDQXMuK41wVX+xp6G0yx8jyR95Ef+Tdr13DK7mbJtQ=="],
|
||||
"@tanstack/vue-virtual": ["@tanstack/vue-virtual@3.13.36", "", { "dependencies": { "@tanstack/virtual-core": "3.17.8" }, "peerDependencies": { "vue": "^2.7.0 || ^3.0.0" } }, "sha512-gKpExv4RbB9luVG+SucTXoqPZv/gzu/Yvz6BNO+8kpNxJ2x+I/ulryzl5W9BRciahZGp5Tls3Dp5XP1ztVGbMw=="],
|
||||
|
||||
"@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="],
|
||||
|
||||
@@ -691,17 +693,17 @@
|
||||
|
||||
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
|
||||
|
||||
"@turbo/darwin-64": ["@turbo/darwin-64@2.10.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-v3R+1R/Ysozyo+p7Ri8MCIbndOvYt3DgPFrGLhrhQHfvyvbxyH3WyJj+A/2JTNmNleuAlh3JUyCV0iSVHIONTA=="],
|
||||
"@turbo/darwin-64": ["@turbo/darwin-64@2.10.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-9nKgKoF6ZOUsM+or0OtNf+TTJSfGvDNP7ZFv/ZGWVwOSCkumyctQiTeHwB4UNljHTnC41AqylgbunLDHoccNrA=="],
|
||||
|
||||
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-R0a0CvGAeYYsBgPIgFNB3agGXh6qukjduNhFlwVVX1Ss2IdBJLXmgjytNGmo084bLKS0B6UdLRwKhXMHKKaObQ=="],
|
||||
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-H4Elb1jqTZVeIC9bbcNwjSzemZ6RegoTOVHeuV5Osirt2Z8UguTyisMEkvZjPVZgMeN9J4ERZBFad40tFnkb7w=="],
|
||||
|
||||
"@turbo/linux-64": ["@turbo/linux-64@2.10.11", "", { "os": [ "linux", "android", ], "cpu": "x64" }, "sha512-dGlY2vg7jpsLjGS1bf9sD/cw1sGMAYbeHGQKGRFxd5Zaj+Ufbj8cNXl/vIit8ueCU97zhL/znn60ZV5iSfZAxw=="],
|
||||
"@turbo/linux-64": ["@turbo/linux-64@2.10.12", "", { "os": [ "linux", "android", ], "cpu": "x64" }, "sha512-lr7KIotukvjZwEXiFSYAeOH3BWzjFVBbSzTbv0fuGFsNukYyH0+g1hB5ecqnJkgkYU+KHEMG1edOhnjiKON1wQ=="],
|
||||
|
||||
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.10.11", "", { "os": [ "linux", "android", ], "cpu": "arm64" }, "sha512-eSP9+jjsSCBs2x0QpJZlp49dSD1EIMwXH7PsMIhdWk7w6IThhuKJ+jL95JQ2IW7tRjRmdh8gKcKQtrHLD5R5lA=="],
|
||||
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.10.12", "", { "os": [ "linux", "android", ], "cpu": "arm64" }, "sha512-f0pZDTtvzB5SuNwuXBaKbZHUCMCukgc8nMlHEuvLmj91Fzec+MEbr3cAvGNor5htEDqZnO6Lxt9N/GPI/77oGA=="],
|
||||
|
||||
"@turbo/windows-64": ["@turbo/windows-64@2.10.11", "", { "os": "win32", "cpu": "x64" }, "sha512-4aD7edogJ8arK8DOyvjTI2KKwmchD5M/WM4BZgidrtFf7aSgn8Ce2rJnDwmriw980c2cKlHnhXtlGy38VtKB8g=="],
|
||||
"@turbo/windows-64": ["@turbo/windows-64@2.10.12", "", { "os": "win32", "cpu": "x64" }, "sha512-SDOueJRjS/QcykWf2KCRtTLmIl5YMKsLbXkXQGhDwcTXvKXZiS5ih5lBl/gkwZIpYFjqA/rAlfMzlAFcVHNe0g=="],
|
||||
|
||||
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.10.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-m8tJkIrTrbQ9O1uHxV0GUq623Zg1678xGna8eMsy4KbygUX/wVFgdZDYV/AxyoyaW/U7nyKoBvaDVwm22p9xqA=="],
|
||||
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.10.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-0i0mVUa4kKk+/B3RwEwPMf9CB+T7ul56hn5FFHNA4VUNTOoLBEd6aNf3FaKfCatDNZ6cicCEf6if9QUTVyzzcA=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||
|
||||
@@ -715,11 +717,11 @@
|
||||
|
||||
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/har-format": ["@types/har-format@1.2.16", "", {}, "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A=="],
|
||||
|
||||
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
||||
"@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
@@ -727,7 +729,7 @@
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/node": ["@types/node@24.13.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="],
|
||||
"@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
|
||||
|
||||
@@ -739,9 +741,9 @@
|
||||
|
||||
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.2", "", {}, "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA=="],
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="],
|
||||
|
||||
"@unhead/vue": ["@unhead/vue@2.1.15", "", { "dependencies": { "hookable": "^6.0.1", "unhead": "2.1.15" }, "peerDependencies": { "vue": ">=3.5.18" } }, "sha512-SSByXfEjhzPn8gXdEdgpYqpLMPSkLUH2HVE0GxZfOtNsJ0GgOHQs0g9T67ZZ1z0kTELLKdtOtYrzrbv9+ffF7g=="],
|
||||
"@unhead/vue": ["@unhead/vue@2.1.17", "", { "dependencies": { "hookable": "^6.0.1", "unhead": "2.1.17" }, "peerDependencies": { "vue": ">=3.5.18" } }, "sha512-pnC8x9HLV3qQXdvWfylUEU25uhfCAy3ly9nmpQz84j9py818DRfU8jOsQ5wjdWtxyU1vX/fW2udfm4jtxUK8Bg=="],
|
||||
|
||||
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
|
||||
|
||||
@@ -761,23 +763,23 @@
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="],
|
||||
|
||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.41", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.41", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg=="],
|
||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.42", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.42", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ=="],
|
||||
|
||||
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.41", "", { "dependencies": { "@vue/compiler-core": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw=="],
|
||||
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.42", "", { "dependencies": { "@vue/compiler-core": "3.5.42", "@vue/shared": "3.5.42" } }, "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA=="],
|
||||
|
||||
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.41", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/compiler-core": "3.5.41", "@vue/compiler-dom": "3.5.41", "@vue/compiler-ssr": "3.5.41", "@vue/shared": "3.5.41", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.19", "source-map-js": "^1.2.1" } }, "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ=="],
|
||||
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.42", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/compiler-core": "3.5.42", "@vue/compiler-dom": "3.5.42", "@vue/compiler-ssr": "3.5.42", "@vue/shared": "3.5.42", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.19", "source-map-js": "^1.2.1" } }, "sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw=="],
|
||||
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.41", "", { "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A=="],
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.42", "", { "dependencies": { "@vue/compiler-dom": "3.5.42", "@vue/shared": "3.5.42" } }, "sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA=="],
|
||||
|
||||
"@vue/reactivity": ["@vue/reactivity@3.5.41", "", { "dependencies": { "@vue/shared": "3.5.41" } }, "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA=="],
|
||||
"@vue/reactivity": ["@vue/reactivity@3.5.42", "", { "dependencies": { "@vue/shared": "3.5.42" } }, "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ=="],
|
||||
|
||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.41", "", { "dependencies": { "@vue/reactivity": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg=="],
|
||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.42", "", { "dependencies": { "@vue/reactivity": "3.5.42", "@vue/shared": "3.5.42" } }, "sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA=="],
|
||||
|
||||
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.41", "", { "dependencies": { "@vue/reactivity": "3.5.41", "@vue/runtime-core": "3.5.41", "@vue/shared": "3.5.41", "csstype": "^3.2.3" } }, "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw=="],
|
||||
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.42", "", { "dependencies": { "@vue/reactivity": "3.5.42", "@vue/runtime-core": "3.5.42", "@vue/shared": "3.5.42", "csstype": "^3.2.3" } }, "sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g=="],
|
||||
|
||||
"@vue/server-renderer": ["@vue/server-renderer@3.5.41", "", { "dependencies": { "@vue/compiler-ssr": "3.5.41", "@vue/runtime-dom": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ=="],
|
||||
"@vue/server-renderer": ["@vue/server-renderer@3.5.42", "", { "dependencies": { "@vue/compiler-ssr": "3.5.42", "@vue/runtime-dom": "3.5.42", "@vue/shared": "3.5.42" } }, "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ=="],
|
||||
|
||||
"@vue/shared": ["@vue/shared@3.5.41", "", {}, "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA=="],
|
||||
"@vue/shared": ["@vue/shared@3.5.42", "", {}, "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g=="],
|
||||
|
||||
"@vueuse/core": ["@vueuse/core@13.9.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "13.9.0", "@vueuse/shared": "13.9.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA=="],
|
||||
|
||||
@@ -787,7 +789,7 @@
|
||||
|
||||
"@vueuse/shared": ["@vueuse/shared@13.9.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
"acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
@@ -795,7 +797,7 @@
|
||||
|
||||
"ai": ["ai@6.0.33", "", { "dependencies": { "@ai-sdk/gateway": "3.0.13", "@ai-sdk/provider": "3.0.2", "@ai-sdk/provider-utils": "4.0.5", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bVokbmy2E2QF6Efl+5hOJx5MRWoacZ/CZY/y1E+VcewknvGlgaiCzMu8Xgddz6ArFJjiMFNUPHKxAhIePE4rmg=="],
|
||||
|
||||
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
|
||||
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
@@ -809,19 +811,19 @@
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"axios": ["axios@1.19.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw=="],
|
||||
"axios": ["axios@1.20.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg=="],
|
||||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.17", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA=="],
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ=="],
|
||||
|
||||
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
|
||||
"brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||
"browserslist": ["browserslist@4.28.9", "", { "dependencies": { "baseline-browser-mapping": "^2.11.20", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.420", "node-releases": "^2.0.54", "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" } }, "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg=="],
|
||||
|
||||
"cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="],
|
||||
|
||||
@@ -829,7 +831,7 @@
|
||||
|
||||
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001787", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="],
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
@@ -863,7 +865,7 @@
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="],
|
||||
"core-js": ["core-js@3.50.0", "", {}, "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw=="],
|
||||
|
||||
"country-flag-icons": ["country-flag-icons@1.6.20", "", {}, "sha512-py8JiEKzjhYw6HPJ0L7SxLgCYim36UPRTZX43/kqGueUCZLSvnrqAiwW8HtQibur7mdkFQUkjOgdK+o/9FBtaw=="],
|
||||
|
||||
@@ -913,7 +915,7 @@
|
||||
|
||||
"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=="],
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.422", "", {}, "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
@@ -925,9 +927,9 @@
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="],
|
||||
"es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
@@ -955,9 +957,9 @@
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
|
||||
"eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="],
|
||||
|
||||
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
||||
"expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
@@ -971,7 +973,7 @@
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="],
|
||||
"fflate": ["fflate@0.4.9", "", {}, "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
|
||||
@@ -979,7 +981,7 @@
|
||||
|
||||
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
||||
|
||||
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
|
||||
"flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="],
|
||||
|
||||
"focus-trap": ["focus-trap@7.8.0", "", { "dependencies": { "tabbable": "^6.4.0" } }, "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA=="],
|
||||
|
||||
@@ -989,7 +991,7 @@
|
||||
|
||||
"formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
@@ -1017,7 +1019,7 @@
|
||||
|
||||
"globals": ["globals@17.11.0", "", {}, "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw=="],
|
||||
|
||||
"goober": ["goober@2.1.18", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw=="],
|
||||
"goober": ["goober@2.1.19", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
@@ -1071,7 +1073,7 @@
|
||||
|
||||
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||
|
||||
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
|
||||
"highlight.js": ["highlight.js@11.12.0", "", {}, "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg=="],
|
||||
|
||||
"hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="],
|
||||
|
||||
@@ -1089,7 +1091,7 @@
|
||||
|
||||
"i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="],
|
||||
|
||||
"identifier-regex": ["identifier-regex@1.0.1", "", { "dependencies": { "reserved-identifiers": "^1.0.0" } }, "sha512-ZrYyM0sozNPZlvBvE7Oq9Bn44n0qKGrYu5sQ0JzMUnjIhpgWYE2JB6aBoFwEYdPjqj7jPyxXTMJiHDOxDfd8yw=="],
|
||||
"identifier-regex": ["identifier-regex@1.1.0", "", { "dependencies": { "reserved-identifiers": "^1.0.0" } }, "sha512-SLX4H/vtcYlYnL7XqnuJKHU7Z8517TgsW9nmQiGOgMCjQ8V/deLYu6bEmbGoXe7WMMhc9+EUGyFFneHja8KabA=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
@@ -1105,7 +1107,7 @@
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-identifier": ["is-identifier@1.0.1", "", { "dependencies": { "identifier-regex": "^1.0.0", "super-regex": "^1.0.0" } }, "sha512-HQ5v4rEJ7REUV54bCd2l5FaD299SGDEn2UPoVXaTHAyGviLq2menVUD2udi3trQ32uvB6LdAh/0ck2EuizrtpA=="],
|
||||
"is-identifier": ["is-identifier@1.1.0", "", { "dependencies": { "identifier-regex": "^1.1.0", "super-regex": "^1.1.0" } }, "sha512-NhOds0mDx9lJu+1lBRO0xbwFo5nobA7GCk/0e5xjr6+6XugX985+0OyGX35BNrTkPAsdLcIKg02HUQJOK8D8kw=="],
|
||||
|
||||
"is-obj": ["is-obj@3.0.0", "", {}, "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ=="],
|
||||
|
||||
@@ -1119,7 +1121,7 @@
|
||||
|
||||
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
||||
"joi": ["joi@18.2.5", "", { "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-+gEA7rLfaNWx9JzawWPrPetSZwT16NUqHtECDgjyAJreXcs4TM7tx2Pa+VVJJK0YHM83ybrVdaT6UekHH50FJQ=="],
|
||||
"joi": ["joi@18.2.8", "", { "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-G2TX62h58ZHuwqetJgP2F4ualakqAmZtBYe3jWen7gxQRw5xApX6crnFtuB91WC0c3ESBnva+kGSnb3+6pIQDQ=="],
|
||||
|
||||
"js-base64": ["js-base64@3.9.3", "", {}, "sha512-uwYQp+VJ38FVvtim6qNbit6e9uT6dwWQ4Y1+H9TxhW5hcHjpHwoxlR0nMpqUmIFOmu4VqMxwdJA88gIVuZJQ/g=="],
|
||||
|
||||
@@ -1179,7 +1181,7 @@
|
||||
|
||||
"lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="],
|
||||
|
||||
"lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="],
|
||||
"lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
|
||||
|
||||
"lucide-react": ["lucide-react@1.33.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-MTRwMy0ZlL8Ur/vOAiJ9XGHE+kFPC7brq6MxAm0GiGXEBj0qy0jA/pG4N675oSzciO/UCdX8T+5yUQdmDeTLxg=="],
|
||||
|
||||
@@ -1219,7 +1221,7 @@
|
||||
|
||||
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||
|
||||
"microdiff": ["microdiff@1.5.0", "", {}, "sha512-Drq+/THMvDdzRYrK0oxJmOKiC24ayUV8ahrt8l3oRK51PWt6gdtrIGrlIH3pT/lFh1z93FbAcidtsHcWbnRz8Q=="],
|
||||
"microdiff": ["microdiff@1.6.0", "", {}, "sha512-w7JWt8Bno6I8h0rEqlxr4lNG4UbT1FVtWo42wWosIiaJ+rP3pXh7shCYDnyqBrYx36LJ1CZ64p8A62aVp0sJpQ=="],
|
||||
|
||||
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
|
||||
|
||||
@@ -1285,7 +1287,7 @@
|
||||
|
||||
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
|
||||
|
||||
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
"minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
@@ -1299,7 +1301,7 @@
|
||||
|
||||
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
|
||||
"node-releases": ["node-releases@2.0.54", "", {}, "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ=="],
|
||||
|
||||
"obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="],
|
||||
|
||||
@@ -1343,27 +1345,27 @@
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||
|
||||
"playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="],
|
||||
"playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="],
|
||||
|
||||
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
|
||||
|
||||
"pnpm-workspace-yaml": ["pnpm-workspace-yaml@1.8.0", "", { "dependencies": { "yaml": "^2.9.0" } }, "sha512-rqGldoFOzjalWzlPtzAi/RcjAyep+Pjl4QUKJ6/oEZXzhVAX79GS/i8gjhQT715g75kTkyfHjL9eBPGZ9Co++Q=="],
|
||||
"pnpm-workspace-yaml": ["pnpm-workspace-yaml@1.9.1", "", { "dependencies": { "yaml": "^2.9.0" } }, "sha512-Xj/T2X6DNAWbtk/9UQ0/TJRswltiHZevmcMjqoL7WrpJi67lu3qaslU4+I+zJ8wtwqmuvAE0KkB8rbv2ZdogBg=="],
|
||||
|
||||
"postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
|
||||
"postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
|
||||
|
||||
"posthog-js": ["posthog-js@1.418.6", "", { "dependencies": { "@posthog/browser-common": "^0.5.0", "@posthog/core": "^1.48.6", "@posthog/types": "^1.405.0", "core-js": "^3.49.0", "dompurify": "^3.4.13", "fflate": "^0.4.8", "preact": "^10.29.3", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.3.0", "web-vitals-soft-navs": "npm:web-vitals@6.0.0" } }, "sha512-s1+5sPSOElZ8twPNRQytBhY3fOp+rOkf6tZzS5xSZlNZG7UE/jRWcI0zRqykhMaeJOVwSABMuaeaap7rmX82Pg=="],
|
||||
|
||||
"preact": ["preact@10.29.7", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q=="],
|
||||
"preact": ["preact@10.29.8", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q=="],
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
|
||||
|
||||
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
|
||||
"pretty-ms": ["pretty-ms@9.3.1", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA=="],
|
||||
|
||||
"property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
|
||||
|
||||
@@ -1431,7 +1433,7 @@
|
||||
|
||||
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
||||
|
||||
"rolldown": ["rolldown@1.2.5", "", { "dependencies": { "@oxc-project/types": "=0.146.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.5", "@rolldown/binding-android-arm64": "1.2.5", "@rolldown/binding-darwin-arm64": "1.2.5", "@rolldown/binding-darwin-x64": "1.2.5", "@rolldown/binding-freebsd-x64": "1.2.5", "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", "@rolldown/binding-linux-arm64-gnu": "1.2.5", "@rolldown/binding-linux-arm64-musl": "1.2.5", "@rolldown/binding-linux-ppc64-gnu": "1.2.5", "@rolldown/binding-linux-s390x-gnu": "1.2.5", "@rolldown/binding-linux-x64-gnu": "1.2.5", "@rolldown/binding-linux-x64-musl": "1.2.5", "@rolldown/binding-openharmony-arm64": "1.2.5", "@rolldown/binding-win32-arm64-msvc": "1.2.5", "@rolldown/binding-win32-x64-msvc": "1.2.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA=="],
|
||||
"rolldown": ["rolldown@1.2.7", "", { "dependencies": { "@oxc-project/types": "=0.148.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.7", "@rolldown/binding-android-arm64": "1.2.7", "@rolldown/binding-darwin-arm64": "1.2.7", "@rolldown/binding-darwin-x64": "1.2.7", "@rolldown/binding-freebsd-x64": "1.2.7", "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", "@rolldown/binding-linux-arm64-gnu": "1.2.7", "@rolldown/binding-linux-arm64-musl": "1.2.7", "@rolldown/binding-linux-ppc64-gnu": "1.2.7", "@rolldown/binding-linux-s390x-gnu": "1.2.7", "@rolldown/binding-linux-x64-gnu": "1.2.7", "@rolldown/binding-linux-x64-musl": "1.2.7", "@rolldown/binding-openharmony-arm64": "1.2.7", "@rolldown/binding-win32-arm64-msvc": "1.2.7", "@rolldown/binding-win32-x64-msvc": "1.2.7" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig=="],
|
||||
|
||||
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
|
||||
|
||||
@@ -1463,7 +1465,7 @@
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
|
||||
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
|
||||
|
||||
"string-byte-length": ["string-byte-length@3.0.1", "", {}, "sha512-yJ8vP0HMwZ54CcA8S8mKoXbkezpZHANFtmafFo8lGxZThCQcAwRHjdFabuSLgOzxj9OFJcmssmiAvmcOK4O2Hw=="],
|
||||
|
||||
@@ -1507,19 +1509,19 @@
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
|
||||
"tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
|
||||
"tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="],
|
||||
|
||||
"tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="],
|
||||
"tldts": ["tldts@7.4.11", "", { "dependencies": { "tldts-core": "^7.4.11" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw=="],
|
||||
|
||||
"tldts-core": ["tldts-core@7.0.30", "", {}, "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q=="],
|
||||
"tldts-core": ["tldts-core@7.4.11", "", {}, "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg=="],
|
||||
|
||||
"tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
|
||||
"tough-cookie": ["tough-cookie@6.0.2", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA=="],
|
||||
|
||||
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
|
||||
|
||||
@@ -1533,29 +1535,29 @@
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"turbo": ["turbo@2.10.11", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.11", "@turbo/darwin-arm64": "2.10.11", "@turbo/linux-64": "2.10.11", "@turbo/linux-arm64": "2.10.11", "@turbo/windows-64": "2.10.11", "@turbo/windows-arm64": "2.10.11" }, "bin": { "turbo": "bin/turbo" } }, "sha512-yQfwQVoRXwOuyX1LxiJFBFNg6VfuYh+/RyZLd82+isgyLkBXw3S5XRRzvcck1FAjSCG5sVyLd+O1eDMvYa3J7g=="],
|
||||
"turbo": ["turbo@2.10.12", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.12", "@turbo/darwin-arm64": "2.10.12", "@turbo/linux-64": "2.10.12", "@turbo/linux-arm64": "2.10.12", "@turbo/windows-64": "2.10.12", "@turbo/windows-arm64": "2.10.12" }, "bin": { "turbo": "bin/turbo" } }, "sha512-AswgMPnpOoaVZHrrSBejETzEbuIA69OVGwfkHwfrY0A23VjWXBANzgq9+OymWOHAIArB7D1+1z498WY8fGg1Jw=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="],
|
||||
"type-fest": ["type-fest@5.9.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw=="],
|
||||
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
|
||||
|
||||
"unbash": ["unbash@4.0.10", "", {}, "sha512-b7zoBQvpWp0vuN5q2vK2RRBR2SvuruQAs50DApdDveBSn3eSYd84IaHodFqQIMlvY9K2VnyBUEXgwOBuGU9GBg=="],
|
||||
"unbash": ["unbash@4.0.11", "", {}, "sha512-FoSOKV7NEofQSkAefMVHam4ZPKYMxjAydxiV72UFEDNV/YofxjGfiZ2A9pZjdL/lRJzTjcu4PABo1JYJX8N5iQ=="],
|
||||
|
||||
"unconfig": ["unconfig@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "defu": "^6.1.4", "jiti": "^2.6.1", "quansync": "^1.0.0", "unconfig-core": "7.5.0" } }, "sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA=="],
|
||||
|
||||
"unconfig-core": ["unconfig-core@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w=="],
|
||||
|
||||
"undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
|
||||
"undici": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"unhead": ["unhead@2.1.15", "", { "dependencies": { "hookable": "^6.0.1" } }, "sha512-MCt5T90mCWyr3Z6pUCdM9lVRXoMoVBlL7z7U4CYVIiaDiuzad/UCfLuMqz5MeNmpZUgoBCQnrucJimU7EZR+XA=="],
|
||||
"unhead": ["unhead@2.1.17", "", { "dependencies": { "hookable": "^6.0.1" } }, "sha512-HLMKXOszRhAPBrr6VlqCeVeJq2kbC4kXwzGLEZvvojPLWNYTJw22xG7Bfwhsvs31+IBet3Wl8ADg9dwYdyphfQ=="],
|
||||
|
||||
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
||||
|
||||
@@ -1571,7 +1573,7 @@
|
||||
|
||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||
|
||||
"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=="],
|
||||
"update-browserslist-db": ["update-browserslist-db@1.3.2", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
@@ -1593,9 +1595,9 @@
|
||||
|
||||
"vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="],
|
||||
|
||||
"vue": ["vue@3.5.41", "", { "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/compiler-sfc": "3.5.41", "@vue/runtime-dom": "3.5.41", "@vue/server-renderer": "3.5.41", "@vue/shared": "3.5.41" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg=="],
|
||||
"vue": ["vue@3.5.42", "", { "dependencies": { "@vue/compiler-dom": "3.5.42", "@vue/compiler-sfc": "3.5.42", "@vue/runtime-dom": "3.5.42", "@vue/server-renderer": "3.5.42", "@vue/shared": "3.5.42" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA=="],
|
||||
|
||||
"vue-component-type-helpers": ["vue-component-type-helpers@3.3.6", "", {}, "sha512-FkljacAwJ9BUoSUdpFe3VDy0sGigNlTH9+2zcXUWmZOjN8swiCkl3t48wOJun0OsUd2cEIda1l04tsxMiKIIrQ=="],
|
||||
"vue-component-type-helpers": ["vue-component-type-helpers@3.3.11", "", {}, "sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA=="],
|
||||
|
||||
"vue-demi": ["vue-demi@0.14.10", "", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
|
||||
|
||||
@@ -1651,7 +1653,7 @@
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
"zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="],
|
||||
|
||||
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
|
||||
|
||||
@@ -1663,16 +1665,16 @@
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@floating-ui/core/@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
|
||||
"@floating-ui/core/@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
|
||||
|
||||
"@floating-ui/dom/@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
|
||||
"@floating-ui/dom/@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
|
||||
|
||||
"@floating-ui/vue/@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
|
||||
"@floating-ui/vue/@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
|
||||
|
||||
"@playwright/test/playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="],
|
||||
|
||||
"@scalar/api-client/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
|
||||
|
||||
"@scalar/api-client/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"@scalar/api-reference/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
|
||||
|
||||
"@scalar/icons/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
@@ -1681,8 +1683,6 @@
|
||||
|
||||
"@scalar/types/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
|
||||
|
||||
"@scalar/types/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"@scalar/use-hooks/tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
|
||||
|
||||
"@tailwindcss/node/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=="],
|
||||
@@ -1699,61 +1699,45 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@tanstack/vue-virtual/@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="],
|
||||
|
||||
"@tauri-apps/plugin-process/@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
|
||||
|
||||
"@tauri-apps/plugin-updater/@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
|
||||
|
||||
"@tauri-apps/plugin-window-state/@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
|
||||
|
||||
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
|
||||
|
||||
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
||||
|
||||
"@vue/compiler-core/@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
|
||||
|
||||
"@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"@vue/compiler-sfc/@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
|
||||
|
||||
"@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"es-set-tostringtag/hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
|
||||
|
||||
"get-intrinsic/hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
|
||||
|
||||
"hast-util-from-html/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"knip/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
"lowlight/highlight.js": ["highlight.js@11.11.2", "", {}, "sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ=="],
|
||||
|
||||
"make-asynchronous/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
|
||||
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"playwright/playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="],
|
||||
|
||||
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
||||
|
||||
"qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
|
||||
|
||||
"radix-vue/@floating-ui/vue": ["@floating-ui/vue@1.1.11", "", { "dependencies": { "@floating-ui/dom": "^1.7.6", "@floating-ui/utils": "^0.2.11", "vue-demi": ">=0.13.0" } }, "sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw=="],
|
||||
|
||||
"radix-vue/@vueuse/core": ["@vueuse/core@10.11.1", "", { "dependencies": { "@types/web-bluetooth": "^0.0.20", "@vueuse/metadata": "10.11.1", "@vueuse/shared": "10.11.1", "vue-demi": ">=0.14.8" } }, "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww=="],
|
||||
|
||||
"radix-vue/@vueuse/shared": ["@vueuse/shared@10.11.1", "", { "dependencies": { "vue-demi": ">=0.14.8" } }, "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA=="],
|
||||
|
||||
"radix-vue/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
|
||||
|
||||
"react-i18next/@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
|
||||
"rolldown/@oxc-project/types": ["@oxc-project/types@0.148.0", "", {}, "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A=="],
|
||||
|
||||
"rolldown/@oxc-project/types": ["@oxc-project/types@0.146.0", "", {}, "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA=="],
|
||||
|
||||
"tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
"@playwright/test/playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
@@ -1777,10 +1761,6 @@
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"@vue/compiler-core/@babel/parser/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
|
||||
|
||||
"@vue/compiler-sfc/@babel/parser/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
|
||||
|
||||
"hast-util-from-html/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
"hast-util-raw/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
@@ -1793,18 +1773,12 @@
|
||||
|
||||
"qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
|
||||
|
||||
"radix-vue/@floating-ui/vue/@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
|
||||
|
||||
"radix-vue/@vueuse/core/@types/web-bluetooth": ["@types/web-bluetooth@0.0.20", "", {}, "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow=="],
|
||||
|
||||
"radix-vue/@vueuse/core/@vueuse/metadata": ["@vueuse/metadata@10.11.1", "", {}, "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw=="],
|
||||
|
||||
"@vue/compiler-core/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@vue/compiler-core/@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@vue/compiler-sfc/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@vue/compiler-sfc/@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||
|
||||
"qrcode/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
@@ -147,3 +147,12 @@ There's no "revert update" flow for clients — they'll only see a *newer* versi
|
||||
3. Clients auto-update to the "new" v0.2.1 which is actually the old code.
|
||||
|
||||
Ugly but it works. Better plan: test with Option B above before publishing the draft.
|
||||
|
||||
## Retrying a partially published build
|
||||
|
||||
Use GitHub Actions **Re-run failed jobs** for the same release run. On retries,
|
||||
the workflow removes only the current version's installers for that job's target
|
||||
before Tauri uploads them again. A macOS retry also replaces that architecture's
|
||||
versionless updater archive. Other versions, sibling platforms, and updater
|
||||
manifests remain intact. Inventory or deletion permission/network failures stop
|
||||
the job instead of hiding an upload collision.
|
||||
|
||||
@@ -88,3 +88,13 @@ sr = model.sample_rate # 22050
|
||||
- ✅ **Sidecar logic unit-tested** (`tests/test_confucius4_sidecar.py`):
|
||||
language normalization, tensor→PCM (mono/stereo/clip), config-path
|
||||
resolution, clone sys.path injection, wire framing, synthesize dispatch.
|
||||
|
||||
## Accelerator routing
|
||||
|
||||
The sidecar passes a runtime-available CUDA/ROCm, XPU, or registered NPU
|
||||
through upstream's device-aware model loading. The engine venv needs a matching
|
||||
PyTorch/vendor runtime, also noted in the catalogue install hint. XPU/NPU selection is covered by mocked loader and routing
|
||||
tests; this change does not certify synthesis on physical XPU/NPU hardware.
|
||||
MPS keeps the existing CPU fallback described in the validation record above.
|
||||
If modern accelerator detection or the legacy CUDA probe raises, loading falls
|
||||
back to CPU instead of aborting before model construction.
|
||||
|
||||
@@ -115,3 +115,9 @@ dots.tts runs in a dedicated sidecar venv (it pins `transformers==4.57`,
|
||||
which conflicts with the parent's `transformers>=5.3`). For why that adds
|
||||
disk and how uv keeps the cost down, see
|
||||
[Engine venvs & disk usage](disk-usage.md).
|
||||
|
||||
The upstream runtime selects CUDA or CPU internally. Automatic precision follows
|
||||
that selection: bfloat16 on CUDA, float32 otherwise, including XPU/NPU/MPS hosts
|
||||
where this runtime executes on CPU. `OMNIVOICE_DOTS_TTS_PRECISION` remains an
|
||||
explicit override. If the CUDA availability probe raises, the automatic precision
|
||||
default stays float32; upstream remains responsible for its device selection.
|
||||
|
||||
@@ -23,10 +23,12 @@ interpreter, so MOSS runs behind
|
||||
8 GB GPUs when quantized; the bf16 Transformers path used here is ~16 GB
|
||||
of weights, so a 16 GB+ GPU is the realistic CUDA target. It also runs on
|
||||
**CPU** (fp32) — correct but slow.
|
||||
- **Device:** CUDA when present, else CPU. **There is no MPS path** —
|
||||
upstream documents only CUDA/CPU and the custom modelling code is
|
||||
untested on Apple Silicon, so VoiceStudio never routes MOSS to MPS. On a
|
||||
Mac it runs on CPU.
|
||||
- **Device:** the sidecar uses a runtime-available PyTorch CUDA/ROCm, XPU,
|
||||
or registered NPU backend, otherwise CPU. The isolated engine venv needs the
|
||||
matching torch/vendor integration. A failed accelerator probe falls back to
|
||||
CPU, including in older venvs without the unified accelerator API. MPS still uses CPU. XPU/NPU routing is
|
||||
covered by mocked loader tests; physical-device synthesis has not been
|
||||
validated by this change.
|
||||
|
||||
## Install
|
||||
|
||||
|
||||
@@ -26,7 +26,11 @@ quantized native binary with a much smaller memory footprint.
|
||||
[#1222](https://github.com/debpalash/VoiceStudio/issues/1222)) the driver
|
||||
pages to system RAM and a render that should take seconds runs for minutes
|
||||
until the compute budget kills it. The UI warns before you wait; nothing
|
||||
hard-blocks, since short inputs can still fit.
|
||||
hard-blocks, since short inputs can still fit. A CUDA or ROCm card below the
|
||||
floor is also budgeted as the CPU-class hardware it performs like — the longer
|
||||
`OMNIVOICE_CPU_GENERATE_TIMEOUT_S` (600 s), not the accelerated 300 s
|
||||
([#1804](https://github.com/debpalash/VoiceStudio/issues/1804)). Apple Silicon
|
||||
is excluded: unified memory has no dedicated pool to compare against.
|
||||
- No extra install — the model ships with the app and downloads its weights
|
||||
on first use (see [downloading-models.md](../downloading-models.md)).
|
||||
|
||||
@@ -88,9 +92,10 @@ The env var overrides the persisted UI choice.
|
||||
is ignored. Design is trained on English and Chinese and can be unstable
|
||||
in low-resource languages; for description-driven design in other cases
|
||||
try [VoxCPM2](voxcpm2.md).
|
||||
- Below the 6 GB VRAM floor, expect very slow renders or budget timeouts;
|
||||
prefer [OmniVoice GGUF](omnivoice-gguf.md) or a CPU engine such as
|
||||
[PocketTTS](pockettts.md).
|
||||
- Below the 6 GB VRAM floor on a CUDA or ROCm card, expect very slow renders;
|
||||
they get the longer CPU compute-time budget rather than the accelerated one,
|
||||
but can still time out. Prefer [OmniVoice GGUF](omnivoice-gguf.md) or a CPU
|
||||
engine such as [PocketTTS](pockettts.md).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -106,3 +111,10 @@ See also: [benchmarks.md](../benchmarks.md),
|
||||
[performance.md](../performance.md),
|
||||
[expressive-speech.md](../expressive-speech.md),
|
||||
[disk usage](disk-usage.md).
|
||||
|
||||
A timed-out subprocess is killed and given a bounded wait to exit before the
|
||||
request returns, so retrying cannot reuse its closing process. Timeout cleanup
|
||||
remains tied to the original child and cannot kill a replacement sidecar.
|
||||
If that wait cannot confirm exit, VoiceStudio retains the process for cleanup
|
||||
and blocks another attempt until it can be reaped, rather than starting a second
|
||||
engine alongside it. A later retry or shutdown retries the bounded cleanup.
|
||||
|
||||
@@ -23,6 +23,9 @@ ignores.
|
||||
Everything you type in the text box reaches the active engine **verbatim** —
|
||||
the pipeline goes out of its way not to break tags:
|
||||
|
||||
- Tilde-separated integer, signed, and decimal ranges get a spoken separator in
|
||||
English, Korean, Japanese, and Chinese; malformed chains and product codes
|
||||
are left unchanged.
|
||||
- The text-normalization pass (numbers, abbreviations) skips every `[…]` span
|
||||
(`backend/services/text_normalization.py`).
|
||||
- The long-text chunker never cuts inside a bracket tag
|
||||
|
||||
@@ -190,3 +190,20 @@ Hit a wall? See [docs/install/troubleshooting.md](troubleshooting.md).
|
||||
The in-app error UI (the React error boundary that fires on backend errors)
|
||||
includes an **"Open docs for this error"** button — that button deeplinks
|
||||
back into this docs tree at the right section for the error class.
|
||||
|
||||
### Desktop window chrome
|
||||
|
||||
The main window uses native macOS traffic lights with an overlay title bar;
|
||||
window sizing, resize limits, and application file-drop behavior match the
|
||||
other desktop platforms. The platform configuration repeats the complete window
|
||||
list because Tauri replaces arrays when merging it with the shared config.
|
||||
The capture widget remains a separate borderless window created at runtime.
|
||||
Its window-scoped Tauri capability permits hiding after recording or idle
|
||||
reconciliation on every desktop platform.
|
||||
|
||||
### Fast process shutdown
|
||||
|
||||
A process that exits while shutdown is signalling it can report a macOS
|
||||
permission error. VoiceStudio accepts this only after confirming the original
|
||||
process exited without being reaped, then still waits for nested operations to
|
||||
drain. Live-process permission errors and lost process ownership remain failures.
|
||||
|
||||
@@ -654,6 +654,8 @@ the error persistently on a current build, that's section **14** (a wedged GPU
|
||||
job), section **14d** (the backend never started), or the crash notice above —
|
||||
not this window.
|
||||
|
||||
A startup timeout from an earlier attempt cannot replace the current startup state after **Retry** takes over.
|
||||
|
||||
Desktop startup, **Retry**, storage reset, setup re-entry, in-app uninstall,
|
||||
app shutdown, and automatic crash recovery also share one backend lifecycle
|
||||
owner. Overlapping start attempts wait and attach to the healthy process,
|
||||
|
||||
@@ -297,3 +297,19 @@ See [docs/setup/huggingface-token.md](../setup/huggingface-token.md).
|
||||
## Troubleshooting
|
||||
|
||||
Hit a wall? See [docs/install/troubleshooting.md](troubleshooting.md).
|
||||
|
||||
### Building the current-user MSI
|
||||
|
||||
Build the system MSI first. `scripts/render-per-user-wix.py` requires
|
||||
`--system-wxs` pointing to its fully rendered `release/wix/x64/main.wxs`,
|
||||
in addition to the canonical `--source` template and `--output` destination.
|
||||
The renderer preserves Tauri resource destinations while assigning distinct,
|
||||
stable per-user component identities, HKCU registry keypaths, and uninstall
|
||||
cleanup for nested resource folders. Missing or unrendered resources fail the
|
||||
build. The canonical system installer retains its per-machine authoring.
|
||||
|
||||
CI bundles both scopes with a tiny executable, an external helper, and nested
|
||||
resources using the CLI version locked in `bun.lock`. The Windows MSI authoring
|
||||
job runs after the test suite and preserves verbose WiX logs and rendered XML.
|
||||
For focused diagnosis, dispatch CI with `windows_wix_diagnostic=true`; it skips
|
||||
the other jobs and never signs, publishes, or installs the fixture bundles.
|
||||
|
||||
+24
-7
@@ -92,8 +92,8 @@ None of them are required — the defaults are chosen for the common case.
|
||||
| `OMNIVOICE_UNIFIED_OFFLOAD_HEADROOM_GB` | `6` | On unified memory (Apple Silicon): if free RAM is below this when a dub needs the transcription model, the TTS model is fully released first (it reloads on the next generation). Raise to be more aggressive about freeing, lower on 32 GB+ machines to avoid the reload. |
|
||||
| `OMNIVOICE_INDEXTTS_FP16` | `1` | IndexTTS half-precision. Leave on. |
|
||||
| `OMNIVOICE_ASR_VRAM_PREFLIGHT` | `1` | Downgrade transcription precision instead of crashing when VRAM is short (CUDA). Leave on. |
|
||||
| `OMNIVOICE_GENERATE_TIMEOUT_S` | `300` | Abandon a generation after this many seconds **of actual compute** on an accelerated (GPU-family) host — the clock starts when a worker picks the job up, never while it waits in line. It's a floor, not a ceiling: the budget grows with the text (+1 s per 40 characters past the first 1200), so long inputs rarely need this raised. Also settable from **Settings → Performance & Device → Compute-time budget** (persists to `prefs.json`; takes effect on the next backend restart, same as `OMNIVOICE_DEVICE` above). |
|
||||
| `OMNIVOICE_CPU_GENERATE_TIMEOUT_S` | `600` | Same budget, for hosts that render on the CPU — correct CPU synthesis legitimately takes longer than the accelerated floor, so it gets its own, higher one. An explicit value here always governs CPU-family generation, independent of `OMNIVOICE_GENERATE_TIMEOUT_S` above — that var only doubles as a CPU floor when *this* one is left unset (a legacy shortcut: setting only `OMNIVOICE_GENERATE_TIMEOUT_S` lowers the watchdog everywhere with one var). Also settable from **Settings → Performance & Device**, which flags a row an external env var is already shadowing instead of claiming a save will apply. |
|
||||
| `OMNIVOICE_GENERATE_TIMEOUT_S` | `300` | Abandon a generation after this many seconds **of actual compute** on an accelerated (GPU-family) host — the clock starts when a worker picks the job up, never while it waits in line. It's a floor, not a ceiling: the budget grows with the text (+1 s per 40 characters past the first 1200), so long inputs rarely need this raised. A **CUDA or ROCm** GPU with less dedicated VRAM than the engine declares it needs is the exception — it pages to system RAM and renders slower than the same machine's CPU, so it floors at `OMNIVOICE_CPU_GENERATE_TIMEOUT_S` below instead. Apple Silicon (MPS) is not included: its reported VRAM is a heuristic over a *unified* memory pool, not a dedicated one, so there is no comparable floor to measure it against. Setting **this** var explicitly turns that off — an explicit value here is the base on every device, under-provisioned or not, so lowering it to fail fast still works. Also settable from **Settings → Performance & Device → Compute-time budget** (persists to `prefs.json`; takes effect on the next backend restart, same as `OMNIVOICE_DEVICE` above). |
|
||||
| `OMNIVOICE_CPU_GENERATE_TIMEOUT_S` | `600` | Same budget, for hosts that render on the CPU — correct CPU synthesis legitimately takes longer than the accelerated floor, so it gets its own, higher one. It is also the floor a CUDA/ROCm GPU below the engine's declared VRAM floor gets, since that is the performance class it actually falls into. An explicit value here always governs CPU-family generation, independent of `OMNIVOICE_GENERATE_TIMEOUT_S` above — that var only doubles as a CPU floor when *this* one is left unset (a legacy shortcut: setting only `OMNIVOICE_GENERATE_TIMEOUT_S` lowers the watchdog everywhere with one var). Also settable from **Settings → Performance & Device**, which flags a row an external env var is already shadowing instead of claiming a save will apply. |
|
||||
| `OMNIVOICE_ENGINE_IMPORT_PROBE_TIMEOUT_S` | `60` | How long to wait while checking that a sidecar engine's virtualenv can import the engine. Only affects how quickly a *broken* venv is ruled out — a probe that runs out of time is treated as "unproven", and the venv is used anyway, so a slow machine is never told its engine is missing. Per-engine override: `OMNIVOICE_INDEXTTS_IMPORT_PROBE_TIMEOUT_S` (and the same shape for `CONFUCIUS4`, `DOTS_TTS`, `MOSS_TTS_V15`). |
|
||||
| `OMNIVOICE_GPU_QUEUE_TIMEOUT_S` | `1800` | How long a job may sit in the GPU queue before it's reported as a saturated pool (a retryable condition — nothing ran). Waiting is normal on 1-worker machines; lower this only if you'd rather fail fast than queue. |
|
||||
|
||||
@@ -116,15 +116,32 @@ editor, profile previews, and streaming).
|
||||
|
||||
| Situation | What you see |
|
||||
| --- | --- |
|
||||
| The engine declares a VRAM floor above what this GPU has, or routing fell back to CPU | The routing caveat, naming your card, the engine's floor, and the ways around it |
|
||||
| The engine declares a VRAM floor above what this GPU has, or routing fell back to CPU | The routing caveat, naming your card, the engine's floor, and the ways around it. A CUDA/ROCm card below the floor is also *budgeted* as the CPU-class hardware it performs like — it gets the larger CPU/accelerated base unless `OMNIVOICE_GENERATE_TIMEOUT_S` is explicitly set |
|
||||
| The host synthesizes on the CPU **and** the text is over 1200 characters | A heads-up that this generation may exceed the time budget |
|
||||
| The host synthesizes on Apple Silicon (MPS) **and** the text is over 1200 characters | The same heads-up — MPS gets the accelerated-host budget (`OMNIVOICE_GENERATE_TIMEOUT_S`), which a long render can still legitimately exceed |
|
||||
|
||||
**Why 1200 characters:** it is the same figure the budget itself uses. The first
|
||||
1200 characters get the flat `OMNIVOICE_GENERATE_TIMEOUT_S`, and only past that
|
||||
does the budget start growing (+1 s per 40 characters). Below the threshold you
|
||||
are inside a budget the backend already considers generous, so ordinary
|
||||
sentences on a CPU laptop stay quiet.
|
||||
1200 characters get the flat base budget, and only past that does the budget
|
||||
start growing (+1 s per 40 characters). Below the threshold you are inside a
|
||||
budget the backend already considers generous, so ordinary sentences on a CPU
|
||||
laptop stay quiet.
|
||||
|
||||
**Which base applies:**
|
||||
|
||||
| Host | Base budget |
|
||||
| --- | --- |
|
||||
| Renders on the CPU | `OMNIVOICE_CPU_GENERATE_TIMEOUT_S` |
|
||||
| CUDA/ROCm GPU below the engine's declared VRAM floor, when `OMNIVOICE_GENERATE_TIMEOUT_S` is not explicitly set | `OMNIVOICE_CPU_GENERATE_TIMEOUT_S` (whichever of the two is larger) |
|
||||
| Any other accelerated host, MPS included | `OMNIVOICE_GENERATE_TIMEOUT_S` |
|
||||
|
||||
Both rows above can be overridden, and the two vars are independent:
|
||||
|
||||
- An explicit `OMNIVOICE_CPU_GENERATE_TIMEOUT_S` always governs CPU-family
|
||||
generation, even when the accelerated var is also set.
|
||||
- An explicit `OMNIVOICE_GENERATE_TIMEOUT_S` is used verbatim on every
|
||||
accelerated host — **including** an under-provisioned one, which then keeps
|
||||
the value you chose rather than being floored. That is deliberate: it is what
|
||||
lets you lower the watchdog to fail fast everywhere with one setting.
|
||||
|
||||
Both warnings are **advisory** — nothing is blocked. A driver can page to system
|
||||
RAM, and a short input fits where a long one does not, so the engine still runs
|
||||
|
||||
@@ -245,6 +245,10 @@ grace window to come back, and if it returns carrying a finished result, that
|
||||
result is used — the task is never run twice just because a network blip
|
||||
happened. Only when the window expires is the task retried elsewhere.
|
||||
|
||||
Each attempt retains the deadline budget granted at dispatch, including after a
|
||||
worker disconnect or control-plane restart; changed worker availability cannot
|
||||
shorten an in-flight attempt’s execution allowance.
|
||||
|
||||
**A worker fails repeatedly.** After three consecutive failures that are
|
||||
actually its fault, it is paused for a minute, then automatically given one
|
||||
task to prove itself. Repeated trips back off further, up to thirty minutes.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Hugging Face Token Setup
|
||||
|
||||
VoiceStudio uses a single HF token for every model download, license-gate
|
||||
check, and `whoami` ping. This page covers the three places VoiceStudio will
|
||||
check, and an explicit **Test now** action. This page covers the three places VoiceStudio will
|
||||
look for a token and the recommended path for v0.3+.
|
||||
|
||||
## Three sources (cascade)
|
||||
@@ -14,13 +14,16 @@ call wins:
|
||||
Set via the in-app **Settings → API Keys** panel.
|
||||
2. **Env** — `HF_TOKEN` (or the legacy `HUGGING_FACE_HUB_TOKEN`) environment
|
||||
variable visible to the VoiceStudio process.
|
||||
3. **HF CLI** — the canonical `~/.cache/huggingface/token` file written by
|
||||
3. **HF CLI** — the local `HF_TOKEN_PATH` file (normally `~/.cache/huggingface/token`) written by
|
||||
`huggingface-cli login`.
|
||||
|
||||
The active source is surfaced live in **Settings → API Keys**: each row shows
|
||||
set/unset, a masked preview (`hf_…3jw`), the `whoami` username + green check
|
||||
when valid, and an **"Active"** badge on whichever source is currently
|
||||
serving the cascade.
|
||||
Source labels in onboarding and Settings follow the selected UI language;
|
||||
product names such as HuggingFace CLI remain unchanged.
|
||||
|
||||
On opening **Settings → API Keys**, each row shows local set/unset state and
|
||||
a masked preview (`hf_…3jw`). Tokens remain **Not tested** until you select
|
||||
**Test now**. That explicit check displays the `whoami` username and a green
|
||||
check for valid sources, with an **Active** badge on the highest-priority valid source.
|
||||
|
||||
## Setting via the app (recommended)
|
||||
|
||||
@@ -32,8 +35,7 @@ serving the cascade.
|
||||
key derived per-install from machine-id) and also written to the
|
||||
canonical `huggingface_hub` token location so subprocess engines pick it
|
||||
up automatically.
|
||||
4. The row's `whoami` indicator flips green and the **Active** badge moves to
|
||||
"App".
|
||||
4. Select **Test now** to validate the token with Hugging Face. A successful test turns the indicator green and moves the **Active** badge to "App".
|
||||
|
||||
> **Known limitation (honest disclosure):** the encryption key is derived
|
||||
> per-install from the machine identifier. If you copy `omnivoice_data/`
|
||||
@@ -79,8 +81,8 @@ huggingface-cli login
|
||||
# paste token at the prompt
|
||||
```
|
||||
|
||||
That writes to `~/.cache/huggingface/token`. VoiceStudio reads via
|
||||
`huggingface_hub.get_token()` and picks it up automatically — you'll see the
|
||||
That normally writes to `~/.cache/huggingface/token`. VoiceStudio reads the
|
||||
selected local token file directly — you'll see the
|
||||
**HF CLI** row in **Settings → API Keys** flip to "set".
|
||||
|
||||
## Accepting model licenses
|
||||
@@ -105,7 +107,7 @@ process).
|
||||
- **HF 401 even though a token is set** — visit the model's HuggingFace page
|
||||
and accept the license (see above). The token is fine; the *license* gate
|
||||
is separate.
|
||||
- **Token row stays red after Save** — the `whoami` call failed. Check the
|
||||
- **Token row stays red after Test now** — the `whoami` call failed. Check the
|
||||
token is valid at
|
||||
[huggingface.co/settings/tokens](https://huggingface.co/settings/tokens)
|
||||
and has at least the "read" scope.
|
||||
@@ -113,3 +115,13 @@ process).
|
||||
the App row. If it's empty, the SQLite store may have been wiped — re-save.
|
||||
If it's set but the active source is "Env" or "HF CLI", that's the cascade
|
||||
working as intended (App is highest priority).
|
||||
|
||||
Opening onboarding or Settings only reads local token presence and masked previews; it does not contact Hugging Face. Untested tokens are shown as **Not tested**, and onboarding reports where a token was found without claiming it is valid. **Test now** explicitly contacts Hugging Face. Replacing a saved token does not revoke the previous token on Hugging Face.
|
||||
|
||||
|
||||
Windows automatically shortens the model cache path while keeping the normal CLI token location. If only a previous VoiceStudio short-cache token exists, the app continues using that file. An existing normal CLI token takes priority; explicit token or cache overrides remain authoritative. Credentials are never copied between these locations.
|
||||
|
||||
The CLI row reads only the selected local file, without OAuth refresh or environment-token fallback. **Also clear saved HuggingFace CLI token files** removes both active and stored-token files at recognized automatic locations, so an older app token cannot reappear on restart. Explicit overrides limit clearing to their selected location. Clearing only the app token preserves CLI files; neither action revokes tokens on Hugging Face or changes Git credentials. A file permission failure is reported instead of claiming the files were cleared.
|
||||
|
||||
|
||||
If onboarding cannot read token state, it shows an error and **Retry**, keeping token entry hidden until discovery succeeds. **Replace token** writes the encrypted app token through the same endpoint as Settings, so it replaces the highest-priority app credential even when an older one exists. The success message confirms saving only; validation remains a separate explicit action.
|
||||
|
||||
@@ -158,3 +158,12 @@ One **Export** path for every audio/video asset, surfaced via `<UseInMenu>` →
|
||||
- **OmniDrive as home?** This spec keeps **Launchpad** as home and OmniDrive as the asset library. If you'd rather OmniDrive *be* the landing hub (everything starts from "your stuff"), that's a Launchpad/OmniDrive merge — say so and it folds into A1.
|
||||
- **Drag-and-drop vs menu.** v1 ships the `<UseInMenu>` dropdown (discoverable, keyboard-friendly). Drag an asset card onto a rail item is a later additive layer over the same `sendTo`.
|
||||
- **`GET /assets` endpoint.** v1 derives assets client-side; promote to a backend read model only if OmniDrive paging needs it.
|
||||
|
||||
## Workspace control behavior
|
||||
|
||||
Conversion keeps its method tab active until the request settles, including failures.
|
||||
Engine quick-switch triggers and expanded choices use the same formatted model name.
|
||||
Dubbing fit badges, explanations, shorter-rewrite actions, playback controls,
|
||||
and engine residency labels are localized in every supported UI language.
|
||||
|
||||
Compact engine-family badges retain the standard TTS, ASR, and LLM abbreviations.
|
||||
|
||||
@@ -128,3 +128,7 @@ for Chinese — the model auto-fixes mismatches).
|
||||
- **Case-insensitive**: `"Male"`, `"MALE"`, and `"male"` are all accepted, the code will normalize them to lower case.
|
||||
|
||||
- **Accent vs Dialect**: English accents are only applied to English speech, Chinese dialects are only applied to Chinese speech.
|
||||
|
||||
Gallery previews reject silent output and near-pure tonal buzz. The quality
|
||||
check measures short audio frames rather than the whole clip, so longer or
|
||||
softly voiced speech is not rejected merely for having low spectral flatness.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export function resolveDialogEsm(frontendRoot) {
|
||||
return [
|
||||
path.resolve(frontendRoot, 'node_modules/@tauri-apps/plugin-dialog/dist-js/index.js'),
|
||||
path.resolve(frontendRoot, '../node_modules/@tauri-apps/plugin-dialog/dist-js/index.js'),
|
||||
].find(existsSync);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "capture-widget",
|
||||
"description": "Allows the capture widget to hide when recording ends or an idle window is reconciled",
|
||||
"windows": ["widget"],
|
||||
"permissions": ["core:window:allow-hide"]
|
||||
}
|
||||
@@ -265,6 +265,9 @@ pub fn respawn_backend<R: tauri::Runtime>(
|
||||
stage: Arc<Mutex<BootstrapStage>>,
|
||||
logs: Arc<Mutex<Vec<LogPayload>>>,
|
||||
) {
|
||||
// Before anything reaches for lifecycle ownership: a readiness wait may be
|
||||
// holding it while a slow backend starts (#1791).
|
||||
preempt_backend_wait();
|
||||
if let Ok(mut guard) = stage.lock() {
|
||||
*guard = BootstrapStage::Checking;
|
||||
}
|
||||
@@ -308,6 +311,7 @@ pub fn with_backend_stopped<R: tauri::Runtime, T>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
action: impl FnOnce() -> T,
|
||||
) -> Result<T, String> {
|
||||
preempt_backend_wait();
|
||||
let state = app.state::<BackendState>();
|
||||
let _lifecycle = state.lifecycle.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Err(error) = stop_backend_locked(app) {
|
||||
@@ -615,11 +619,18 @@ fn launch_backend_and_wait<R: tauri::Runtime>(
|
||||
stage_handle: &Arc<Mutex<BootstrapStage>>,
|
||||
first_run_gate: bool,
|
||||
) {
|
||||
// Preserve cancellation arriving while waiting for ownership or preparing launch.
|
||||
let wait_generation = backend_wait_generation();
|
||||
let outcome = {
|
||||
let state = app.state::<BackendState>();
|
||||
let _lifecycle = state.lifecycle.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if backend_stop_requested(app) {
|
||||
log::info!("App is quitting — backend launch cancelled");
|
||||
#[cfg(debug_assertions)]
|
||||
wait_for_tracking_test_gate(
|
||||
"OMNIVOICE_TEST_LAUNCH_LOCKED_ENTERED",
|
||||
"OMNIVOICE_TEST_LAUNCH_LOCKED_RELEASE",
|
||||
);
|
||||
if backend_stop_requested(app) || backend_wait_generation() != wait_generation {
|
||||
log::info!("Backend launch cancelled before preparation");
|
||||
LaunchOutcome::Done
|
||||
} else {
|
||||
match prepare_backend_launch(app, stage_handle) {
|
||||
@@ -638,10 +649,10 @@ fn launch_backend_and_wait<R: tauri::Runtime>(
|
||||
set_stage(stage_handle, BootstrapStage::AwaitingSetup);
|
||||
LaunchOutcome::Done
|
||||
} else {
|
||||
spawn_with_supervisor_owner(app, stage_handle)
|
||||
spawn_with_supervisor_owner(app, stage_handle, wait_generation)
|
||||
}
|
||||
} else {
|
||||
spawn_with_supervisor_owner(app, stage_handle)
|
||||
spawn_with_supervisor_owner(app, stage_handle, wait_generation)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -659,9 +670,10 @@ fn launch_backend_and_wait<R: tauri::Runtime>(
|
||||
fn spawn_with_supervisor_owner<R: tauri::Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
stage_handle: &Arc<Mutex<BootstrapStage>>,
|
||||
wait_generation: u64,
|
||||
) -> LaunchOutcome {
|
||||
let supervisor_owner = SUPERVISOR_OWNER.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
if spawn_backend_until_ready(app, stage_handle) {
|
||||
if spawn_backend_until_ready(app, stage_handle, wait_generation) {
|
||||
LaunchOutcome::SupervisedReady {
|
||||
owner: supervisor_owner,
|
||||
}
|
||||
@@ -680,25 +692,39 @@ fn spawn_with_supervisor_owner<R: tauri::Runtime>(
|
||||
fn spawn_backend_until_ready<R: tauri::Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
stage_handle: &Arc<Mutex<BootstrapStage>>,
|
||||
wait_generation: u64,
|
||||
) -> bool {
|
||||
let mut venv_heal_attempted = false;
|
||||
'bootstrap: loop {
|
||||
if backend_stop_requested(app) {
|
||||
if backend_stop_requested(app) || backend_wait_generation() != wait_generation {
|
||||
return false;
|
||||
}
|
||||
spawn_and_track_backend(app, stage_handle);
|
||||
let start = std::time::Instant::now();
|
||||
// Latest `/startup/progress` status, or None while nothing answers.
|
||||
let mut last_status: Option<String> = None;
|
||||
// Early-bind narration: the backend answers /startup/progress within
|
||||
// ~1s of spawn, long before it is Ready — surface each step change
|
||||
// as a log line so the splash shows "Loading ML runtime (PyTorch)…"
|
||||
// instead of a silent 300s wait. An old backend (no endpoint) yields
|
||||
// None and the wait looks exactly as it did before.
|
||||
let mut last_step = String::new();
|
||||
while start.elapsed() < startup_budget() {
|
||||
// #1791: the clock measures time since the last sign of life, not time
|
||||
// since spawn — see `keep_waiting_for_backend`.
|
||||
let mut last_progress = start;
|
||||
while keep_waiting_for_backend(last_status.as_deref(), last_progress.elapsed(), startup_budget())
|
||||
{
|
||||
if backend_stop_requested(app) {
|
||||
log::info!("App is quitting — backend startup poll cancelled");
|
||||
return false;
|
||||
}
|
||||
if backend_wait_generation() != wait_generation {
|
||||
log::info!(
|
||||
"Retry/reset is taking the backend lifecycle — standing down from \
|
||||
the startup wait so it can proceed"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if crate::backend::backend_ready(backend_port()) {
|
||||
set_stage(stage_handle, BootstrapStage::Ready);
|
||||
return true;
|
||||
@@ -838,13 +864,20 @@ fn spawn_backend_until_ready<R: tauri::Runtime>(
|
||||
set_stage(stage_handle, BootstrapStage::Failed { message: msg });
|
||||
return false;
|
||||
}
|
||||
if let Some((status, step, label)) =
|
||||
crate::backend::startup_progress(backend_port())
|
||||
{
|
||||
if status == "starting" && !step.is_empty() && step != last_step {
|
||||
last_step = step;
|
||||
emit_log(app, "starting_backend", &format!("Startup: {label}"));
|
||||
match crate::backend::startup_progress(backend_port()) {
|
||||
Some((status, step, label)) => {
|
||||
if status == "starting" {
|
||||
// Alive, serving, and naming the step it is on — that is
|
||||
// the evidence the wait is keyed on (#1791).
|
||||
last_progress = std::time::Instant::now();
|
||||
if !step.is_empty() && step != last_step {
|
||||
last_step = step;
|
||||
emit_log(app, "starting_backend", &format!("Startup: {label}"));
|
||||
}
|
||||
}
|
||||
last_status = Some(status);
|
||||
}
|
||||
None => last_status = None,
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
@@ -861,7 +894,14 @@ fn spawn_backend_until_ready<R: tauri::Runtime>(
|
||||
err_tail
|
||||
)
|
||||
};
|
||||
set_stage(stage_handle, BootstrapStage::Failed { message: msg });
|
||||
publish_backend_wait_timeout(
|
||||
stage_handle,
|
||||
&LAST_FAILURE,
|
||||
&BACKEND_WAIT_GENERATION,
|
||||
&BACKEND_WAIT_PUBLICATION,
|
||||
wait_generation,
|
||||
msg,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -889,6 +929,52 @@ static SUPERVISOR_OWNER: AtomicU64 = AtomicU64::new(0);
|
||||
/// moment a fresh child is spawned and tracked (`track_backend_child`).
|
||||
static BACKEND_KILL_INTENDED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Bumped by any flow about to take backend lifecycle ownership for a
|
||||
/// deliberate replacement — Retry, Clean & Retry, reset, uninstall.
|
||||
///
|
||||
/// #1791: the readiness wait keeps waiting for as long as the backend answers
|
||||
/// `/startup/progress`, and it holds `BackendState::lifecycle` the whole time
|
||||
/// (`launch_backend_and_wait` takes it around the entire launch). Without a way
|
||||
/// to interrupt that wait, the user's own escape hatch would deadlock behind
|
||||
/// it: Retry and Clean & Retry both need the same lock, so pressing either on
|
||||
/// a slow start would hang instead of restarting anything — trading a backend
|
||||
/// killed too early for an app with no way out, which is worse. Every such
|
||||
/// flow bumps this BEFORE reaching for the lock; the waiting loop sees the
|
||||
/// change within one poll, returns, and releases it (Greptile, #1809).
|
||||
static BACKEND_WAIT_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
// Serialize invalidation with the final timeout publication. The lifecycle
|
||||
// lock cannot do this: Retry deliberately invalidates before acquiring it.
|
||||
static BACKEND_WAIT_PUBLICATION: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn publish_backend_wait_timeout(
|
||||
state: &Arc<Mutex<BootstrapStage>>,
|
||||
last_failure: &Mutex<Option<String>>,
|
||||
generation: &AtomicU64,
|
||||
publication: &Mutex<()>,
|
||||
expected_generation: u64,
|
||||
message: String,
|
||||
) -> bool {
|
||||
let _publication = publication.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if generation.load(Ordering::SeqCst) != expected_generation {
|
||||
return false;
|
||||
}
|
||||
set_stage_into(state, last_failure, BootstrapStage::Failed { message });
|
||||
true
|
||||
}
|
||||
|
||||
/// Ask any in-flight readiness wait to stand down, so this caller can take
|
||||
/// lifecycle ownership. Call before locking, never while holding the lock.
|
||||
pub fn preempt_backend_wait() {
|
||||
let _publication = BACKEND_WAIT_PUBLICATION
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
BACKEND_WAIT_GENERATION.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn backend_wait_generation() -> u64 {
|
||||
BACKEND_WAIT_GENERATION.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Bumped every time `track_backend_child` installs a new child. The
|
||||
/// supervisor snapshots it when it observes a death; a change during its
|
||||
/// backoff pause means ANOTHER flow (Retry / Clean & Retry) spawned and
|
||||
@@ -1124,6 +1210,42 @@ fn startup_budget() -> Duration {
|
||||
.unwrap_or(Duration::from_secs(300))
|
||||
}
|
||||
|
||||
/// Should the readiness poll keep waiting for a backend that is not Ready yet?
|
||||
///
|
||||
/// `status` is the `status` field of the latest `/startup/progress` reply, or
|
||||
/// `None` while nothing answers on the port.
|
||||
///
|
||||
/// #1791: this used to be a flat `elapsed < startup_budget()` from spawn, so a
|
||||
/// host where the cold start genuinely takes longer than five minutes — a
|
||||
/// project on a mapped network drive, a cold `import torch` off a spinning
|
||||
/// disk, a first CUDA DLL load — had its backend killed *while it was still
|
||||
/// importing*, reported as "the backend never reported ready". The respawn
|
||||
/// then threw away the warm work and raced the same clock again, so the app
|
||||
/// could never start even though launching the same backend by hand reached
|
||||
/// ready in under a minute.
|
||||
///
|
||||
/// A backend answering `status: "starting"` is not a backend we have to guess
|
||||
/// about: it bound its socket, it is serving HTTP, and it is telling us which
|
||||
/// step it is on. Killing it cannot make the next attempt faster, and the
|
||||
/// launcher has no information the user lacks — so keep waiting and keep
|
||||
/// narrating. The user's escape hatch is deliberate rather than clock-driven:
|
||||
/// the splash's own stall budget surfaces Retry and the logs, and its
|
||||
/// `/health` recovery poll walks straight into the app if the slow start does
|
||||
/// finish. The budget still governs *silence* — nothing answering, or a
|
||||
/// `failed`/unknown status — because there we truly cannot tell a slow
|
||||
/// backend from a wedged one, and the existing failure path (stderr tail +
|
||||
/// Retry) is the right answer.
|
||||
fn keep_waiting_for_backend(
|
||||
status: Option<&str>,
|
||||
since_progress: Duration,
|
||||
budget: Duration,
|
||||
) -> bool {
|
||||
match status {
|
||||
Some("starting") => true,
|
||||
_ => since_progress < budget,
|
||||
}
|
||||
}
|
||||
|
||||
/// The supervisor's death-detection poll interval. 2s in production;
|
||||
/// `OMNIVOICE_SUPERVISOR_POLL_MS` shrinks it for the harness only.
|
||||
fn supervisor_poll() -> Duration {
|
||||
@@ -3396,6 +3518,89 @@ mod tests {
|
||||
std::env::remove_var("OMNIVOICE_SUPERVISOR_POLL_MS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_backend_that_is_still_starting_is_never_timed_out() {
|
||||
let budget = Duration::from_secs(300);
|
||||
// #1791: the whole point — a slow cold start on a network drive or a
|
||||
// cold disk keeps waiting no matter how long it has already taken,
|
||||
// because it is demonstrably alive and naming its current step.
|
||||
assert!(keep_waiting_for_backend(
|
||||
Some("starting"),
|
||||
Duration::from_secs(3_600),
|
||||
budget
|
||||
));
|
||||
// Silence is the case we cannot read, so the budget still governs it:
|
||||
// wait up to the budget, then fail with the stderr tail as before.
|
||||
assert!(keep_waiting_for_backend(None, Duration::from_secs(299), budget));
|
||||
assert!(!keep_waiting_for_backend(None, budget, budget));
|
||||
// A backend that reports its own startup failure gets no extension —
|
||||
// it is not making progress and never will.
|
||||
assert!(!keep_waiting_for_backend(
|
||||
Some("failed"),
|
||||
Duration::from_secs(301),
|
||||
budget
|
||||
));
|
||||
// An unrecognised status is treated as silence, not as liveness.
|
||||
assert!(!keep_waiting_for_backend(
|
||||
Some("wat"),
|
||||
Duration::from_secs(301),
|
||||
budget
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_retry_preempts_an_in_flight_readiness_wait() {
|
||||
// #1791 + Greptile on #1809: the wait holds lifecycle ownership, which
|
||||
// Retry and Clean & Retry both need. A waiter that cannot be asked to
|
||||
// stand down turns a slow start into an app with no way out — strictly
|
||||
// worse than the early kill this fix removed. The waiter snapshots the
|
||||
// generation before ownership is acquired; a later bump means someone else is
|
||||
// taking over.
|
||||
let snapshot = backend_wait_generation();
|
||||
assert_eq!(backend_wait_generation(), snapshot, "nothing changed yet");
|
||||
preempt_backend_wait();
|
||||
assert_ne!(
|
||||
backend_wait_generation(),
|
||||
snapshot,
|
||||
"Retry must be visible to the waiting loop"
|
||||
);
|
||||
// And the flow that preempted then takes its OWN snapshot, so it does
|
||||
// not immediately cancel itself.
|
||||
let theirs = backend_wait_generation();
|
||||
assert_eq!(backend_wait_generation(), theirs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_after_wait_expiry_does_not_publish_a_stale_timeout() {
|
||||
let generation = AtomicU64::new(7);
|
||||
let publication = Mutex::new(());
|
||||
let state = Arc::new(Mutex::new(BootstrapStage::Checking));
|
||||
let failure = Mutex::new(Some("earlier diagnosis".to_string()));
|
||||
let snapshot = generation.load(Ordering::SeqCst);
|
||||
assert!(!keep_waiting_for_backend(None, Duration::from_secs(301), Duration::from_secs(300)));
|
||||
// Retry invalidates after the loop condition fails, before the old
|
||||
// waiter finishes collecting stderr and publishes its timeout.
|
||||
generation.fetch_add(1, Ordering::SeqCst);
|
||||
assert!(!publish_backend_wait_timeout(
|
||||
&state, &failure, &generation, &publication, snapshot, "stale timeout".into()
|
||||
));
|
||||
assert!(matches!(*state.lock().unwrap(), BootstrapStage::Checking));
|
||||
assert_eq!(failure.lock().unwrap().as_deref(), Some("earlier diagnosis"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_wait_expiry_still_publishes_and_retains_its_timeout() {
|
||||
let generation = AtomicU64::new(7);
|
||||
let publication = Mutex::new(());
|
||||
let state = Arc::new(Mutex::new(BootstrapStage::StartingBackend));
|
||||
let failure = Mutex::new(None);
|
||||
assert!(publish_backend_wait_timeout(
|
||||
&state, &failure, &generation, &publication, 7, "current timeout".into()
|
||||
));
|
||||
assert!(matches!(*state.lock().unwrap(), BootstrapStage::Failed { .. }));
|
||||
assert_eq!(failure.lock().unwrap().as_deref(), Some("current timeout"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_backoff_escalates_but_first_respawn_is_immediate() {
|
||||
// A one-off crash self-heals with zero added latency; repeat deaths
|
||||
|
||||
+100
-33
@@ -73,6 +73,33 @@ pub struct OwnedProcessTree {
|
||||
job: std::os::windows::io::OwnedHandle,
|
||||
}
|
||||
|
||||
// Keep the delivery and post-error ownership probe together: the root may
|
||||
// exit between any earlier liveness check and either TERM or KILL delivery.
|
||||
#[cfg(unix)]
|
||||
fn signal_process_group_with(
|
||||
signal: libc::c_int,
|
||||
darwin: bool,
|
||||
send: impl FnOnce(libc::c_int) -> io::Result<()>,
|
||||
root_exited_unreaped: impl FnOnce() -> io::Result<bool>,
|
||||
) -> io::Result<()> {
|
||||
match send(signal) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.raw_os_error() == Some(libc::ESRCH) => Ok(()),
|
||||
Err(error) if darwin && error.raw_os_error() == Some(libc::EPERM) => {
|
||||
// Darwin can report EPERM when the last signalable member exited
|
||||
// during delivery. Accept only a newly verified unreaped root:
|
||||
// live roots, lost identity and probe failures remain errors.
|
||||
// Callers must still join nested drain before reaping that root.
|
||||
if root_exited_unreaped()? {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
impl OwnedProcessTree {
|
||||
#[cfg(unix)]
|
||||
fn root_exited_unreaped(&self) -> io::Result<bool> {
|
||||
@@ -141,15 +168,18 @@ impl OwnedProcessTree {
|
||||
|
||||
#[cfg(unix)]
|
||||
fn signal_group(&self, signal: libc::c_int) -> io::Result<()> {
|
||||
if unsafe { libc::kill(-self.process_group, signal) } == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let error = io::Error::last_os_error();
|
||||
if error.raw_os_error() == Some(libc::ESRCH) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(error)
|
||||
}
|
||||
signal_process_group_with(
|
||||
signal,
|
||||
cfg!(target_os = "macos"),
|
||||
|signal| {
|
||||
if unsafe { libc::kill(-self.process_group, signal) } == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::last_os_error())
|
||||
}
|
||||
},
|
||||
|| self.root_exited_unreaped(),
|
||||
)
|
||||
}
|
||||
|
||||
fn force_terminate(&mut self) -> io::Result<()> {
|
||||
@@ -173,27 +203,6 @@ impl OwnedProcessTree {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn force_terminate_after_root_exit(&mut self) -> io::Result<()> {
|
||||
if self.terminated {
|
||||
return Ok(());
|
||||
}
|
||||
match self.signal_group(libc::SIGKILL) {
|
||||
Ok(()) => {}
|
||||
#[cfg(target_os = "macos")]
|
||||
Err(error) if error.raw_os_error() == Some(libc::EPERM) => {
|
||||
// XNU excludes zombies when iterating an explicit process
|
||||
// group, then reports EPERM when it found no signalable live
|
||||
// member. The unreaped root still reserves this exact group;
|
||||
// the nested-drain join that follows catches any descendant
|
||||
// which actually survived the signal attempt.
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
self.terminated = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wait_nested_drain(&mut self, timeout: Duration) -> io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -446,7 +455,7 @@ pub fn contained_child_exit(
|
||||
Ok(true) => {
|
||||
// Do not reap the root until group cleanup succeeds: the
|
||||
// zombie is what keeps this process-group ID non-reusable.
|
||||
tree.force_terminate_after_root_exit()?;
|
||||
tree.force_terminate()?;
|
||||
tree.wait_nested_drain(nested_drain_timeout())?;
|
||||
let status = child.try_wait()?;
|
||||
return Ok(status);
|
||||
@@ -495,7 +504,7 @@ pub fn terminate_process_tree(
|
||||
#[cfg(unix)]
|
||||
match tree.root_exited_unreaped() {
|
||||
Ok(true) => {
|
||||
tree.force_terminate_after_root_exit()?;
|
||||
tree.force_terminate()?;
|
||||
tree.wait_nested_drain(nested_drain_timeout())?;
|
||||
return child.wait();
|
||||
}
|
||||
@@ -522,7 +531,7 @@ pub fn terminate_process_tree(
|
||||
while std::time::Instant::now() < deadline {
|
||||
#[cfg(unix)]
|
||||
if tree.root_exited_unreaped()? {
|
||||
tree.force_terminate_after_root_exit()?;
|
||||
tree.force_terminate()?;
|
||||
tree.wait_nested_drain(nested_drain_timeout())?;
|
||||
return child.wait();
|
||||
}
|
||||
@@ -1207,6 +1216,64 @@ mod uv_tests {
|
||||
use super::*;
|
||||
use std::ffi::OsStr;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn darwin_group_signal_recovers_exit_during_term_or_kill_delivery() {
|
||||
for signal in [libc::SIGTERM, libc::SIGKILL] {
|
||||
let exited = std::cell::Cell::new(false);
|
||||
signal_process_group_with(
|
||||
signal,
|
||||
true,
|
||||
|delivered| {
|
||||
assert_eq!(delivered, signal);
|
||||
assert!(!exited.replace(true));
|
||||
Err(io::Error::from_raw_os_error(libc::EPERM))
|
||||
},
|
||||
|| {
|
||||
assert!(exited.get(), "ownership must be checked after failed delivery");
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.expect("an exited unreaped root keeps its group reserved until drain completes");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn darwin_group_signal_rejects_live_reaped_or_unverifiable_roots() {
|
||||
for signal in [libc::SIGTERM, libc::SIGKILL] {
|
||||
for (probe, expected) in [
|
||||
(Ok(false), libc::EPERM),
|
||||
(Err(libc::ECHILD), libc::ECHILD),
|
||||
(Err(libc::EIO), libc::EIO),
|
||||
] {
|
||||
let error = signal_process_group_with(
|
||||
signal,
|
||||
true,
|
||||
|_| Err(io::Error::from_raw_os_error(libc::EPERM)),
|
||||
|| probe.map_err(io::Error::from_raw_os_error),
|
||||
)
|
||||
.expect_err("only a confirmed unreaped exit can explain Darwin EPERM");
|
||||
assert_eq!(error.raw_os_error(), Some(expected));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn group_signal_preserves_other_permission_errors_and_platforms() {
|
||||
for (darwin, errno) in [(false, libc::EPERM), (true, libc::EACCES)] {
|
||||
let error = signal_process_group_with(
|
||||
libc::SIGKILL,
|
||||
darwin,
|
||||
|_| Err(io::Error::from_raw_os_error(errno)),
|
||||
|| panic!("unrelated errors must not use the Darwin exit exception"),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.raw_os_error(), Some(errno));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn contained_exit_probe_preserves_a_live_child() {
|
||||
|
||||
@@ -3,10 +3,35 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "VoiceStudio",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"maximized": true,
|
||||
"dragDropEnabled": false,
|
||||
"transparent": true,
|
||||
"backgroundColor": "#1d2021"
|
||||
},
|
||||
{
|
||||
"label": "widget",
|
||||
"title": "Capture",
|
||||
"width": 300,
|
||||
"height": 64,
|
||||
"resizable": false,
|
||||
"fullscreen": false,
|
||||
"transparent": true,
|
||||
"decorations": false,
|
||||
"alwaysOnTop": true,
|
||||
"visible": false,
|
||||
"skipTaskbar": true,
|
||||
"center": true,
|
||||
"create": false
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -345,6 +345,8 @@ const SCENARIO_ENV: &[&str] = &[
|
||||
"OMNIVOICE_TEST_BEFORE_TRACK_RELEASE",
|
||||
"OMNIVOICE_TEST_AFTER_TRACK_ENTERED",
|
||||
"OMNIVOICE_TEST_AFTER_TRACK_RELEASE",
|
||||
"OMNIVOICE_TEST_LAUNCH_LOCKED_ENTERED",
|
||||
"OMNIVOICE_TEST_LAUNCH_LOCKED_RELEASE",
|
||||
"OMNIVOICE_BACKEND_CMD",
|
||||
"OMNIVOICE_LOG_DIR",
|
||||
"OMNIVOICE_PORT",
|
||||
@@ -1760,3 +1762,39 @@ fn deferred_startup_failure_names_the_step() {
|
||||
logs.iter().map(|l| &l.line).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn retry_preempts_launch_before_the_readiness_wait_starts() {
|
||||
let t = TestApp::new(&Scenario { serve_ms: Some(0), ..Default::default() });
|
||||
std::env::set_var("OMNIVOICE_SCENARIO_PROGRESS_ONLY", "1");
|
||||
let entered = t._logdir.path().join("launch-locked");
|
||||
let release = t._logdir.path().join("release-launch");
|
||||
std::env::set_var("OMNIVOICE_TEST_LAUNCH_LOCKED_ENTERED", &entered);
|
||||
std::env::set_var("OMNIVOICE_TEST_LAUNCH_LOCKED_RELEASE", &release);
|
||||
let bootstrap = t.run_bootstrap();
|
||||
let entered_launch = wait_until(Duration::from_secs(5), || entered.exists());
|
||||
if !entered_launch {
|
||||
// Release and join before asserting: a timeout must not detach a
|
||||
// bootstrap thread while it still owns the lifecycle mutex.
|
||||
let released = std::fs::write(&release, b"release");
|
||||
t.quit();
|
||||
t.kill_tracked_child();
|
||||
join_with_timeout(bootstrap, Duration::from_secs(10), "launch gate timeout");
|
||||
released.expect("release launch gate after timeout");
|
||||
panic!("bootstrap did not enter the launch gate");
|
||||
}
|
||||
app_lib::bootstrap::preempt_backend_wait();
|
||||
let handle = t.handle();
|
||||
let retry = std::thread::spawn(move || {
|
||||
let state = handle.state::<BackendState>();
|
||||
let _ownership = state.lifecycle.lock().unwrap();
|
||||
});
|
||||
std::fs::write(&release, b"release").unwrap();
|
||||
let acquired = wait_until(Duration::from_secs(3), || retry.is_finished());
|
||||
t.quit();
|
||||
t.kill_tracked_child();
|
||||
join_with_timeout(bootstrap, Duration::from_secs(10), "preempted launch");
|
||||
join_with_timeout(retry, Duration::from_secs(10), "retry ownership");
|
||||
assert!(acquired, "old launch swallowed Retry's generation and held lifecycle");
|
||||
}
|
||||
|
||||
@@ -177,7 +177,9 @@
|
||||
<File Id="UpdateTaskUninstaller" Source="uninstall-task.ps1" KeyPath="yes" Checksum="yes"/>
|
||||
</Component>
|
||||
{{/if}}
|
||||
<!-- BEGIN BUNDLED_RESOURCES -->
|
||||
{{resources}}
|
||||
<!-- END BUNDLED_RESOURCES -->
|
||||
<Component Id="CMP_UninstallShortcut" Guid="*">
|
||||
|
||||
<Shortcut Id="UninstallShortcut"
|
||||
|
||||
+5
-10
@@ -1,3 +1,4 @@
|
||||
import { firstSoundRequest } from './utils/firstSound';
|
||||
import React, {
|
||||
useState,
|
||||
useRef,
|
||||
@@ -699,16 +700,10 @@ function App() {
|
||||
if (!pending) return;
|
||||
(async () => {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('text', i18n.t('firstrun.first_sound_text'));
|
||||
// Functional model prompt (not user-facing copy) — keeps the demo
|
||||
// voice warm without depending on seeded profiles.
|
||||
fd.append('instruct', 'A warm, friendly narrator voice, medium pace');
|
||||
fd.append('num_step', '16');
|
||||
const res = await apiFetch(`${API}/generate`, {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
});
|
||||
const res = await apiFetch(
|
||||
`${API}/generate`,
|
||||
firstSoundRequest(i18n.t('firstrun.first_sound_text')),
|
||||
);
|
||||
const blob = await res.blob();
|
||||
await playBlobAudio(blob, { label: i18n.t('player.generated_audio') });
|
||||
toast.success(i18n.t('firstrun.first_sound_done'), { duration: 7000 });
|
||||
|
||||
+20
-10
@@ -1,3 +1,4 @@
|
||||
import { abortableDelay } from '../utils/abortableDelay.ts';
|
||||
// Backend base URL.
|
||||
// • VITE_API_URL → explicit override (any deploy).
|
||||
// • Tauri webview → the local sidecar (127.0.0.1:<port>).
|
||||
@@ -12,10 +13,10 @@
|
||||
// under `node --experimental-strip-types`, whose ESM resolver requires real
|
||||
// file extensions (tsconfig has allowImportingTsExtensions for tsc).
|
||||
import {
|
||||
awaitBackendCrashMarker,
|
||||
getUnacknowledgedBackendCrash,
|
||||
describeCrashExit,
|
||||
crashAge,
|
||||
type BackendCrashMarker,
|
||||
} from '../utils/backendCrash.ts';
|
||||
import { backendLifecycleStage, type BackendLifecycle } from '../utils/backendLifecycle.ts';
|
||||
import { scrubText } from '../utils/scrub.js';
|
||||
@@ -384,7 +385,7 @@ export async function apiFetch(path: string, opts: ApiFetchOptions = {}): Promis
|
||||
if (signal?.aborted || (e as Error)?.name === 'AbortError') throw e;
|
||||
lastDetail = String((e as Error)?.message || e);
|
||||
if (retryTransport && attempt < TRANSPORT_RETRY_BACKOFF_MS.length) {
|
||||
await new Promise((r) => setTimeout(r, TRANSPORT_RETRY_BACKOFF_MS[attempt]));
|
||||
await abortableDelay(TRANSPORT_RETRY_BACKOFF_MS[attempt], signal);
|
||||
continue;
|
||||
}
|
||||
// The short cascade is exhausted, but the desktop shell may KNOW the
|
||||
@@ -400,7 +401,7 @@ export async function apiFetch(path: string, opts: ApiFetchOptions = {}): Promis
|
||||
}
|
||||
const stage = lastStage.stage;
|
||||
if (stage === 'starting') {
|
||||
await new Promise((r) => setTimeout(r, RESTART_WAIT_INTERVAL_MS));
|
||||
await abortableDelay(RESTART_WAIT_INTERVAL_MS, signal);
|
||||
continue;
|
||||
}
|
||||
// `ready` while the transport is failing is a contradiction — the
|
||||
@@ -410,7 +411,7 @@ export async function apiFetch(path: string, opts: ApiFetchOptions = {}): Promis
|
||||
// marker. 'failed'/'unknown' fall through and error now, so a shell
|
||||
// that gave up — or no shell at all — still surfaces promptly.
|
||||
if (stage === 'ready' && elapsed < RECONCILE_MS) {
|
||||
await new Promise((r) => setTimeout(r, RECONCILE_INTERVAL_MS));
|
||||
await abortableDelay(RECONCILE_INTERVAL_MS, signal);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -429,12 +430,21 @@ export async function apiFetch(path: string, opts: ApiFetchOptions = {}): Promis
|
||||
// watcher, or (browser/dev/Docker) by the backend's own run sentinel —
|
||||
// tell the honest story instead of the vague "can't reach" and let
|
||||
// BackendCrashNotice raise its "View crash details" affordance.
|
||||
let crash: BackendCrashMarker | null = null;
|
||||
try {
|
||||
crash = await getUnacknowledgedBackendCrash();
|
||||
} catch {
|
||||
/* forensics unavailable — fall through to the generic message */
|
||||
}
|
||||
//
|
||||
// #1802/#1805: asking ONCE, right now, races the shell's ~2 s death
|
||||
// poll and loses — the marker for a backend that just died has usually
|
||||
// not been written yet. We then concluded "no crash" and said so, which
|
||||
// is how a give-up came to assert "it most likely crashed or was killed
|
||||
// mid-request" while carrying no forensics to back it. `streamDropError`
|
||||
// already waits out that poll (#1119); this path never did. The budget
|
||||
// is shorter than the stream path's 8 s because the retry cascade above
|
||||
// has already cost the user a few seconds — two poll intervals is enough
|
||||
// to stop losing the race without turning every failure into a stall.
|
||||
const { crash } = await awaitBackendCrashMarker(getUnacknowledgedBackendCrash, {
|
||||
waitMs: 4_000,
|
||||
intervalMs: 1_000,
|
||||
signal,
|
||||
});
|
||||
if (crash) {
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('ov:backend-crashed', { detail: crash }));
|
||||
|
||||
@@ -365,6 +365,27 @@ function IpcLostRecovery({ t }) {
|
||||
}
|
||||
|
||||
/** Mono error block. */
|
||||
/** When the backend last produced ANY line of bootstrap output.
|
||||
*
|
||||
* #1791: the stall watchdog in `useBootstrapStage` keys on `bootstrap_status`,
|
||||
* which sits on a single stage for the whole of a slow start — a cold `import
|
||||
* torch` off a mapped network drive can hold `starting_backend` for many
|
||||
* minutes. The narration that proves it is alive ("Loading ML runtime
|
||||
* (PyTorch)…") arrives on the separate `bootstrap-log` event stream instead,
|
||||
* so the watchdog never saw it and called a live launch stuck. A splash that
|
||||
* is still printing new lines is by definition not the info-less spinner the
|
||||
* watchdog exists to break.
|
||||
*
|
||||
* Module-scoped rather than a ref because the two halves live in different
|
||||
* components — `BootstrapSplash` owns the event subscription, `useBootstrapStage`
|
||||
* owns the watchdog — and they are always mounted as a pair.
|
||||
*/
|
||||
let lastBootstrapLogTs = 0;
|
||||
|
||||
export function noteBootstrapLogActivity(ts = Date.now()) {
|
||||
lastBootstrapLogTs = ts;
|
||||
}
|
||||
|
||||
function ErrorBox({ children }) {
|
||||
return (
|
||||
<pre className="m-0 overflow-x-auto whitespace-pre-wrap break-words rounded-md bg-danger/10 px-3 py-2 font-mono text-[0.66rem] leading-relaxed text-danger shadow-[inset_2px_0_0_var(--color-danger)]">
|
||||
@@ -523,6 +544,7 @@ export function BootstrapSplash({ stage, message }) {
|
||||
unlistenLog = await listen('bootstrap-log', (e) => {
|
||||
const { stage: s, line } = e.payload || {};
|
||||
if (!line) return;
|
||||
noteBootstrapLogActivity();
|
||||
setLogs((prev) => {
|
||||
// Deduplicate against backfill by checking the last few lines.
|
||||
const lastFew = prev.slice(-5);
|
||||
@@ -982,7 +1004,8 @@ export function useBootstrapStage(pollMs = 1000) {
|
||||
}
|
||||
// Rust returns { stage: 'ready' } or { stage: 'failed', message: '…' } etc.
|
||||
if (stage !== 'ready' && stage !== 'failed') {
|
||||
if (Date.now() - lastChangeTs > stallBudgetMs(stage)) {
|
||||
const lastActivity = Math.max(lastChangeTs, lastBootstrapLogTs);
|
||||
if (Date.now() - lastActivity > stallBudgetMs(stage)) {
|
||||
// Stuck — surface it as a failure so Retry/logs/hints appear.
|
||||
setState({
|
||||
stage: 'failed',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { CheckCircle, ArrowRight, X, Sparkles, Languages, Mic } from 'lucide-react';
|
||||
import { CheckCircle, ArrowRight, X, Sparkles, Languages, Mic, AlertTriangle } from 'lucide-react';
|
||||
import { Button } from '../ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -32,7 +32,14 @@ const STAGE_KEYS = {
|
||||
done: { title: 'checkpoint.done_title', cta: null, hint: 'checkpoint.done_hint' },
|
||||
};
|
||||
|
||||
export default function CheckpointBanner({ stage, count, onContinue, onDismiss, continueLoading }) {
|
||||
export default function CheckpointBanner({
|
||||
stage,
|
||||
count,
|
||||
onContinue,
|
||||
onDismiss,
|
||||
continueLoading,
|
||||
timingWarnings = 0,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const icons = STAGE_ICONS[stage];
|
||||
const keys = STAGE_KEYS[stage];
|
||||
@@ -56,6 +63,12 @@ export default function CheckpointBanner({ stage, count, onContinue, onDismiss,
|
||||
)}
|
||||
</div>
|
||||
<span className="ckpt-hint">{t(keys.hint)}</span>
|
||||
{stage === 'done' && timingWarnings > 0 && (
|
||||
<span className="flex items-center gap-2 text-xs text-[var(--color-warn)]">
|
||||
<AlertTriangle size={14} aria-hidden="true" />
|
||||
{t('checkpoint.timing_review', { count: timingWarnings })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{keys.cta && onContinue && (
|
||||
<Button
|
||||
|
||||
@@ -14,11 +14,12 @@ import {
|
||||
Plus,
|
||||
Sparkles,
|
||||
Volume2,
|
||||
BookOpen,
|
||||
} from 'lucide-react';
|
||||
import { formatTime } from '../utils/format';
|
||||
import { LANG_CODES } from '../utils/languages';
|
||||
import { MIN_SEG_DUR } from '../utils/timeline';
|
||||
import { Menu, Button, Badge } from '../ui';
|
||||
import { Menu } from '../ui';
|
||||
import VoiceSelector from './VoiceSelector';
|
||||
|
||||
const CHAR_BUDGET_RATIO = 1.3;
|
||||
@@ -67,6 +68,7 @@ function DubSegmentRow({
|
||||
seg,
|
||||
idx,
|
||||
style,
|
||||
ariaAttributes,
|
||||
disabled,
|
||||
isActive,
|
||||
isDone,
|
||||
@@ -260,6 +262,7 @@ function DubSegmentRow({
|
||||
|
||||
return (
|
||||
<div
|
||||
{...ariaAttributes}
|
||||
style={style}
|
||||
className={rowClass(isActive, isDone, selected, isPlaying, timelineSelected)}
|
||||
onClick={handleRowClick}
|
||||
@@ -270,156 +273,14 @@ function DubSegmentRow({
|
||||
onChange={(e) => onSelect(seg.id, idx, e.nativeEvent.shiftKey)}
|
||||
onClick={(e) => onSelect(seg.id, idx, e.shiftKey)}
|
||||
disabled={disabled}
|
||||
className="cursor-pointer justify-self-center"
|
||||
className="seg-select cursor-pointer justify-self-center"
|
||||
title={t('segment.select_title')}
|
||||
/>
|
||||
<span className="segment-time flex flex-col min-w-0 overflow-hidden tabular-nums">
|
||||
{['start', 'end'].map((edge) => (
|
||||
<span className="seg-time-stepper" key={`${edge}-${seg.id}-${seg[edge]}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nudgeTime(edge, -0.1)}
|
||||
disabled={disabled || (edge === 'start' && seg.start <= 0)}
|
||||
aria-label={t(`segment.time_nudge_${edge}_earlier`)}
|
||||
>
|
||||
<Minus size={10} />
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
className="seg-time-input"
|
||||
defaultValue={formatTime(seg[edge])}
|
||||
disabled={disabled}
|
||||
title={t(
|
||||
edge === 'start' ? 'segment.time_edit_title' : 'segment.time_edit_end_title',
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={timeKeyDown(edge)}
|
||||
onBlur={commitTime(edge)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nudgeTime(edge, 0.1)}
|
||||
disabled={disabled}
|
||||
aria-label={t(`segment.time_nudge_${edge}_later`)}
|
||||
>
|
||||
<Plus size={10} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<span className="flex items-baseline gap-[2px] min-w-0">
|
||||
{seg.speed && seg.speed !== 1.0 && (
|
||||
<span
|
||||
className="text-[0.52rem] ml-[1px]"
|
||||
style={{ color: seg.speed > 1 ? '#d3869b' : '#8ec07c' }}
|
||||
>
|
||||
{seg.speed.toFixed(2)}x
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{hasOverlap && (
|
||||
<span className="seg-overlap-warning" title={t('timeline.overlap_warning')}>
|
||||
<AlertCircle size={9} /> {t('timeline.overlap_warning')}
|
||||
</span>
|
||||
)}
|
||||
{fitBadge && (
|
||||
<span
|
||||
className="text-[0.48rem] mt-[1px] inline-flex items-center gap-[1px]"
|
||||
style={{ color: fitBadge.color }}
|
||||
title={fitBadge.title}
|
||||
>
|
||||
<fitBadge.Icon size={8} /> {fitBadge.label}
|
||||
</span>
|
||||
)}
|
||||
{seg.qc_flagged && (
|
||||
// Wave 3.3: second-pass ASR heard something different from the
|
||||
// target text for this line — worth a re-listen / re-dub.
|
||||
<span
|
||||
className="text-[0.48rem] mt-[1px] inline-flex items-center gap-[1px]"
|
||||
style={{ color: '#fb4934' }}
|
||||
title={t('segment.qc_verify_title', { heard: seg.qc_recognized || '' })}
|
||||
>
|
||||
<AlertCircle size={8} /> {t('segment.qc_verify')}
|
||||
</span>
|
||||
)}
|
||||
{seg.rate_ratio != null && Math.abs(seg.rate_ratio - 1.0) > 0.03 && (
|
||||
<span
|
||||
className="text-[0.48rem] mt-[1px] tabular-nums"
|
||||
style={{
|
||||
color:
|
||||
seg.rate_ratio > 1.15 ? '#fb4934' : seg.rate_ratio < 0.85 ? '#83a598' : '#a89984',
|
||||
}}
|
||||
title={t('segment.rate_title', {
|
||||
ratio: seg.rate_ratio.toFixed(2),
|
||||
error: seg.rate_error || '',
|
||||
})}
|
||||
>
|
||||
📖 {seg.rate_ratio.toFixed(2)}×
|
||||
</span>
|
||||
)}
|
||||
{/* Pre-synthesis duration plan (backend duration_planner): warn about
|
||||
tight/impossible segments BEFORE GPU time is spent. Informational
|
||||
only — generation is never blocked. */}
|
||||
{seg.plan && (seg.plan.status === 'tight' || seg.plan.status === 'impossible') && (
|
||||
<span
|
||||
className="text-[0.48rem] mt-[1px] inline-flex items-center gap-[1px]"
|
||||
style={{ color: seg.plan.status === 'impossible' ? '#fb4934' : '#fabd2f' }}
|
||||
title={t(
|
||||
seg.plan.status === 'impossible'
|
||||
? 'segment.plan_impossible_title'
|
||||
: 'segment.plan_tight_title',
|
||||
{
|
||||
est: (seg.plan.est_dur_s || 0).toFixed(1),
|
||||
avail: (seg.plan.available_s || 0).toFixed(1),
|
||||
seconds: (seg.plan.est_overrun_s || 0).toFixed(1),
|
||||
},
|
||||
)}
|
||||
>
|
||||
<AlertCircle size={8} />{' '}
|
||||
{seg.plan.status === 'impossible'
|
||||
? t('segment.plan_impossible', {
|
||||
seconds: (seg.plan.est_overrun_s || 0).toFixed(1),
|
||||
})
|
||||
: t('segment.plan_tight')}
|
||||
</span>
|
||||
)}
|
||||
{seg.plan && seg.plan.suggested_text && seg.plan.suggested_text !== seg.text && (
|
||||
<button
|
||||
onClick={() => onEditField(seg.id, 'text', seg.plan.suggested_text)}
|
||||
disabled={disabled}
|
||||
title={t('segment.plan_apply_title', { text: seg.plan.suggested_text })}
|
||||
className="bg-transparent border-none text-[#83a598] cursor-pointer p-0 mt-[1px] text-[0.48rem] text-left"
|
||||
>
|
||||
✂ {t('segment.plan_apply')}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<input
|
||||
className="seg-speaker-input"
|
||||
value={seg.speaker_id || ''}
|
||||
onChange={(e) => onEditField(seg.id, 'speaker_id', e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={disabled}
|
||||
list={speakerOptions.length ? speakerListId : undefined}
|
||||
placeholder={speakerOptions.length ? t('segment.speaker_pick') : ''}
|
||||
title={
|
||||
speakerOptions.length
|
||||
? t('segment.speaker_title_detected')
|
||||
: t('segment.speaker_title_custom')
|
||||
}
|
||||
/>
|
||||
{speakerOptions.length > 0 && (
|
||||
<datalist id={speakerListId}>
|
||||
{speakerOptions.map((spk) => (
|
||||
<option key={spk} value={spk} />
|
||||
))}
|
||||
</datalist>
|
||||
)}
|
||||
|
||||
<span className="seg-text-col">
|
||||
<span className="flex items-center gap-[2px] min-w-0">
|
||||
<input
|
||||
<textarea
|
||||
ref={textInputRef}
|
||||
rows={2}
|
||||
className="input-base segment-input"
|
||||
value={seg.text}
|
||||
onChange={(e) => {
|
||||
@@ -467,12 +328,12 @@ function DubSegmentRow({
|
||||
onLiveToggle?.(seg);
|
||||
}}
|
||||
>
|
||||
<Volume2 size={9} />
|
||||
<Volume2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
{seg.text_original && seg.text_original !== seg.text && (
|
||||
<span className="text-[0.52rem] text-[#6b6657] flex items-center gap-[3px] px-[2px] overflow-hidden">
|
||||
<span className="seg-original flex items-center gap-[6px] px-[2px] overflow-hidden">
|
||||
<span className="opacity-80 uppercase font-semibold text-[0.48rem] text-[#7c6f64]">
|
||||
{t('segment.orig_label')}
|
||||
</span>
|
||||
@@ -499,139 +360,280 @@ function DubSegmentRow({
|
||||
)}
|
||||
</span>
|
||||
|
||||
<select
|
||||
className="input-base seg-lang-select"
|
||||
value={seg.target_lang || ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onEditField(seg.id, 'target_lang', e.target.value)}
|
||||
>
|
||||
<option value="">{t('segment.lang_default')}</option>
|
||||
{LANG_CODES.map((lc) => (
|
||||
<option key={lc.code} value={lc.code}>
|
||||
{lc.code.toUpperCase()}
|
||||
</option>
|
||||
<span className="segment-time flex flex-col min-w-0 overflow-hidden tabular-nums">
|
||||
{['start', 'end'].map((edge) => (
|
||||
<span className="seg-time-stepper" key={`${edge}-${seg.id}-${seg[edge]}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nudgeTime(edge, -0.1)}
|
||||
disabled={disabled || (edge === 'start' && seg.start <= 0)}
|
||||
aria-label={t(`segment.time_nudge_${edge}_earlier`)}
|
||||
>
|
||||
<Minus size={10} />
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
className="seg-time-input"
|
||||
defaultValue={formatTime(seg[edge])}
|
||||
disabled={disabled}
|
||||
title={t(
|
||||
edge === 'start' ? 'segment.time_edit_title' : 'segment.time_edit_end_title',
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={timeKeyDown(edge)}
|
||||
onBlur={commitTime(edge)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nudgeTime(edge, 0.1)}
|
||||
disabled={disabled}
|
||||
aria-label={t(`segment.time_nudge_${edge}_later`)}
|
||||
>
|
||||
<Plus size={10} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</select>
|
||||
{seg.speed && seg.speed !== 1.0 && (
|
||||
<span
|
||||
className="text-[0.52rem] ml-[1px]"
|
||||
style={{ color: seg.speed > 1 ? '#d3869b' : '#8ec07c' }}
|
||||
>
|
||||
{seg.speed.toFixed(2)}x
|
||||
</span>
|
||||
)}
|
||||
{hasOverlap && (
|
||||
<span className="seg-overlap-warning" title={t('timeline.overlap_warning')}>
|
||||
<AlertCircle size={14} /> {t('timeline.overlap_warning')}
|
||||
</span>
|
||||
)}
|
||||
{fitBadge && (
|
||||
<span className="seg-status" style={{ color: fitBadge.color }} title={fitBadge.title}>
|
||||
<fitBadge.Icon size={8} /> {fitBadge.label}
|
||||
</span>
|
||||
)}
|
||||
{seg.qc_flagged && (
|
||||
// Wave 3.3: second-pass ASR heard something different from the
|
||||
// target text for this line — worth a re-listen / re-dub.
|
||||
<span
|
||||
className="seg-status"
|
||||
style={{ color: '#fb4934' }}
|
||||
title={t('segment.qc_verify_title', { heard: seg.qc_recognized || '' })}
|
||||
>
|
||||
<AlertCircle size={8} /> {t('segment.qc_verify')}
|
||||
</span>
|
||||
)}
|
||||
{seg.rate_ratio != null && Math.abs(seg.rate_ratio - 1.0) > 0.03 && (
|
||||
<span
|
||||
className="seg-status tabular-nums"
|
||||
style={{
|
||||
color:
|
||||
seg.rate_ratio > 1.15 ? '#fb4934' : seg.rate_ratio < 0.85 ? '#83a598' : '#a89984',
|
||||
}}
|
||||
title={t('segment.rate_title', {
|
||||
ratio: seg.rate_ratio.toFixed(2),
|
||||
error: seg.rate_error || '',
|
||||
})}
|
||||
>
|
||||
<BookOpen size={12} aria-hidden="true" /> {seg.rate_ratio.toFixed(2)}×
|
||||
</span>
|
||||
)}
|
||||
{/* Pre-synthesis duration plan (backend duration_planner): warn about
|
||||
tight/impossible segments BEFORE GPU time is spent. Informational
|
||||
only — generation is never blocked. */}
|
||||
{seg.plan && (seg.plan.status === 'tight' || seg.plan.status === 'impossible') && (
|
||||
<span
|
||||
className="seg-status"
|
||||
style={{ color: seg.plan.status === 'impossible' ? '#fb4934' : '#fabd2f' }}
|
||||
title={t(
|
||||
seg.plan.status === 'impossible'
|
||||
? 'segment.plan_impossible_title'
|
||||
: 'segment.plan_tight_title',
|
||||
{
|
||||
est: (seg.plan.est_dur_s || 0).toFixed(1),
|
||||
avail: (seg.plan.available_s || 0).toFixed(1),
|
||||
seconds: (seg.plan.est_overrun_s || 0).toFixed(1),
|
||||
},
|
||||
)}
|
||||
>
|
||||
<AlertCircle size={8} />{' '}
|
||||
{seg.plan.status === 'impossible'
|
||||
? t('segment.plan_impossible', {
|
||||
seconds: (seg.plan.est_overrun_s || 0).toFixed(1),
|
||||
})
|
||||
: t('segment.plan_tight')}
|
||||
</span>
|
||||
)}
|
||||
{seg.plan && seg.plan.suggested_text && seg.plan.suggested_text !== seg.text && (
|
||||
<button
|
||||
onClick={() => onEditField(seg.id, 'text', seg.plan.suggested_text)}
|
||||
disabled={disabled}
|
||||
title={t('segment.plan_apply_title', { text: seg.plan.suggested_text })}
|
||||
className="seg-plan-apply"
|
||||
>
|
||||
<Scissors size={12} aria-hidden="true" /> {t('segment.plan_apply')}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Shared gallery-enabled picker (#1220). `data-noseek` + stopPropagation
|
||||
<div className="seg-controls">
|
||||
<input
|
||||
className="seg-speaker-input"
|
||||
value={seg.speaker_id || ''}
|
||||
onChange={(e) => onEditField(seg.id, 'speaker_id', e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={disabled}
|
||||
list={speakerOptions.length ? speakerListId : undefined}
|
||||
placeholder={speakerOptions.length ? t('segment.speaker_pick') : ''}
|
||||
title={
|
||||
speakerOptions.length
|
||||
? t('segment.speaker_title_detected')
|
||||
: t('segment.speaker_title_custom')
|
||||
}
|
||||
/>
|
||||
{speakerOptions.length > 0 && (
|
||||
<datalist id={speakerListId}>
|
||||
{speakerOptions.map((spk) => (
|
||||
<option key={spk} value={spk} />
|
||||
))}
|
||||
</datalist>
|
||||
)}
|
||||
|
||||
<select
|
||||
className="input-base seg-lang-select"
|
||||
aria-label={t('segment.lang')}
|
||||
value={seg.target_lang || ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onEditField(seg.id, 'target_lang', e.target.value)}
|
||||
>
|
||||
<option value="">{t('segment.lang_default')}</option>
|
||||
{LANG_CODES.map((lc) => (
|
||||
<option key={lc.code} value={lc.code}>
|
||||
{lc.code.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Shared gallery-enabled picker (#1220). `data-noseek` + stopPropagation
|
||||
keep a voice pick from also seeking the player (handleRowClick). The
|
||||
dropdown portals to <body> so it isn't clipped by the react-window
|
||||
virtualized row's overflow. The from-video `auto:<slug>` options come
|
||||
through `speakerClones`; the legacy hardcoded design PRESETS group is
|
||||
intentionally dropped — the Gallery supersedes it (see #1220). */}
|
||||
<span className="seg-voice-col min-w-0" data-noseek onClick={(e) => e.stopPropagation()}>
|
||||
<VoiceSelector
|
||||
value={seg.profile_id || ''}
|
||||
onChange={(v) => onEditField(seg.id, 'profile_id', v)}
|
||||
profiles={profiles}
|
||||
speakerClones={speakerClones}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
menuPortal
|
||||
buttonClassName="input-base seg-profile-select"
|
||||
defaultLabel={t('segment.voice_default')}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
value={Math.round((seg.gain ?? 1.0) * 100)}
|
||||
title={`${Math.round((seg.gain ?? 1.0) * 100)}%`}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onEditField(seg.id, 'gain', Number(e.target.value) / 100)}
|
||||
className="seg-gain-slider"
|
||||
style={{
|
||||
accentColor:
|
||||
(seg.gain ?? 1.0) > 1.2
|
||||
? 'var(--color-danger)'
|
||||
: (seg.gain ?? 1.0) < 0.5
|
||||
? 'var(--color-info)'
|
||||
: 'var(--color-fg-muted)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="seg-actions">
|
||||
<button
|
||||
className="segment-play"
|
||||
disabled={disabled}
|
||||
title={t('segment.preview_title')}
|
||||
onClick={(e) => onPreview(seg, e)}
|
||||
>
|
||||
{previewLoading ? <Loader className="spinner" size={9} /> : <Headphones size={9} />}
|
||||
</button>
|
||||
<Menu
|
||||
placement="bottom-end"
|
||||
disabled={disabled}
|
||||
items={[
|
||||
{
|
||||
id: 'direct',
|
||||
label: seg.direction ? t('segment.edit_direction') : t('segment.set_direction'),
|
||||
icon: Sparkles,
|
||||
onSelect: () => onDirect?.(seg),
|
||||
},
|
||||
'separator',
|
||||
{
|
||||
id: 'split',
|
||||
label: t('segment.split_label'),
|
||||
icon: Scissors,
|
||||
shortcut: '⌘D',
|
||||
onSelect: () => {
|
||||
// Prefer the live cursor if the text input still has focus,
|
||||
// otherwise fall back to the last remembered position, then
|
||||
// to a sentence-boundary heuristic. Splitting at the literal
|
||||
// midpoint (the old behaviour) felt random to users.
|
||||
let pos = textInputRef.current?.selectionStart;
|
||||
if (pos == null || pos <= 0 || pos >= seg.text.length) {
|
||||
pos = lastCursorRef.current;
|
||||
}
|
||||
if (pos == null || pos <= 0 || pos >= seg.text.length) {
|
||||
pos = bestSplitPoint(seg.text);
|
||||
}
|
||||
onSplit(seg.id, pos);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'merge-prev',
|
||||
label: t('segment.merge_prev_label'),
|
||||
icon: Merge,
|
||||
shortcut: '⇧⌘M',
|
||||
disabled: !canMergePrev,
|
||||
onSelect: () => onMerge(seg.id, 'prev'),
|
||||
},
|
||||
{
|
||||
id: 'merge',
|
||||
label: t('segment.merge_label'),
|
||||
icon: Merge,
|
||||
shortcut: '⌘M',
|
||||
disabled: !canMerge,
|
||||
onSelect: () => onMerge(seg.id, 'next'),
|
||||
},
|
||||
'separator',
|
||||
{
|
||||
id: 'insert',
|
||||
label: t('segment.insert_label'),
|
||||
icon: Plus,
|
||||
onSelect: () => onInsert(seg.id),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<button
|
||||
className={`segment-play ${seg.direction ? 'has-direction' : ''}`}
|
||||
<span className="seg-voice-col min-w-0" data-noseek onClick={(e) => e.stopPropagation()}>
|
||||
<VoiceSelector
|
||||
value={seg.profile_id || ''}
|
||||
onChange={(v) => onEditField(seg.id, 'profile_id', v)}
|
||||
profiles={profiles}
|
||||
speakerClones={speakerClones}
|
||||
disabled={disabled}
|
||||
title={
|
||||
seg.direction
|
||||
? t('segment.direction_title', { dir: seg.direction })
|
||||
: t('segment.more_actions_title')
|
||||
}
|
||||
size="sm"
|
||||
menuPortal
|
||||
buttonClassName="input-base seg-profile-select"
|
||||
defaultLabel={t('segment.voice_default')}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
value={Math.round((seg.gain ?? 1.0) * 100)}
|
||||
title={`${Math.round((seg.gain ?? 1.0) * 100)}%`}
|
||||
aria-label={t('segment.vol_title')}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onEditField(seg.id, 'gain', Number(e.target.value) / 100)}
|
||||
className="seg-gain-slider"
|
||||
style={{
|
||||
accentColor:
|
||||
(seg.gain ?? 1.0) > 1.2
|
||||
? 'var(--color-danger)'
|
||||
: (seg.gain ?? 1.0) < 0.5
|
||||
? 'var(--color-info)'
|
||||
: 'var(--color-fg-muted)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="seg-actions">
|
||||
<button
|
||||
className="segment-play"
|
||||
disabled={disabled}
|
||||
title={t('segment.preview_title')}
|
||||
onClick={(e) => onPreview(seg, e)}
|
||||
>
|
||||
{seg.direction ? <Sparkles size={9} /> : <MoreHorizontal size={9} />}
|
||||
{previewLoading ? <Loader className="spinner" size={14} /> : <Headphones size={14} />}
|
||||
</button>
|
||||
</Menu>
|
||||
<button className="segment-del" disabled={disabled} onClick={() => onDelete(seg.id)}>
|
||||
<Trash2 size={9} />
|
||||
</button>
|
||||
<Menu
|
||||
placement="bottom-end"
|
||||
disabled={disabled}
|
||||
items={[
|
||||
{
|
||||
id: 'direct',
|
||||
label: seg.direction ? t('segment.edit_direction') : t('segment.set_direction'),
|
||||
icon: Sparkles,
|
||||
onSelect: () => onDirect?.(seg),
|
||||
},
|
||||
'separator',
|
||||
{
|
||||
id: 'split',
|
||||
label: t('segment.split_label'),
|
||||
icon: Scissors,
|
||||
shortcut: '⌘D',
|
||||
onSelect: () => {
|
||||
// Prefer the live cursor if the text input still has focus,
|
||||
// otherwise fall back to the last remembered position, then
|
||||
// to a sentence-boundary heuristic. Splitting at the literal
|
||||
// midpoint (the old behaviour) felt random to users.
|
||||
let pos = textInputRef.current?.selectionStart;
|
||||
if (pos == null || pos <= 0 || pos >= seg.text.length) {
|
||||
pos = lastCursorRef.current;
|
||||
}
|
||||
if (pos == null || pos <= 0 || pos >= seg.text.length) {
|
||||
pos = bestSplitPoint(seg.text);
|
||||
}
|
||||
onSplit(seg.id, pos);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'merge-prev',
|
||||
label: t('segment.merge_prev_label'),
|
||||
icon: Merge,
|
||||
shortcut: '⇧⌘M',
|
||||
disabled: !canMergePrev,
|
||||
onSelect: () => onMerge(seg.id, 'prev'),
|
||||
},
|
||||
{
|
||||
id: 'merge',
|
||||
label: t('segment.merge_label'),
|
||||
icon: Merge,
|
||||
shortcut: '⌘M',
|
||||
disabled: !canMerge,
|
||||
onSelect: () => onMerge(seg.id, 'next'),
|
||||
},
|
||||
'separator',
|
||||
{
|
||||
id: 'insert',
|
||||
label: t('segment.insert_label'),
|
||||
icon: Plus,
|
||||
onSelect: () => onInsert(seg.id),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<button
|
||||
className={`segment-play ${seg.direction ? 'has-direction' : ''}`}
|
||||
disabled={disabled}
|
||||
title={
|
||||
seg.direction
|
||||
? t('segment.direction_title', { dir: seg.direction })
|
||||
: t('segment.more_actions_title')
|
||||
}
|
||||
>
|
||||
{seg.direction ? <Sparkles size={14} /> : <MoreHorizontal size={14} />}
|
||||
</button>
|
||||
</Menu>
|
||||
<button className="segment-del" disabled={disabled} onClick={() => onDelete(seg.id)}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -640,6 +642,9 @@ function DubSegmentRow({
|
||||
export default memo(
|
||||
DubSegmentRow,
|
||||
(prev, next) =>
|
||||
prev.style === next.style &&
|
||||
prev.ariaAttributes === next.ariaAttributes &&
|
||||
prev.hasOverlap === next.hasOverlap &&
|
||||
prev.seg === next.seg &&
|
||||
prev.disabled === next.disabled &&
|
||||
prev.isActive === next.isActive &&
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { List } from 'react-window';
|
||||
import { List, useDynamicRowHeight } from 'react-window';
|
||||
import DubSegmentRow from './DubSegmentRow';
|
||||
import { Table, Select } from '../ui';
|
||||
import { Table } from '../ui';
|
||||
import { Headphones } from 'lucide-react';
|
||||
import SearchableSelect from './SearchableSelect';
|
||||
import DubToggle from './dub/DubToggle';
|
||||
import { useAppStore } from '../store';
|
||||
import { visibleMergeAvailability } from '../utils/segmentParts';
|
||||
import useDubLivePreview from '../hooks/useDubLivePreview';
|
||||
|
||||
const BASE_ROW_HEIGHT = 48;
|
||||
const ROW_HEIGHT_WITH_ORIG = 62;
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: 'time', width: 50 },
|
||||
{ key: 'spkr', width: 45 },
|
||||
{ key: 'text', flex: 1 },
|
||||
{ key: 'lang', width: 42 },
|
||||
{ key: 'voice', width: 60 },
|
||||
{ key: 'vol', width: 40 },
|
||||
{ key: 'act', width: 42 },
|
||||
];
|
||||
|
||||
export default function DubSegmentTable({
|
||||
segments,
|
||||
profiles,
|
||||
@@ -65,12 +55,6 @@ export default function DubSegmentTable({
|
||||
// below the `filtered` memo so it can depend on it without TDZ.)
|
||||
const listRef = useRef(null);
|
||||
|
||||
const columns = COLUMNS.map((c) => {
|
||||
if (c.key === 'vol') return { ...c, label: t('segment.vol'), title: t('segment.vol_title') };
|
||||
if (c.key === 'act') return { ...c, label: '' };
|
||||
return { ...c, label: t(`segment.${c.key}`) };
|
||||
});
|
||||
|
||||
const bodyRef = useRef(null);
|
||||
const [bodyHeight, setBodyHeight] = useState(0);
|
||||
useLayoutEffect(() => {
|
||||
@@ -138,14 +122,8 @@ export default function DubSegmentTable({
|
||||
}
|
||||
}, [timelineSelectedId, filtered]);
|
||||
|
||||
const rowHeight = useCallback(
|
||||
(index) => {
|
||||
const s = filtered[index];
|
||||
if (!s) return BASE_ROW_HEIGHT;
|
||||
return s.text_original && s.text_original !== s.text ? ROW_HEIGHT_WITH_ORIG : BASE_ROW_HEIGHT;
|
||||
},
|
||||
[filtered],
|
||||
);
|
||||
// Wrapped controls and translated status badges can change a row's height.
|
||||
const rowHeight = useDynamicRowHeight({ defaultRowHeight: 160 });
|
||||
|
||||
const rowProps = useMemo(
|
||||
() => ({
|
||||
@@ -210,6 +188,7 @@ export default function DubSegmentTable({
|
||||
({
|
||||
index,
|
||||
style,
|
||||
ariaAttributes,
|
||||
filtered: fl,
|
||||
profiles: profs,
|
||||
speakerClones: clones,
|
||||
@@ -259,6 +238,7 @@ export default function DubSegmentTable({
|
||||
seg={seg}
|
||||
idx={index}
|
||||
style={style}
|
||||
ariaAttributes={ariaAttributes}
|
||||
disabled={dis}
|
||||
isActive={isActive}
|
||||
isDone={isDone}
|
||||
@@ -315,35 +295,31 @@ export default function DubSegmentTable({
|
||||
searchPlaceholder={t('segment.search_placeholder')}
|
||||
meta={meta}
|
||||
>
|
||||
<label className="dub-live-toggle" title={t('dub.live_preview_title')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-[var(--color-brand)]"
|
||||
checked={!!livePreviewOn}
|
||||
onChange={(e) => setDubLivePreview(e.target.checked)}
|
||||
/>
|
||||
{t('dub.live_preview')}
|
||||
</label>
|
||||
<DubToggle
|
||||
label={t('dub.live_preview')}
|
||||
title={t('dub.live_preview_title')}
|
||||
Icon={Headphones}
|
||||
checked={livePreviewOn}
|
||||
onChange={setDubLivePreview}
|
||||
/>
|
||||
{speakers.length > 1 && (
|
||||
<Select
|
||||
size="sm"
|
||||
<SearchableSelect
|
||||
menuPortal
|
||||
ariaLabel={t('segment.all_speakers')}
|
||||
value={speakerFilter}
|
||||
onChange={(e) => setSpeakerFilter(e.target.value)}
|
||||
className="dub-segment-table__spk-filter"
|
||||
>
|
||||
<option value="">{t('segment.all_speakers')}</option>
|
||||
{speakers.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
onChange={setSpeakerFilter}
|
||||
buttonClassName="min-h-10 rounded-lg border-0 px-3 text-sm bg-[var(--chrome-hover-bg)] text-[var(--chrome-fg)]"
|
||||
options={[
|
||||
{ value: '', label: t('segment.all_speakers') },
|
||||
...speakers.map((s) => ({ value: s, label: s })),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Table.Toolbar>
|
||||
|
||||
<Table.Header
|
||||
className="dub-segment-table__header"
|
||||
columns={columns}
|
||||
columns={[{ key: 'text', label: t('segment.text'), flex: 1 }]}
|
||||
leading={
|
||||
<span className="dub-segment-table__select-all">
|
||||
<input
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useEngines, useSelectEngine } from '../api/hooks';
|
||||
import { notifyEngineSelected } from '../utils/engineSelectToast';
|
||||
import { useAppStore } from '../store';
|
||||
import { MENU_SURFACE } from './computeTarget';
|
||||
import { engineDisplayName } from '../utils/engineDisplayName';
|
||||
|
||||
/**
|
||||
* A compact TTS/ASR/LLM picker for chrome that needs to expose the active
|
||||
@@ -22,6 +23,7 @@ export default function EngineQuickSwitch({
|
||||
// top of the viewport.
|
||||
dropUp = false,
|
||||
prominent = false,
|
||||
embedded = false,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rootRef = useRef(null);
|
||||
@@ -34,7 +36,7 @@ export default function EngineQuickSwitch({
|
||||
queryFn: listLoadedModels,
|
||||
staleTime: 10_000,
|
||||
retry: false,
|
||||
enabled: open,
|
||||
enabled: open || embedded,
|
||||
});
|
||||
|
||||
const familyData = engines?.[family];
|
||||
@@ -81,6 +83,7 @@ export default function EngineQuickSwitch({
|
||||
|
||||
if (!active || available.length === 0) return null;
|
||||
|
||||
const activeName = engineDisplayName(active.display_name);
|
||||
const locked = Boolean(familyData.env_override);
|
||||
const choose = async (backendId) => {
|
||||
if (backendId === familyData.active || locked) return;
|
||||
@@ -99,38 +102,47 @@ export default function EngineQuickSwitch({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative inline-flex shrink-0 items-center ${className}`} ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSwitchError('');
|
||||
setOpen((value) => !value);
|
||||
}}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
title={t('engines.activeEngine', {
|
||||
family: family.toUpperCase(),
|
||||
engine: active.display_name,
|
||||
})}
|
||||
aria-label={t('engines.activeEngine', {
|
||||
family: family.toUpperCase(),
|
||||
engine: active.display_name,
|
||||
})}
|
||||
className={`inline-flex items-center gap-[5px] rounded-sm border-0 bg-transparent px-[7px] font-medium text-[color:var(--chrome-fg-muted)] transition-[background,color] hover:bg-[var(--chrome-hover-bg)] hover:text-[color:var(--chrome-fg)] ${prominent ? 'min-h-11 text-sm text-left' : 'h-[20px] text-[11px]'}`}
|
||||
>
|
||||
<Cpu size={13} aria-hidden="true" />
|
||||
<span className={prominent ? 'min-w-0 break-words' : 'max-w-[124px] truncate'}>
|
||||
{active.display_name}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
className={`${embedded ? 'block w-full' : 'relative inline-flex shrink-0 items-center'} ${prominent ? 'self-start max-w-full' : ''} ${className}`}
|
||||
ref={rootRef}
|
||||
>
|
||||
{!embedded && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSwitchError('');
|
||||
setOpen((value) => !value);
|
||||
}}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
title={t('engines.activeEngine', {
|
||||
family: family.toUpperCase(),
|
||||
engine: activeName,
|
||||
})}
|
||||
aria-label={t('engines.activeEngine', {
|
||||
family: family.toUpperCase(),
|
||||
engine: activeName,
|
||||
})}
|
||||
className={`inline-flex items-center gap-[5px] rounded-sm border-0 bg-transparent px-[7px] font-medium text-[color:var(--chrome-fg-muted)] transition-[background,color] hover:bg-[var(--chrome-hover-bg)] hover:text-[color:var(--chrome-fg)] ${prominent ? 'min-h-11 text-sm text-left' : 'h-[20px] text-[11px]'}`}
|
||||
>
|
||||
<Cpu size={13} aria-hidden="true" />
|
||||
<span className={prominent ? 'min-w-0 break-words' : 'max-w-[124px] truncate'}>
|
||||
{activeName}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
{(open || embedded) && (
|
||||
<div
|
||||
role="dialog"
|
||||
role={embedded ? 'group' : 'dialog'}
|
||||
aria-label={t('engines.engineCompatLabel', { family: family.toUpperCase() })}
|
||||
className={`absolute right-0 z-[60] flex w-[272px] flex-col gap-[4px] p-[8px] ${
|
||||
dropUp ? 'bottom-[calc(100%+8px)]' : 'top-[calc(100%+8px)]'
|
||||
} ${MENU_SURFACE}`}
|
||||
className={
|
||||
embedded
|
||||
? 'flex w-full min-w-0 flex-col gap-1 py-2'
|
||||
: `absolute ${prominent ? 'left-0' : 'right-0'} z-[60] flex w-[272px] max-w-[calc(100vw-32px)] flex-col gap-[4px] p-[8px] ${
|
||||
dropUp ? 'bottom-[calc(100%+8px)]' : 'top-[calc(100%+8px)]'
|
||||
} ${MENU_SURFACE}`
|
||||
}
|
||||
>
|
||||
{locked && (
|
||||
<p className="m-[4px] text-[11px] leading-[1.4] text-[color:var(--chrome-fg-muted)]">
|
||||
@@ -140,20 +152,45 @@ export default function EngineQuickSwitch({
|
||||
{available.map((engine) => {
|
||||
const isActive = engine.id === familyData.active;
|
||||
const warm = residentIds.has(engine.id);
|
||||
const displayName = engineDisplayName(engine.display_name);
|
||||
const parts = displayName.match(/^(.+?)\s*\((.*)\)$/);
|
||||
return (
|
||||
<button
|
||||
key={engine.id}
|
||||
type="button"
|
||||
disabled={isActive || locked || selectMutation.isPending}
|
||||
onClick={() => choose(engine.id)}
|
||||
className="flex w-full items-center gap-[8px] rounded-[5px] border-0 bg-transparent px-[7px] py-[6px] text-left text-[11px] text-[color:var(--chrome-fg)] hover:bg-[var(--chrome-hover-bg)] disabled:cursor-default disabled:opacity-60"
|
||||
className={`flex w-full items-center gap-2 rounded-md border-0 px-2 py-3 text-left text-xs text-[color:var(--chrome-fg)] hover:bg-[var(--chrome-hover-bg)] disabled:cursor-default ${embedded && isActive ? 'bg-[var(--chrome-accent-bg)]' : 'bg-transparent'}`}
|
||||
>
|
||||
<span className="w-[12px] shrink-0">
|
||||
{isActive && <Check size={12} aria-label={t('engines.active')} />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{engine.display_name}</span>
|
||||
<span
|
||||
className={
|
||||
embedded
|
||||
? 'min-w-0 flex-1 whitespace-normal break-words'
|
||||
: 'min-w-0 flex-1 truncate'
|
||||
}
|
||||
>
|
||||
{embedded && parts ? (
|
||||
<>
|
||||
<span className="block font-medium text-sm">{parts[1]}</span>
|
||||
<span className="block mt-1 text-xs leading-relaxed text-[var(--chrome-fg-muted)]">
|
||||
{parts[2]}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
displayName
|
||||
)}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-[color:var(--chrome-fg-muted)]">
|
||||
{warm ? t('engines.inMemory') : t('engines.available')}
|
||||
{warm
|
||||
? t('engines.inMemory')
|
||||
: isActive
|
||||
? t('engines.active')
|
||||
: embedded
|
||||
? ''
|
||||
: t('engines.available')}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@@ -166,16 +203,18 @@ export default function EngineQuickSwitch({
|
||||
{switchError}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
useAppStore.getState().openCatalogue({ pane: 'engines', family });
|
||||
}}
|
||||
className="mt-[3px] flex items-center gap-[3px] border-0 bg-transparent px-[7px] py-[5px] text-left text-[11px] text-[color:var(--chrome-fg-muted)] hover:text-[color:var(--chrome-fg)]"
|
||||
>
|
||||
{t('settings.engines')} <ChevronRight size={12} aria-hidden="true" />
|
||||
</button>
|
||||
{!embedded && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
useAppStore.getState().openCatalogue({ pane: 'engines', family });
|
||||
}}
|
||||
className="mt-[3px] flex items-center gap-[3px] border-0 bg-transparent px-[7px] py-[5px] text-left text-[11px] text-[color:var(--chrome-fg-muted)] hover:text-[color:var(--chrome-fg)]"
|
||||
>
|
||||
{t('settings.engines')} <ChevronRight size={12} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -39,12 +39,47 @@ function renderPicker(props = {}) {
|
||||
}
|
||||
|
||||
describe('EngineQuickSwitch', () => {
|
||||
it('embeds engine choices without another popup trigger', async () => {
|
||||
renderPicker({ embedded: true });
|
||||
expect(await screen.findByText('IndexTTS 2')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /active tts:/i })).not.toBeInTheDocument();
|
||||
});
|
||||
it('anchors the prominent menu to the left-hand engine button', async () => {
|
||||
renderPicker({ prominent: true });
|
||||
fireEvent.click(await screen.findByRole('button', { name: /active tts: omnivoice/i }));
|
||||
expect(screen.getByRole('dialog')).toHaveClass('left-0');
|
||||
expect(screen.getByRole('dialog')).not.toHaveClass('right-0');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listEngines.mockResolvedValue(inventory());
|
||||
listLoadedModels.mockResolvedValue({ models: [{ engine_id: 'omnivoice' }] });
|
||||
});
|
||||
|
||||
it.each(['VoiceStudio', 'VoiceStudio TTS', 'VoiceStudio (k2-fsa/OmniVoice)'])(
|
||||
'formats the active name consistently for %s',
|
||||
async (name) => {
|
||||
const data = inventory();
|
||||
data.tts.backends[0].display_name = name;
|
||||
listEngines.mockResolvedValue(data);
|
||||
const expected = name.replace('VoiceStudio', 'OmniVoice');
|
||||
const { unmount } = renderPicker();
|
||||
const trigger = await screen.findByRole('button', {
|
||||
name: `Active TTS: ${expected}`,
|
||||
});
|
||||
expect(trigger).toHaveAttribute('title', `Active TTS: ${expected}`);
|
||||
expect(trigger).toHaveTextContent(expected);
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByRole('dialog')).not.toHaveTextContent('VoiceStudio');
|
||||
unmount();
|
||||
renderPicker({ embedded: true });
|
||||
expect(await screen.findByRole('group')).not.toHaveTextContent('VoiceStudio');
|
||||
expect(screen.getByRole('group')).toHaveTextContent('OmniVoice');
|
||||
},
|
||||
);
|
||||
|
||||
it('lists only available engines and annotates residency', async () => {
|
||||
renderPicker();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /active tts: omnivoice/i }));
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.export-drawer > header { padding: 18px 20px; border: 0; }
|
||||
.export-drawer > header > span:not([aria-hidden]) { min-width: 0; }
|
||||
.export-drawer > header > button { min-width: 36px; min-height: 36px; flex-shrink: 0; }
|
||||
.export-tabs button { justify-content: center; min-height: 42px; border: 0; border-radius: 6px; }
|
||||
.export-tabs button svg { width: 16px; height: 16px; }
|
||||
.export-tabs .export-tab-active { background: var(--chrome-accent-bg); }
|
||||
.export-fields > .grid { grid-template-columns: repeat(auto-fit,minmax(min(100%,260px),1fr)); gap: 12px; }
|
||||
.export-field .ui-seg { flex-wrap: wrap; align-self: flex-start; border-radius: 8px; }
|
||||
.export-field .ui-seg button { min-height: 34px; font-size: 12px; }
|
||||
.export-field .dub-setting-toggle { text-align: left; line-height: 1.5; padding-inline: 0; }
|
||||
.export-summary > div:first-child { flex: 1; flex-direction: column; align-items: flex-start; }
|
||||
.export-summary code { max-width: 100%; white-space: normal; overflow-wrap: anywhere; font-size: 12px; }
|
||||
.export-summary button { min-height: 40px; }
|
||||
@media (max-width: 520px) {
|
||||
.export-summary { flex-direction: column; align-items: stretch; }
|
||||
.export-summary > div:last-child { justify-content: flex-end; }
|
||||
.export-tabs button { padding-inline: 4px; font-size: 12px; gap: 4px; }
|
||||
}
|
||||
@@ -16,17 +16,16 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Button, Segmented } from '../ui';
|
||||
import TrackManager from './dub/TrackManager';
|
||||
import DubToggle from './dub/DubToggle';
|
||||
import SearchableSelect from './SearchableSelect';
|
||||
import './ExportModal.css';
|
||||
|
||||
const TAB_BASE =
|
||||
'inline-flex items-center gap-[6px] px-[12px] py-[6px] bg-transparent border-0 border-b-2 cursor-pointer text-[length:var(--text-sm)] transition-[color,border-color] duration-[var(--dur-fast)]';
|
||||
const tabCls = (active) =>
|
||||
active
|
||||
? `${TAB_BASE} border-b-[var(--chrome-accent)] text-[var(--chrome-accent)]`
|
||||
? `${TAB_BASE} export-tab-active border-b-[var(--chrome-accent)] text-[var(--chrome-accent)]`
|
||||
: `${TAB_BASE} border-b-transparent text-[var(--chrome-fg-muted)] hover:text-[var(--chrome-fg)]`;
|
||||
const TOGGLE_CLS =
|
||||
'inline-flex items-center gap-[6px] text-[length:var(--text-sm)] text-[var(--chrome-fg)] cursor-pointer [&_input]:accent-[var(--color-brand)]';
|
||||
const TOGGLE_INDENT_CLS =
|
||||
'inline-flex items-center gap-[6px] ml-[var(--space-4)] text-[length:var(--text-sm)] text-[var(--chrome-fg-muted)] cursor-pointer [&_input]:accent-[var(--color-brand)]';
|
||||
|
||||
/**
|
||||
* ExportModal — comprehensive export panel for the dubbing studio.
|
||||
@@ -126,15 +125,18 @@ export default function ExportModal({
|
||||
// ── Drawer dismiss — ESC closes; click-outside closes. The drawer is a
|
||||
// bottom sheet (non-blocking), so background interactions stay live.
|
||||
const drawerRef = useRef(null);
|
||||
const lastDrawerMouseDownRef = useRef(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (e.key === 'Escape' && !e.defaultPrevented) {
|
||||
e.stopPropagation();
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
const onDown = (e) => {
|
||||
// React capture includes portaled descendants, unlike DOM contains().
|
||||
if (lastDrawerMouseDownRef.current === e) return;
|
||||
if (drawerRef.current && !drawerRef.current.contains(e.target)) onClose?.();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
@@ -278,8 +280,11 @@ export default function ExportModal({
|
||||
aria-label={t('exportModal.export_options')}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-auto flex w-[min(880px,calc(100vw-24px))] max-h-[min(70vh,560px)] flex-col overflow-hidden rounded-t-lg border border-b-0 border-transparent bg-[var(--chrome-bg)] shadow-[0_-8px_24px_rgba(0,0,0,0.45),0_-1px_0_var(--chrome-border)_inset] animate-in fade-in slide-in-from-bottom-full duration-200"
|
||||
className="export-drawer pointer-events-auto flex w-[min(880px,calc(100vw-24px))] max-h-[85vh] flex-col overflow-hidden rounded-t-xl border-0 bg-[var(--chrome-bg)] shadow-xl animate-in fade-in duration-200 motion-reduce:animate-none"
|
||||
ref={drawerRef}
|
||||
onMouseDownCapture={(event) => {
|
||||
lastDrawerMouseDownRef.current = event.nativeEvent;
|
||||
}}
|
||||
>
|
||||
<header className="relative flex items-center gap-[var(--space-3)] p-[6px_var(--space-4)_10px] [border-bottom:1px_solid_var(--chrome-border)] [background:linear-gradient(180deg,rgba(255,255,255,0.02),transparent)]">
|
||||
<span
|
||||
@@ -303,7 +308,7 @@ export default function ExportModal({
|
||||
<X size={13} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex flex-col gap-[var(--space-4)] overflow-y-auto p-[var(--space-3)_var(--space-4)_var(--space-4)]">
|
||||
<div className="export-body flex min-h-0 flex-col gap-4 overflow-y-auto p-4">
|
||||
{/* Preset chips */}
|
||||
<div className="flex flex-wrap items-center gap-[var(--space-2)] pb-[var(--space-3)] [border-bottom:1px_solid_var(--chrome-border)]">
|
||||
<span className="inline-flex items-center gap-[4px] uppercase [font-family:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] text-[var(--chrome-fg-muted)]">
|
||||
@@ -332,7 +337,7 @@ export default function ExportModal({
|
||||
/>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-[var(--space-1)] [border-bottom:1px_solid_var(--chrome-border)]">
|
||||
<div className="export-tabs grid grid-cols-4 gap-1 rounded-lg bg-[var(--chrome-hover-bg)] p-1">
|
||||
<button
|
||||
type="button"
|
||||
className={tabCls(tab === 'video')}
|
||||
@@ -356,7 +361,7 @@ export default function ExportModal({
|
||||
</div>
|
||||
|
||||
{/* Tab body */}
|
||||
<div className="min-h-[160px]">
|
||||
<div className="export-fields">
|
||||
{tab === 'video' && (
|
||||
<div className="grid grid-cols-2 gap-x-[var(--space-5)] gap-y-[var(--space-4)]">
|
||||
<Field label={t('exportModal.container')}>
|
||||
@@ -371,42 +376,38 @@ export default function ExportModal({
|
||||
label={t('exportModal.default_audio_track')}
|
||||
hint={t('exportModal.default_audio_hint')}
|
||||
>
|
||||
<select
|
||||
className="input-base input-base--xs"
|
||||
<SearchableSelect
|
||||
menuPortal
|
||||
ariaLabel={t('exportModal.default_audio_track')}
|
||||
value={defaultTrack}
|
||||
onChange={(e) => setDefaultTrack(e.target.value)}
|
||||
>
|
||||
{exportTracks['original'] !== false && (
|
||||
<option value="original">{t('exportModal.original')}</option>
|
||||
)}
|
||||
{(dubTracks || [])
|
||||
.filter((code) => exportTracks[code] !== false)
|
||||
.map((code) => (
|
||||
<option key={code} value={code}>
|
||||
{code.toUpperCase()} {t('exportModal.dub_suffix')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={setDefaultTrack}
|
||||
buttonClassName="min-h-10 rounded-lg border-0 bg-[var(--chrome-hover-bg)] px-3 text-sm text-[var(--chrome-fg)]"
|
||||
options={[
|
||||
...(exportTracks.original !== false
|
||||
? [{ value: 'original', label: t('exportModal.original') }]
|
||||
: []),
|
||||
...(dubTracks || [])
|
||||
.filter((code) => exportTracks[code] !== false)
|
||||
.map((code) => ({
|
||||
value: code,
|
||||
label: code.toUpperCase() + ' ' + t('exportModal.dub_suffix'),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('exportModal.bg_audio')}>
|
||||
<label className={TOGGLE_CLS}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preserveBg}
|
||||
onChange={(e) => setPreserveBg(e.target.checked)}
|
||||
/>
|
||||
{t('exportModal.mix_bg_video')}
|
||||
</label>
|
||||
<DubToggle
|
||||
label={t('exportModal.mix_bg_video')}
|
||||
checked={preserveBg}
|
||||
onChange={setPreserveBg}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('exportModal.subs_in_video')}>
|
||||
<label className={TOGGLE_CLS}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={burnSubs}
|
||||
onChange={(e) => setBurnSubs(e.target.checked)}
|
||||
/>
|
||||
{t('exportModal.hardsub')}
|
||||
</label>
|
||||
<DubToggle
|
||||
label={t('exportModal.hardsub')}
|
||||
checked={burnSubs}
|
||||
onChange={setBurnSubs}
|
||||
/>
|
||||
{burnSubs && (
|
||||
<Segmented
|
||||
size="sm"
|
||||
@@ -425,14 +426,11 @@ export default function ExportModal({
|
||||
/>
|
||||
)}
|
||||
{burnSubs && (
|
||||
<label className={TOGGLE_INDENT_CLS}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!dualSubs}
|
||||
onChange={(e) => setDualSubs(e.target.checked)}
|
||||
/>
|
||||
{t('exportModal.dual_subs_video')}
|
||||
</label>
|
||||
<DubToggle
|
||||
label={t('exportModal.dual_subs_video')}
|
||||
checked={!!dualSubs}
|
||||
onChange={setDualSubs}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{(timingStrategy === 'smart_fit' || timingStrategy === 'stretch_video') && (
|
||||
@@ -482,28 +480,25 @@ export default function ExportModal({
|
||||
]}
|
||||
/>
|
||||
{audioBatch === 'primary' && (
|
||||
<select
|
||||
className="input-base input-base--xs mt-[6px]"
|
||||
<SearchableSelect
|
||||
menuPortal
|
||||
ariaLabel={t('exportModal.languages')}
|
||||
value={audioPrimaryLang}
|
||||
onChange={(e) => setAudioPrimaryLang(e.target.value)}
|
||||
>
|
||||
{(dubTracks || []).map((code) => (
|
||||
<option key={code} value={code}>
|
||||
{code.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={setAudioPrimaryLang}
|
||||
buttonClassName="min-h-10 rounded-lg border-0 bg-[var(--chrome-hover-bg)] px-3 text-sm text-[var(--chrome-fg)]"
|
||||
options={(dubTracks || []).map((code) => ({
|
||||
value: code,
|
||||
label: code.toUpperCase(),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label={t('exportModal.bg_audio')}>
|
||||
<label className={TOGGLE_CLS}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preserveBg}
|
||||
onChange={(e) => setPreserveBg(e.target.checked)}
|
||||
/>
|
||||
{t('exportModal.mix_bg_audio')}
|
||||
</label>
|
||||
<DubToggle
|
||||
label={t('exportModal.mix_bg_audio')}
|
||||
checked={preserveBg}
|
||||
onChange={setPreserveBg}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
@@ -602,44 +597,43 @@ export default function ExportModal({
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Summary footer */}
|
||||
<div className="flex items-center justify-between gap-[var(--space-3)] pt-[var(--space-3)] [border-top:1px_solid_var(--chrome-border)]">
|
||||
<div className="flex min-w-0 items-center gap-[var(--space-2)]">
|
||||
<span className="inline-flex items-center gap-[4px] uppercase [font-family:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] text-[var(--chrome-fg-muted)]">
|
||||
{t('exportModal.output')}
|
||||
</span>
|
||||
<code
|
||||
className="max-w-[340px] overflow-hidden text-ellipsis whitespace-nowrap rounded-[2px] bg-[var(--chrome-hover-bg)] px-[8px] py-[3px] [font-family:var(--chrome-font-mono)] text-[length:var(--text-xs)] text-[var(--chrome-fg)]"
|
||||
title={filenamePreview}
|
||||
>
|
||||
{filenamePreview}
|
||||
</code>
|
||||
</div>
|
||||
<div className="inline-flex gap-[var(--space-2)]">
|
||||
{tab !== 'pkg' && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={active.fn}
|
||||
disabled={!active.can}
|
||||
leading={<Download size={11} />}
|
||||
title={active.can ? '' : t('exportModal.nothing_selected')}
|
||||
>
|
||||
{active.label}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{tab === 'pkg' && (
|
||||
</div>
|
||||
{/* Summary footer stays visible while format settings scroll. */}
|
||||
<div className="export-summary flex shrink-0 items-center justify-between gap-3 p-4 bg-[var(--chrome-hover-bg)]">
|
||||
<div className="flex min-w-0 items-center gap-[var(--space-2)]">
|
||||
<span className="inline-flex items-center gap-[4px] uppercase [font-family:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] text-[var(--chrome-fg-muted)]">
|
||||
{t('exportModal.output')}
|
||||
</span>
|
||||
<code
|
||||
className="max-w-[340px] overflow-hidden text-ellipsis whitespace-nowrap rounded-[2px] bg-[var(--chrome-hover-bg)] px-[8px] py-[3px] [font-family:var(--chrome-font-mono)] text-[length:var(--text-xs)] text-[var(--chrome-fg)]"
|
||||
title={filenamePreview}
|
||||
>
|
||||
{filenamePreview}
|
||||
</code>
|
||||
</div>
|
||||
<div className="inline-flex gap-[var(--space-2)]">
|
||||
{tab !== 'pkg' && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={active.fn}
|
||||
disabled={!active.can}
|
||||
leading={<Download size={11} />}
|
||||
title={active.can ? '' : t('exportModal.nothing_selected')}
|
||||
>
|
||||
{active.label}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{tab === 'pkg' && (
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -650,11 +644,9 @@ export default function ExportModal({
|
||||
|
||||
function Field({ label, hint, children }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-[6px]">
|
||||
<div className="export-field flex min-w-0 flex-col gap-3 rounded-lg bg-[var(--chrome-hover-bg)] p-4">
|
||||
<div className="flex flex-col gap-[2px]">
|
||||
<span className="uppercase [font-family:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] tracking-[var(--chrome-label-track)] text-[var(--chrome-fg-muted)]">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-[var(--chrome-fg)]">{label}</span>
|
||||
{hint && (
|
||||
<span className="text-[length:var(--text-xs)] text-[var(--chrome-fg-dim)] leading-[1.4]">
|
||||
{hint}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
// inside `.map(t => …)` callbacks where `t` was the loop variable, shadowing the
|
||||
// useTranslation `t`. Rendering with a dub track that equals the primary
|
||||
// dubLangCode exercises the exact crashing branch.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import '../i18n';
|
||||
import ExportModal from './ExportModal';
|
||||
|
||||
@@ -41,6 +41,55 @@ function renderModal(extra = {}) {
|
||||
}
|
||||
|
||||
describe('ExportModal (regression #183)', () => {
|
||||
it.each(['search', 'option'])('keeps the drawer open for a portaled selector %s', (target) => {
|
||||
const onClose = vi.fn();
|
||||
const setDefaultTrack = vi.fn();
|
||||
renderModal({ onClose, setDefaultTrack });
|
||||
fireEvent.click(screen.getByRole('button', { name: /default audio track/i }));
|
||||
expect(document.querySelector('.export-drawer')).not.toContainElement(
|
||||
screen.getByRole('listbox'),
|
||||
);
|
||||
|
||||
fireEvent.mouseDown(
|
||||
target === 'search'
|
||||
? screen.getByRole('textbox', { name: /search/i })
|
||||
: screen.getByRole('option', { name: /^ES/ }),
|
||||
);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
if (target === 'option') expect(setDefaultTrack).toHaveBeenCalledWith('es');
|
||||
|
||||
fireEvent.mouseDown(document.querySelector('.export-summary'));
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
fireEvent.mouseDown(document.body);
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('dismisses the selector before the export drawer when Escape is pressed', () => {
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onClose });
|
||||
const trigger = screen.getByRole('button', { name: /default audio track/i });
|
||||
fireEvent.click(trigger);
|
||||
fireEvent.keyDown(screen.getByRole('textbox', { name: /search/i }), { key: 'Escape' });
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(trigger).toHaveFocus();
|
||||
|
||||
fireEvent.keyDown(trigger, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps export actions outside the scrolling settings and uses themed track choices', () => {
|
||||
const setDefaultTrack = vi.fn();
|
||||
renderModal({ setDefaultTrack });
|
||||
expect(document.querySelector('.export-body')).not.toContainElement(
|
||||
document.querySelector('.export-summary'),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /default audio track/i }));
|
||||
fireEvent.mouseDown(screen.getByRole('option', { name: /^ES/ }));
|
||||
expect(setDefaultTrack).toHaveBeenCalledWith('es');
|
||||
expect(screen.getAllByRole('switch')).toHaveLength(2);
|
||||
});
|
||||
it('renders with dub tracks incl. the primary dub without throwing', () => {
|
||||
expect(() => renderModal()).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
.glossary-content { min-width: 0; }
|
||||
.glossary-purpose, .glossary-empty { margin: 0 0 10px; color: var(--chrome-fg-muted); font-size: 12px; line-height: 1.5; }
|
||||
.glossary-empty { color: var(--chrome-fg-dim); }
|
||||
.glossary-table-scroll { max-height: 28vh; overflow: auto; margin-bottom: 12px; }
|
||||
.glossary-table-scroll table { min-width: 440px; }
|
||||
.glossary-add-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; align-items: end; }
|
||||
.glossary-add-form label { display: grid; gap: 5px; min-width: 0; color: var(--chrome-fg-muted); font-size: 12px; }
|
||||
.glossary-add-form input { width: 100%; min-width: 0; min-height: 36px; font-size: 13px; }
|
||||
.glossary-add-form > button { justify-self: end; min-height: 36px; padding-inline: 12px; }
|
||||
.glossary-panel { container-type: inline-size; }
|
||||
@container (max-width: 340px) {
|
||||
.glossary-add-form { grid-template-columns: minmax(0, 1fr); }
|
||||
.glossary-add-form > button { justify-self: stretch; }
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Trash2, BookOpen, Sparkles, Check, ChevronDown, X } from 'lucide-react';
|
||||
import { Plus, Trash2, BookOpen, Sparkles, Check, ChevronUp, X, Pencil } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Panel, Button, Input, Badge } from '../ui';
|
||||
import './GlossaryPanel.css';
|
||||
import {
|
||||
listGlossary,
|
||||
addGlossaryTerm,
|
||||
@@ -144,7 +145,7 @@ export default function GlossaryPanel({
|
||||
className="glossary-panel"
|
||||
title={
|
||||
<>
|
||||
<BookOpen size={13} /> {t('glossary.title')}
|
||||
<BookOpen size={15} aria-hidden="true" /> {t('glossary.title')}
|
||||
<span className="ml-[var(--space-3)] text-[length:var(--text-xs)] font-medium text-[var(--color-fg-subtle)]">
|
||||
{t('glossary.count', { count: terms.length })}
|
||||
{autoCount > 0 && <> · {t('glossary.auto_count', { count: autoCount })}</>}
|
||||
@@ -175,108 +176,103 @@ export default function GlossaryPanel({
|
||||
</Button>
|
||||
)}
|
||||
{onClose && (
|
||||
<Button variant="ghost" size="sm" onClick={onClose} title={t('glossary.close')}>
|
||||
<ChevronDown size={11} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
title={t('glossary.close')}
|
||||
aria-label={t('glossary.close')}
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="max-h-[35vh] overflow-y-auto">
|
||||
<div className="glossary-content">
|
||||
{!projectId ? (
|
||||
<div className="p-[var(--space-5)] text-center text-[length:var(--text-md)] text-[var(--color-fg-subtle)]">
|
||||
{t('glossary.empty_save')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full border-collapse text-[length:var(--text-sm)] [&_td]:border-b [&_td]:border-b-transparent [&_td]:px-[6px] [&_td]:py-[3px] [&_td]:text-left [&_td]:align-middle [&_th]:border-b [&_th]:border-b-transparent [&_th]:px-[6px] [&_th]:py-[3px] [&_th]:text-left [&_th]:align-middle [&_th]:text-[length:var(--text-xs)] [&_th]:font-semibold [&_th]:uppercase [&_th]:tracking-[0.04em] [&_th]:text-[var(--color-fg-subtle)]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('glossary.source')}</th>
|
||||
<th>{t('glossary.target')}</th>
|
||||
<th>{t('glossary.note')}</th>
|
||||
<th className="w-[60px]" aria-label="auto / manual"></th>
|
||||
<th className="w-[26px]" aria-label="delete"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && !terms.length && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
className="p-[var(--space-5)] text-center italic text-[var(--color-fg-subtle)]"
|
||||
>
|
||||
{t('common.loading')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!loading && !terms.length && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={5}
|
||||
className="p-[var(--space-5)] text-center italic text-[var(--color-fg-subtle)]"
|
||||
>
|
||||
{t('glossary.no_terms')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{terms.map((term) => (
|
||||
<GlossaryRow
|
||||
key={term.id}
|
||||
term={term}
|
||||
onUpdate={(patch) => onUpdate(term.id, patch)}
|
||||
onDelete={() => onDelete(term.id)}
|
||||
/>
|
||||
))}
|
||||
<tr className="border-t border-dashed border-transparent [&>td]:py-[4px]">
|
||||
<td>
|
||||
<Input
|
||||
size="sm"
|
||||
placeholder={t('glossary.source_placeholder', { lang: sourceLang })}
|
||||
value={draft.source}
|
||||
onChange={(e) => setDraft({ ...draft, source: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onAdd();
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Input
|
||||
size="sm"
|
||||
placeholder={t('glossary.target_placeholder', { lang: targetLang || '—' })}
|
||||
value={draft.target}
|
||||
onChange={(e) => setDraft({ ...draft, target: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onAdd();
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Input
|
||||
size="sm"
|
||||
placeholder={t('glossary.note_placeholder')}
|
||||
value={draft.note}
|
||||
onChange={(e) => setDraft({ ...draft, note: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onAdd();
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td />
|
||||
<td>
|
||||
<Button
|
||||
variant="subtle"
|
||||
iconSize="sm"
|
||||
disabled={!draft.source.trim() || !draft.target.trim()}
|
||||
onClick={onAdd}
|
||||
title={t('glossary.add_term')}
|
||||
>
|
||||
<Plus size={10} />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="glossary-purpose">{t('dub.glossary_title')}</p>
|
||||
{terms.length === 0 && (
|
||||
<p className="glossary-empty" role="status">
|
||||
{loading ? t('common.loading') : t('glossary.no_terms')}
|
||||
</p>
|
||||
)}
|
||||
{terms.length > 0 && (
|
||||
<div className="glossary-table-scroll">
|
||||
<table className="w-full border-collapse text-[length:var(--text-sm)] [&_td]:border-b [&_td]:border-b-transparent [&_td]:px-[6px] [&_td]:py-[3px] [&_td]:text-left [&_td]:align-middle [&_th]:border-b [&_th]:border-b-transparent [&_th]:px-[6px] [&_th]:py-[3px] [&_th]:text-left [&_th]:align-middle [&_th]:text-[length:var(--text-xs)] [&_th]:font-semibold [&_th]:uppercase [&_th]:tracking-[0.04em] [&_th]:text-[var(--color-fg-subtle)]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('glossary.source')}</th>
|
||||
<th>{t('glossary.target')}</th>
|
||||
<th>{t('glossary.note')}</th>
|
||||
<th
|
||||
className="w-[60px]"
|
||||
aria-label={`${t('glossary.auto_badge')} / ${t('glossary.manual_badge')}`}
|
||||
></th>
|
||||
<th className="w-[26px]" aria-label={t('common.delete')}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{terms.map((term) => (
|
||||
<GlossaryRow
|
||||
key={term.id}
|
||||
term={term}
|
||||
onUpdate={(patch) => onUpdate(term.id, patch)}
|
||||
onDelete={() => onDelete(term.id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
className="glossary-add-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onAdd();
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
<span>{t('glossary.source')}</span>
|
||||
<Input
|
||||
size="sm"
|
||||
placeholder={t('glossary.source_placeholder', { lang: sourceLang })}
|
||||
value={draft.source}
|
||||
onChange={(e) => setDraft({ ...draft, source: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>{t('glossary.target')}</span>
|
||||
<Input
|
||||
size="sm"
|
||||
placeholder={t('glossary.target_placeholder', { lang: targetLang || '—' })}
|
||||
value={draft.target}
|
||||
onChange={(e) => setDraft({ ...draft, target: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="glossary-add-note">
|
||||
<span>{t('glossary.note_placeholder')}</span>
|
||||
<Input
|
||||
size="sm"
|
||||
value={draft.note}
|
||||
onChange={(e) => setDraft({ ...draft, note: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
leading={<Plus size={14} />}
|
||||
disabled={!draft.source.trim() || !draft.target.trim()}
|
||||
>
|
||||
{t('glossary.add_term')}
|
||||
</Button>
|
||||
</form>
|
||||
{manualCount > 0 && targetLang && (
|
||||
<div className="mt-[var(--space-3)] px-[var(--space-3)] py-[2px] text-[length:var(--text-2xs)] italic text-[var(--color-fg-subtle)]">
|
||||
{t('glossary.hint')}
|
||||
@@ -312,6 +308,7 @@ function GlossaryRow({ term, onUpdate, onDelete }) {
|
||||
<td>
|
||||
<Input
|
||||
size="sm"
|
||||
aria-label={t('glossary.source')}
|
||||
value={local.source}
|
||||
onChange={(e) => setLocal({ ...local, source: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
@@ -324,6 +321,7 @@ function GlossaryRow({ term, onUpdate, onDelete }) {
|
||||
<td>
|
||||
<Input
|
||||
size="sm"
|
||||
aria-label={t('glossary.target')}
|
||||
value={local.target}
|
||||
onChange={(e) => setLocal({ ...local, target: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
@@ -335,6 +333,7 @@ function GlossaryRow({ term, onUpdate, onDelete }) {
|
||||
<td>
|
||||
<Input
|
||||
size="sm"
|
||||
aria-label={t('glossary.note')}
|
||||
value={local.note}
|
||||
onChange={(e) => setLocal({ ...local, note: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
@@ -380,6 +379,15 @@ function GlossaryRow({ term, onUpdate, onDelete }) {
|
||||
)}
|
||||
</td>
|
||||
<td className="flex justify-end gap-[var(--space-1)]">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditing(true)}
|
||||
aria-label={t('clone.edit')}
|
||||
title={t('clone.edit')}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</Button>
|
||||
<Button variant="danger" iconSize="sm" onClick={onDelete} title={t('common.delete')}>
|
||||
<Trash2 size={10} />
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import GlossaryPanel from './GlossaryPanel';
|
||||
import { listGlossary, addGlossaryTerm, updateGlossaryTerm } from '../api/glossary';
|
||||
|
||||
vi.mock('../api/glossary', () => ({
|
||||
listGlossary: vi.fn(),
|
||||
addGlossaryTerm: vi.fn(),
|
||||
updateGlossaryTerm: vi.fn(),
|
||||
deleteGlossaryTerm: vi.fn(),
|
||||
clearGlossary: vi.fn(),
|
||||
autoExtractGlossary: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listGlossary.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe('Glossary term entry', () => {
|
||||
it('offers labelled fields without an empty table and submits from the keyboard', async () => {
|
||||
const onChange = vi.fn();
|
||||
addGlossaryTerm.mockResolvedValue({ id: 1, source: 'Studio', target: 'Estudio', note: '' });
|
||||
render(<GlossaryPanel projectId="test" targetLang="es" onChange={onChange} />);
|
||||
await screen.findByText('No terms yet. Add one below or click Auto.');
|
||||
expect(screen.queryByRole('table')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Add term' })).toBeDisabled();
|
||||
fireEvent.change(screen.getByLabelText('Source'), { target: { value: 'Studio' } });
|
||||
fireEvent.change(screen.getByLabelText('Target'), { target: { value: 'Estudio' } });
|
||||
fireEvent.submit(screen.getByLabelText('Target').closest('form'));
|
||||
await waitFor(() =>
|
||||
expect(addGlossaryTerm).toHaveBeenCalledWith('test', {
|
||||
source: 'Studio',
|
||||
target: 'Estudio',
|
||||
note: '',
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByRole('table')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Source')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('makes editing an existing term available through a named button', async () => {
|
||||
listGlossary.mockResolvedValue([{ id: 1, source: 'Studio', target: 'Estudio', note: '' }]);
|
||||
updateGlossaryTerm.mockResolvedValue({
|
||||
id: 1,
|
||||
source: 'Studio',
|
||||
target: 'Estudio nuevo',
|
||||
note: '',
|
||||
});
|
||||
render(<GlossaryPanel projectId="test" targetLang="es" />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Edit' }));
|
||||
const table = screen.getByRole('table');
|
||||
const target = table.querySelector('input[aria-label="Target"]');
|
||||
fireEvent.change(target, { target: { value: 'Estudio nuevo' } });
|
||||
fireEvent.keyDown(target, { key: 'Enter' });
|
||||
await waitFor(() =>
|
||||
expect(updateGlossaryTerm).toHaveBeenCalledWith('test', 1, {
|
||||
source: 'Studio',
|
||||
target: 'Estudio nuevo',
|
||||
note: '',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import GpuTarget from './GpuTarget';
|
||||
import EngineQuickSwitch from './EngineQuickSwitch';
|
||||
import { engineDisplayName } from '../utils/engineDisplayName';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
Globe,
|
||||
@@ -27,7 +30,7 @@ import NotificationPanel from './NotificationPanel';
|
||||
import TitleTabs from './TitleTabs';
|
||||
import VoiceStudioMark from './brand/VoiceStudioMark';
|
||||
import { useAppStore } from '../store';
|
||||
import { useSysinfo } from '../api/hooks';
|
||||
import { useSysinfo, useEngines } from '../api/hooks';
|
||||
import { reloadAfterApplicationPersistence } from '../utils/persistenceLifecycle';
|
||||
|
||||
const VIEW_META = {
|
||||
@@ -150,7 +153,12 @@ export default function Header({
|
||||
// breadcrumb + wordmark normally sit — the tabs already say where you are,
|
||||
// and two answers to that question in one bar is one too many.
|
||||
const tabsInTitlebar = navStyle === 'tabs';
|
||||
const showWindowControls = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||
// The macOS platform config enables decorated overlay chrome. Its native
|
||||
// traffic lights provide the same window actions as our custom desktop row.
|
||||
const isDesktop = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||
const hasMacTrafficLights =
|
||||
isDesktop && typeof navigator !== 'undefined' && (navigator.platform || '').startsWith('Mac');
|
||||
const showWindowControls = isDesktop && !hasMacTrafficLights;
|
||||
const { t } = useTranslation();
|
||||
// Sysinfo is subscribed here (not in App via useAppData) so the 5s poll
|
||||
// only re-renders the header chrome, not the whole App tree.
|
||||
@@ -162,6 +170,23 @@ export default function Header({
|
||||
const showLiveStats = useAppStore((s) => s.showHeaderLiveStats);
|
||||
const [flushing, setFlushing] = useState(false);
|
||||
const [flushOpen, setFlushOpen] = useState(false);
|
||||
const [engineFamily, setEngineFamily] = useState('tts');
|
||||
const { data: engines } = useEngines();
|
||||
const [engineLabelIndex, setEngineLabelIndex] = useState(0);
|
||||
const [engineLabelPaused, setEngineLabelPaused] = useState(false);
|
||||
const activeEngines = ['tts', 'asr', 'llm'].flatMap((family) => {
|
||||
const inventory = engines?.[family];
|
||||
const active = inventory?.backends?.find((engine) => engine.id === inventory.active);
|
||||
return active ? [{ family, name: engineDisplayName(active.display_name) }] : [];
|
||||
});
|
||||
const displayedEngine = activeEngines[engineLabelIndex % (activeEngines.length || 1)];
|
||||
const activeEngineName = displayedEngine?.name;
|
||||
const activeEngineShortName = activeEngineName?.split(' (')[0];
|
||||
useEffect(() => {
|
||||
if (flushOpen || engineLabelPaused || activeEngines.length < 2) return;
|
||||
const timer = setInterval(() => setEngineLabelIndex((index) => index + 1), 4000);
|
||||
return () => clearInterval(timer);
|
||||
}, [flushOpen, engineLabelPaused, activeEngines.length]);
|
||||
const [loadedModels, setLoadedModels] = useState([]);
|
||||
const [unloading, setUnloading] = useState(null);
|
||||
const flushRef = useRef(null);
|
||||
@@ -172,8 +197,8 @@ export default function Header({
|
||||
const computePos = useCallback(() => {
|
||||
if (!flushBtnRef.current) return;
|
||||
const rect = flushBtnRef.current.getBoundingClientRect();
|
||||
const dropW = 260;
|
||||
const dropH = 220; // approximate max height
|
||||
const dropW = Math.min(420, window.innerWidth - 16);
|
||||
const dropH = Math.min(640, window.innerHeight - 80);
|
||||
const pad = 6;
|
||||
|
||||
// Default: below button, right-aligned
|
||||
@@ -182,13 +207,13 @@ export default function Header({
|
||||
|
||||
// Flip up if too close to bottom
|
||||
if (top + dropH > window.innerHeight - 10) {
|
||||
top = rect.top - dropH - pad;
|
||||
top = Math.max(8, rect.top - dropH - pad);
|
||||
}
|
||||
// Clamp left so it doesn't go off-screen
|
||||
if (left < 8) left = 8;
|
||||
if (left + dropW > window.innerWidth - 8) left = window.innerWidth - dropW - 8;
|
||||
|
||||
setDropdownPos({ top, left });
|
||||
setDropdownPos({ top, left, width: dropW });
|
||||
}, []);
|
||||
|
||||
// Recompute on open, resize, and scroll
|
||||
@@ -221,6 +246,18 @@ export default function Header({
|
||||
|
||||
// Click outside to close (must check both the button wrapper AND the portal dropdown)
|
||||
const dropdownRef = useRef(null);
|
||||
useEffect(() => {
|
||||
if (!flushOpen) return;
|
||||
const frame = requestAnimationFrame(() =>
|
||||
dropdownRef.current?.querySelector('button')?.focus(),
|
||||
);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [flushOpen]);
|
||||
useEffect(() => {
|
||||
const show = () => setFlushOpen(true);
|
||||
window.addEventListener('engine-quick-switch', show);
|
||||
return () => window.removeEventListener('engine-quick-switch', show);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!flushOpen) return;
|
||||
const handler = (e) => {
|
||||
@@ -229,7 +266,17 @@ export default function Header({
|
||||
if (!inBtn && !inDrop) setFlushOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
const escape = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setFlushOpen(false);
|
||||
flushBtnRef.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', escape);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handler);
|
||||
document.removeEventListener('keydown', escape);
|
||||
};
|
||||
}, [flushOpen]);
|
||||
|
||||
const unloadModel = async (modelId) => {
|
||||
@@ -258,10 +305,14 @@ export default function Header({
|
||||
</div>
|
||||
) : (
|
||||
/* Left: view title + breadcrumb */
|
||||
<div className="flex items-center gap-[14px] justify-self-start min-w-0">
|
||||
<div
|
||||
className={`flex items-center gap-[14px] justify-self-start min-w-0 ${
|
||||
hasMacTrafficLights ? 'header-area__left--mac-inset' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="inline-flex items-center gap-[6px] h-[var(--chrome-pill-h)] [font-family:var(--font-sans)] max-[961px]:gap-[5px]">
|
||||
<span
|
||||
className="w-[7px] h-[7px] rounded-full shrink-0 [animation:hqPulse_2.4s_ease-in-out_infinite] max-[821px]:hidden"
|
||||
className="w-[7px] h-[7px] rounded-full shrink-0 [animation:hqPulse_2.4s_ease-in-out_infinite] motion-reduce:[animation:none] max-[821px]:hidden"
|
||||
style={dotStyle}
|
||||
/>
|
||||
<span className="text-[length:var(--chrome-label-size)] font-semibold tracking-[var(--chrome-label-track)] uppercase text-[var(--chrome-fg-muted)] max-[1501px]:hidden">
|
||||
@@ -327,7 +378,7 @@ export default function Header({
|
||||
active={modelStatus === 'ready' || modelStatus === 'loading'}
|
||||
/>
|
||||
{sysStats && (
|
||||
<div className="flex items-center gap-[10px] [font-family:var(--chrome-font-mono)] text-[10.5px] text-[var(--chrome-fg-dim)] bg-transparent h-[var(--chrome-pill-h)] whitespace-nowrap shrink overflow-hidden tabular-nums slashed-zero max-[851px]:hidden!">
|
||||
<div className="flex items-center gap-2 [font-family:var(--chrome-font-mono)] text-[10.5px] text-[var(--chrome-fg-dim)] bg-transparent h-[var(--chrome-pill-h)] whitespace-nowrap shrink-0 tabular-nums slashed-zero">
|
||||
{showLiveStats && (
|
||||
<>
|
||||
<span className="max-[1081px]:hidden">
|
||||
@@ -381,22 +432,101 @@ export default function Header({
|
||||
ref={flushBtnRef}
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
title={t('header.memory_management')}
|
||||
title={activeEngineName || t('header.memory_management')}
|
||||
aria-label={t('settings.engines')}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={flushOpen}
|
||||
loading={flushing}
|
||||
leading={!flushing && <Zap size={8} />}
|
||||
trailing={<ChevronDown size={8} />}
|
||||
onClick={() => setFlushOpen((o) => !o)}
|
||||
onMouseEnter={() => setEngineLabelPaused(true)}
|
||||
onMouseLeave={() => setEngineLabelPaused(false)}
|
||||
onFocus={() => setEngineLabelPaused(true)}
|
||||
onBlur={() => setEngineLabelPaused(false)}
|
||||
onClick={() => {
|
||||
if (!flushOpen && displayedEngine) setEngineFamily(displayedEngine.family);
|
||||
setFlushOpen((o) => !o);
|
||||
}}
|
||||
className="ml-[2px]"
|
||||
>
|
||||
{t('header.flush')}
|
||||
<span
|
||||
key={displayedEngine?.family}
|
||||
className="engine-title-label flex w-[112px] items-center justify-between gap-1 max-[600px]:w-[77px]"
|
||||
>
|
||||
{displayedEngine && (
|
||||
<span className="shrink-0 text-left text-[var(--chrome-fg-dim)]">
|
||||
{t(`models.role_${displayedEngine.family}`)} ·{' '}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-right">
|
||||
{activeEngineShortName || t('settings.engines')}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
{flushOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed w-[260px] bg-[var(--color-bg-elev-1)] [border:1px_solid_var(--color-border)] rounded-[var(--radius-lg)] [box-shadow:0_8px_24px_rgba(0,0,0,0.5)] z-[9999] py-[4px] [animation:flush-slide_0.12s_ease-out]"
|
||||
style={{ top: dropdownPos.top, left: dropdownPos.left }}
|
||||
role="dialog"
|
||||
aria-label={t('settings.engines')}
|
||||
className="fixed overflow-y-auto bg-[var(--color-bg)] border border-transparent rounded-[var(--radius-lg)] shadow-xl z-[9999] p-3"
|
||||
style={{
|
||||
top: dropdownPos.top,
|
||||
left: dropdownPos.left,
|
||||
width: dropdownPos.width,
|
||||
maxHeight: `calc(100vh - ${dropdownPos.top + 8}px)`,
|
||||
}}
|
||||
ref={dropdownRef}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 pb-2">
|
||||
<span className="text-sm font-semibold">{t('settings.engines')}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
onClick={() => {
|
||||
setFlushOpen(false);
|
||||
flushBtnRef.current?.focus();
|
||||
}}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border-0 bg-transparent text-[var(--chrome-fg-muted)] cursor-pointer hover:bg-[var(--chrome-hover-bg)] focus-visible:outline-2 focus-visible:outline-[var(--chrome-accent)]"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<Tabs value={engineFamily} onValueChange={setEngineFamily}>
|
||||
<TabsList
|
||||
aria-label={t('settings.engines')}
|
||||
className="w-full h-auto grid grid-cols-3 bg-[var(--chrome-hover-bg)]"
|
||||
>
|
||||
{['tts', 'asr', 'llm'].map((family) => (
|
||||
<TabsTrigger
|
||||
key={family}
|
||||
value={family}
|
||||
className="min-h-11 cursor-pointer whitespace-normal bg-transparent text-xs text-[var(--chrome-fg-muted)] data-[state=active]:bg-[var(--chrome-accent-bg)] data-[state=active]:text-[var(--chrome-accent)] dark:data-[state=active]:bg-[var(--chrome-accent-bg)] dark:data-[state=active]:text-[var(--chrome-accent)] hover:data-[state=inactive]:bg-[var(--chrome-hover-bg)]"
|
||||
>
|
||||
{family === 'tts'
|
||||
? t('header.speech')
|
||||
: family === 'asr'
|
||||
? t('projects.transcription')
|
||||
: t('models.role_llm')}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<TabsContent value={engineFamily}>
|
||||
<EngineQuickSwitch key={engineFamily} family={engineFamily} embedded />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 border-0 bg-transparent p-2 text-xs text-[var(--chrome-fg-muted)] cursor-pointer hover:text-[var(--chrome-fg)]"
|
||||
onClick={() => {
|
||||
setFlushOpen(false);
|
||||
useAppStore
|
||||
.getState()
|
||||
.openCatalogue({ pane: 'engines', family: engineFamily });
|
||||
}}
|
||||
>
|
||||
{t('header.label_catalogue')} <ChevronRight size={12} />
|
||||
</button>
|
||||
<div className="h-px bg-[var(--color-border)] my-2" />
|
||||
<div className="text-[10px] font-semibold text-[var(--color-fg-subtle)] uppercase tracking-[0.5px] pt-[6px] px-[12px] pb-[4px]">
|
||||
{t('header.loaded_models')}
|
||||
</div>
|
||||
@@ -412,7 +542,7 @@ export default function Header({
|
||||
>
|
||||
<div className="flex flex-col gap-[1px] min-w-0">
|
||||
<span className="text-[12px] text-[var(--color-fg)] font-medium">
|
||||
{m.name}
|
||||
{engineDisplayName(m.name)}
|
||||
{/* Resident-but-not-routed engine (e.g. VoiceStudio still in
|
||||
VRAM after switching to another backend) — say so. */}
|
||||
{m.is_active_engine === false && (
|
||||
@@ -453,20 +583,25 @@ export default function Header({
|
||||
>
|
||||
<Zap size={10} /> {t('header.flush_caches')}
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-[6px] w-full py-[6px] px-[12px] text-[12px] text-[#fb4934] bg-transparent border-none cursor-pointer text-left hover:bg-[rgba(251,73,52,0.08)]"
|
||||
onClick={async () => {
|
||||
setFlushing(true);
|
||||
setFlushOpen(false);
|
||||
try {
|
||||
await onFlushMemory(true);
|
||||
} finally {
|
||||
setFlushing(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={10} /> {t('header.unload_all_flush')}
|
||||
</button>
|
||||
<details>
|
||||
<summary className="cursor-pointer px-3 py-2 text-xs text-[var(--chrome-fg-muted)]">
|
||||
{t('header.memory_management')}
|
||||
</summary>
|
||||
<button
|
||||
className="flex items-center gap-[6px] w-full py-[6px] px-[12px] text-[12px] text-[#fb4934] bg-transparent border-none cursor-pointer text-left hover:bg-[rgba(251,73,52,0.08)]"
|
||||
onClick={async () => {
|
||||
setFlushing(true);
|
||||
setFlushOpen(false);
|
||||
try {
|
||||
await onFlushMemory(true);
|
||||
} finally {
|
||||
setFlushing(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={10} /> {t('header.unload_all_flush')}
|
||||
</button>
|
||||
</details>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Check, Zap } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { openExternal } from '../api/external';
|
||||
import { apiJson, apiPost } from '../api/client';
|
||||
import { Button, Input } from '../ui';
|
||||
|
||||
/**
|
||||
* HfTokenCard — a compact, single-line Hugging Face token input that lives in
|
||||
* the wizard's pinned action area, right by the "Waiting for required models…"
|
||||
* / Continue button. It takes only the HF token: paste it, Save, done. A free
|
||||
* token gives authenticated downloads (faster, higher rate limits, fewer
|
||||
* stalls) and unlocks gated models (pyannote diarization). Persisted via the
|
||||
* same `set-env` endpoint Settings uses, so it survives restarts.
|
||||
* / Continue button. A free token gives authenticated downloads (faster,
|
||||
* higher rate limits, fewer stalls) and unlocks gated models (pyannote
|
||||
* diarization). Persisted via the same encrypted-app-token endpoint Settings uses, so
|
||||
* it survives restarts.
|
||||
*
|
||||
* Before pitching a token, it checks the resolver state (same endpoint the
|
||||
* Settings → API Keys panel uses, `ApiKeysPanel.jsx`) so a user who already
|
||||
* has a locally configured token — from the app store, an env var, or `huggingface-cli
|
||||
* login` — sees that instead of a blind "add a token" prompt (#FR-006).
|
||||
* Replacing an already-active token is gated behind an explicit "Replace…"
|
||||
* click rather than being one blind paste-and-Save away, since Save persists
|
||||
* in the app store and the selected local Hub token file.
|
||||
*
|
||||
* @param {string=} className extra class on the root (e.g. layout pinning).
|
||||
*/
|
||||
@@ -20,17 +29,46 @@ export default function HfTokenCard({ className = '' }) {
|
||||
const [hfToken, setHfToken] = useState('');
|
||||
const [hfState, setHfState] = useState('idle'); // idle | saving | saved | error
|
||||
|
||||
// Resolver state (mirrors ApiKeysPanel's `state`/`refresh` pair): null while
|
||||
// the first GET is in flight, 'error' when it fails — either way we never
|
||||
// want to flash a false "you have no token" pitch before we actually know.
|
||||
const [tokenState, setTokenState] = useState(null);
|
||||
const [checkFailed, setCheckFailed] = useState(false);
|
||||
const [checkAttempt, setCheckAttempt] = useState(0);
|
||||
// Explicit gate: revealing the paste-a-token form when a token is already
|
||||
// active requires this deliberate click, so Save can never blind-clobber a
|
||||
// working token.
|
||||
const [replacing, setReplacing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const data = await apiJson('/system/hf-token/state');
|
||||
if (
|
||||
!Array.isArray(data?.sources) ||
|
||||
data.sources.length !== 3 ||
|
||||
!['app', 'env', 'hf-cli'].every((source) =>
|
||||
data.sources.some((row) => row?.source === source && typeof row.set === 'boolean'),
|
||||
)
|
||||
)
|
||||
throw new Error('Invalid token state');
|
||||
if (!cancelled) setTokenState(data);
|
||||
} catch {
|
||||
if (!cancelled) setCheckFailed(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [checkAttempt]);
|
||||
|
||||
const saveHfToken = async () => {
|
||||
const value = hfToken.trim();
|
||||
if (!value || hfState === 'saving') return;
|
||||
if (!value || hfState === 'saving' || tokenState == null || checkFailed) return;
|
||||
setHfState('saving');
|
||||
try {
|
||||
const { apiFetch } = await import('../api/client');
|
||||
await apiFetch('/system/set-env', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key: 'HF_TOKEN', value }),
|
||||
});
|
||||
await apiPost('/api/settings/hf-token', { token: value });
|
||||
setHfState('saved');
|
||||
setHfToken('');
|
||||
} catch {
|
||||
@@ -48,12 +86,97 @@ export default function HfTokenCard({ className = '' }) {
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5 font-semibold text-success">
|
||||
<Check size={14} aria-hidden="true" />
|
||||
{t('firstrun.hf_token_saved_fast', 'Hugging Face token saved — downloads are now faster')}
|
||||
{t('firstrun.hf_token_saved', 'Hugging Face token saved')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (checkFailed) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-wrap items-center gap-2 rounded-md bg-danger/10 px-3 py-2 text-sm',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span role="alert" className="text-danger">
|
||||
{t('common.error', 'Something went wrong')}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setCheckFailed(false);
|
||||
setTokenState(null);
|
||||
setCheckAttempt((attempt) => attempt + 1);
|
||||
}}
|
||||
>
|
||||
{t('bootstrap.retry', 'Retry')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Still checking — never flash the "add a token" pitch before we know
|
||||
// whether one is already active.
|
||||
if (tokenState == null) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-transparent bg-primary/[0.07] px-3 py-2 text-sm text-fg-muted',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Zap size={16} className="shrink-0 text-primary" aria-hidden="true" />
|
||||
{t('firstrun.checking', 'checking…')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SOURCE_LABELS = {
|
||||
app: t('settings.hf_source_app_label', {
|
||||
defaultValue: 'VoiceStudio (encrypted, recommended)',
|
||||
}),
|
||||
env: t('settings.hf_source_env_label', {
|
||||
defaultValue: 'Environment variable',
|
||||
}),
|
||||
'hf-cli': t('settings.hf_source_cli_label', {
|
||||
defaultValue: 'HuggingFace CLI',
|
||||
}),
|
||||
};
|
||||
const activeRow = tokenState?.sources?.find((row) => row.set);
|
||||
|
||||
// A token is already configured locally and the user hasn't asked to replace
|
||||
// it — show the satisfied state, not the pitch (#FR-006).
|
||||
if (activeRow && !replacing) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-wrap items-center gap-2 rounded-md border border-transparent bg-success/[0.09] px-3 py-2 text-sm',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Check size={16} className="shrink-0 text-success" aria-hidden="true" />
|
||||
<span className="font-semibold text-success">
|
||||
{t('firstrun.hf_token_active_using', {
|
||||
source: SOURCE_LABELS[activeRow.source] || activeRow.source,
|
||||
masked: activeRow.masked || '',
|
||||
defaultValue: 'Hugging Face token found in {{source}} — {{masked}}',
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer appearance-none whitespace-nowrap border-0 bg-transparent p-0 text-[0.76rem] text-primary underline hover:no-underline"
|
||||
onClick={() => setReplacing(true)}
|
||||
>
|
||||
{t('firstrun.hf_token_replace', 'Replace token…')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A successful state read found no token, or replacement was explicit.
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -62,8 +185,16 @@ export default function HfTokenCard({ className = '' }) {
|
||||
)}
|
||||
>
|
||||
<Zap size={16} className="shrink-0 text-primary" aria-hidden="true" />
|
||||
<span className="font-semibold max-[560px]:hidden">
|
||||
{t('firstrun.hf_token_inline_prompt', 'Speed up downloads with a free Hugging Face token')}
|
||||
<span className={cn('font-semibold', !(replacing && activeRow) && 'max-[560px]:hidden')}>
|
||||
{replacing && activeRow
|
||||
? t(
|
||||
'firstrun.hf_token_replace_warning',
|
||||
'This replaces the token saved for this app. It does not revoke the old token.',
|
||||
)
|
||||
: t(
|
||||
'firstrun.hf_token_inline_prompt',
|
||||
'Speed up downloads with a free Hugging Face token',
|
||||
)}
|
||||
</span>
|
||||
<Input
|
||||
size="sm"
|
||||
@@ -72,7 +203,9 @@ export default function HfTokenCard({ className = '' }) {
|
||||
placeholder={t('firstrun.hf_token_inline_ph', 'Paste hf_… token (optional)')}
|
||||
value={hfToken}
|
||||
autoComplete="off"
|
||||
disabled={hfState === 'saving'}
|
||||
onChange={(e) => {
|
||||
if (hfState === 'saving') return;
|
||||
setHfToken(e.target.value);
|
||||
if (hfState !== 'idle') setHfState('idle');
|
||||
}}
|
||||
@@ -94,6 +227,20 @@ export default function HfTokenCard({ className = '' }) {
|
||||
? t('firstrun.hf_token_saving', 'saving…')
|
||||
: t('firstrun.hf_token_save', 'Save')}
|
||||
</Button>
|
||||
{replacing && activeRow && (
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer appearance-none whitespace-nowrap border-0 bg-transparent p-0 text-[0.76rem] text-fg-muted underline hover:no-underline"
|
||||
disabled={hfState === 'saving'}
|
||||
onClick={() => {
|
||||
setReplacing(false);
|
||||
setHfToken('');
|
||||
setHfState('idle');
|
||||
}}
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer appearance-none whitespace-nowrap border-0 bg-transparent p-0 text-[0.76rem] text-primary underline hover:no-underline"
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import HfTokenCard from './HfTokenCard';
|
||||
|
||||
const STATE_NONE_ACTIVE = {
|
||||
active: null,
|
||||
sources: [
|
||||
{
|
||||
source: 'app',
|
||||
set: false,
|
||||
masked: null,
|
||||
whoami_user: null,
|
||||
whoami_ok: false,
|
||||
},
|
||||
{
|
||||
source: 'env',
|
||||
set: false,
|
||||
masked: null,
|
||||
whoami_user: null,
|
||||
whoami_ok: false,
|
||||
},
|
||||
{
|
||||
source: 'hf-cli',
|
||||
set: false,
|
||||
masked: null,
|
||||
whoami_user: null,
|
||||
whoami_ok: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Mirrors the live report (#FR-006): `hf-cli` already resolved and validated.
|
||||
const STATE_HF_CLI_ACTIVE = {
|
||||
active: null,
|
||||
sources: [
|
||||
{
|
||||
source: 'app',
|
||||
set: false,
|
||||
masked: null,
|
||||
whoami_user: null,
|
||||
whoami_ok: false,
|
||||
},
|
||||
{
|
||||
source: 'env',
|
||||
set: false,
|
||||
masked: null,
|
||||
whoami_user: null,
|
||||
whoami_ok: false,
|
||||
},
|
||||
{
|
||||
source: 'hf-cli',
|
||||
set: true,
|
||||
masked: 'hf_…Sfb',
|
||||
whoami_user: null,
|
||||
whoami_ok: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function mockFetchOnce(payload, status = 200) {
|
||||
return vi.fn().mockResolvedValueOnce({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => payload,
|
||||
text: async () => JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
function mockFetchSequence(...responses) {
|
||||
const fn = vi.fn();
|
||||
for (const r of responses) {
|
||||
fn.mockResolvedValueOnce({
|
||||
ok: r.status >= 200 && r.status < 300,
|
||||
status: r.status,
|
||||
json: async () => r.body,
|
||||
text: async () => JSON.stringify(r.body),
|
||||
});
|
||||
}
|
||||
return fn;
|
||||
}
|
||||
|
||||
describe('HfTokenCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows the add-token pitch when no token is active anywhere', async () => {
|
||||
global.fetch = mockFetchOnce(STATE_NONE_ACTIVE);
|
||||
render(<HfTokenCard />);
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(/hf_/)).toBeInTheDocument());
|
||||
expect(
|
||||
screen.getByText(/Speed up downloads with a free Hugging Face token/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does NOT pitch a new token once one is locally configured without validation (#FR-006)', async () => {
|
||||
global.fetch = mockFetchOnce(STATE_HF_CLI_ACTIVE);
|
||||
render(<HfTokenCard />);
|
||||
|
||||
// The satisfied state names the masked value; the blind "paste a token"
|
||||
// input must never appear alongside it.
|
||||
await waitFor(() => expect(screen.getByText(/hf_…Sfb/)).toBeInTheDocument());
|
||||
expect(screen.queryByPlaceholderText(/hf_/)).toBeNull();
|
||||
expect(screen.queryByText(/Speed up downloads with a free Hugging Face token/)).toBeNull();
|
||||
});
|
||||
|
||||
it('replacing an active token requires an explicit "Replace…" click, not a blind paste-and-Save', async () => {
|
||||
global.fetch = mockFetchOnce(STATE_HF_CLI_ACTIVE);
|
||||
render(<HfTokenCard />);
|
||||
await waitFor(() => expect(screen.getByText(/hf_…Sfb/)).toBeInTheDocument());
|
||||
|
||||
// Still no paste field until the user opts in.
|
||||
expect(screen.queryByPlaceholderText(/hf_/)).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /replace/i }));
|
||||
|
||||
// Now the field appears, alongside a warning that this overwrites the
|
||||
// token above — the deliberate-action gate the fix adds.
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(/hf_/)).toBeInTheDocument());
|
||||
expect(screen.getByText(/replaces the token saved for this app/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the overwrite warning visible on narrow screens (unlike the dismissable pitch)', async () => {
|
||||
global.fetch = mockFetchOnce(STATE_HF_CLI_ACTIVE);
|
||||
render(<HfTokenCard />);
|
||||
await waitFor(() => expect(screen.getByText(/hf_…Sfb/)).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', { name: /replace/i }));
|
||||
|
||||
const warning = await screen.findByText(/replaces the token saved for this app/i);
|
||||
// The default pitch is allowed to hide at <=560px; the overwrite safety
|
||||
// warning must not carry that same responsive-hide class, or a user on a
|
||||
// narrow viewport can replace a working token without ever seeing it.
|
||||
expect(warning.className).not.toMatch(/max-\[560px\]:hidden/);
|
||||
});
|
||||
|
||||
it('shows a neutral checking placeholder while state is loading, never the pitch', async () => {
|
||||
let resolveFetch;
|
||||
global.fetch = vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}),
|
||||
);
|
||||
render(<HfTokenCard />);
|
||||
|
||||
expect(screen.queryByPlaceholderText(/hf_/)).toBeNull();
|
||||
expect(screen.queryByText(/Speed up downloads with a free Hugging Face token/)).toBeNull();
|
||||
expect(screen.getByText(/checking/i)).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => expect(global.fetch).toHaveBeenCalledOnce());
|
||||
resolveFetch({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => STATE_NONE_ACTIVE,
|
||||
text: async () => JSON.stringify(STATE_NONE_ACTIVE),
|
||||
});
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(/hf_/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('keeps Save hidden after a failed state read until Retry discovers the existing token', async () => {
|
||||
const failed = mockFetchOnce({ detail: 'private server failure' }, 503);
|
||||
failed.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => STATE_HF_CLI_ACTIVE,
|
||||
});
|
||||
global.fetch = failed;
|
||||
render(<HfTokenCard />);
|
||||
const retry = await screen.findByRole('button', { name: 'Retry' });
|
||||
expect(screen.queryByPlaceholderText(/hf_/)).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /^save$/i })).toBeNull();
|
||||
expect(screen.queryByText(/private server failure/)).toBeNull();
|
||||
fireEvent.click(retry);
|
||||
await screen.findByText(/hf_…Sfb/);
|
||||
expect(screen.queryByPlaceholderText(/hf_/)).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: /replace/i }));
|
||||
expect(screen.getByText(/replaces the token saved for this app/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves an app token through the canonical Settings endpoint without claiming validation', async () => {
|
||||
const fetchMock = mockFetchSequence(
|
||||
{ status: 200, body: STATE_NONE_ACTIVE }, // GET state
|
||||
{ status: 200, body: STATE_HF_CLI_ACTIVE }, // POST app token
|
||||
);
|
||||
global.fetch = fetchMock;
|
||||
|
||||
render(<HfTokenCard />);
|
||||
const input = await screen.findByPlaceholderText(/hf_/);
|
||||
fireEvent.change(input, { target: { value: 'hf_newtoken123' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const postCall = fetchMock.mock.calls.find(([, opts]) => opts?.method === 'POST');
|
||||
expect(postCall).toBeTruthy();
|
||||
expect(postCall[0]).toMatch(/\/api\/settings\/hf-token$/);
|
||||
expect(JSON.parse(postCall[1].body)).toEqual({ token: 'hf_newtoken123' });
|
||||
});
|
||||
await screen.findByText('Hugging Face token saved');
|
||||
expect(screen.queryByText(/downloads are now faster/)).toBeNull();
|
||||
});
|
||||
it.each(['app', 'env', 'hf-cli'])(
|
||||
'replaces the used token from %s through the app source',
|
||||
async (source) => {
|
||||
const state = {
|
||||
active: null,
|
||||
sources: STATE_NONE_ACTIVE.sources.map((row) => ({
|
||||
...row,
|
||||
set: row.source === source,
|
||||
masked: row.source === source ? 'hf_…old' : null,
|
||||
})),
|
||||
};
|
||||
global.fetch = mockFetchSequence(
|
||||
{ status: 200, body: state },
|
||||
{ status: 200, body: STATE_NONE_ACTIVE },
|
||||
);
|
||||
render(<HfTokenCard />);
|
||||
await screen.findByText(/hf_…old/);
|
||||
expect(screen.queryByPlaceholderText(/hf_/)).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: /replace/i }));
|
||||
fireEvent.change(screen.getByPlaceholderText(/hf_/), {
|
||||
target: { value: ' hf_replacement ' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
||||
await screen.findByText('Hugging Face token saved');
|
||||
const [url, options] = global.fetch.mock.calls.find(([, opts]) => opts?.method === 'POST');
|
||||
expect(url).toMatch(/\/api\/settings\/hf-token$/);
|
||||
expect(JSON.parse(options.body)).toEqual({ token: 'hf_replacement' });
|
||||
},
|
||||
);
|
||||
|
||||
it('retains the replacement warning and token after a failed save so it can be retried', async () => {
|
||||
global.fetch = mockFetchSequence(
|
||||
{ status: 200, body: STATE_HF_CLI_ACTIVE },
|
||||
{ status: 500, body: { detail: 'private server failure' } },
|
||||
{ status: 200, body: STATE_NONE_ACTIVE },
|
||||
);
|
||||
render(<HfTokenCard />);
|
||||
await screen.findByText(/hf_…Sfb/);
|
||||
fireEvent.click(screen.getByRole('button', { name: /replace/i }));
|
||||
fireEvent.change(screen.getByPlaceholderText(/hf_/), {
|
||||
target: { value: 'hf_replacement' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
||||
await screen.findByText(/Could not save the token/);
|
||||
expect(screen.getByPlaceholderText(/hf_/)).toHaveValue('hf_replacement');
|
||||
expect(screen.getByText(/replaces the token saved for this app/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/private server failure/)).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
||||
await screen.findByText('Hugging Face token saved');
|
||||
});
|
||||
it.each([
|
||||
null,
|
||||
{},
|
||||
{ sources: [] },
|
||||
{ sources: [null] },
|
||||
{ sources: [...STATE_NONE_ACTIVE.sources, null] },
|
||||
])('keeps malformed token state gated: %j', async (state) => {
|
||||
global.fetch = mockFetchOnce(state);
|
||||
render(<HfTokenCard />);
|
||||
await screen.findByRole('button', { name: 'Retry' });
|
||||
expect(screen.queryByPlaceholderText(/hf_/)).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /^save$/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('waits for Retry to finish before showing the empty-state form', async () => {
|
||||
let resolveRetry;
|
||||
global.fetch = mockFetchOnce({ detail: 'failed' }, 503);
|
||||
global.fetch.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveRetry = resolve;
|
||||
}),
|
||||
);
|
||||
render(<HfTokenCard />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Retry' }));
|
||||
expect(screen.getByText(/checking/i)).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText(/hf_/)).toBeNull();
|
||||
await waitFor(() => expect(resolveRetry).toBeTypeOf('function'));
|
||||
resolveRetry({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => STATE_NONE_ACTIVE,
|
||||
});
|
||||
await screen.findByPlaceholderText(/hf_/);
|
||||
});
|
||||
|
||||
it('does not issue duplicate saves or cancel an in-flight replacement', async () => {
|
||||
let resolveSave;
|
||||
global.fetch = mockFetchOnce(STATE_HF_CLI_ACTIVE);
|
||||
global.fetch.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSave = resolve;
|
||||
}),
|
||||
);
|
||||
render(<HfTokenCard />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /replace/i }));
|
||||
fireEvent.change(screen.getByPlaceholderText(/hf_/), {
|
||||
target: { value: 'hf_replacement' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
||||
expect(screen.getByRole('button', { name: /saving/i })).toBeDisabled();
|
||||
expect(screen.getByPlaceholderText(/hf_/)).toBeDisabled();
|
||||
fireEvent.change(screen.getByPlaceholderText(/hf_/), { target: { value: 'hf_racing_edit' } });
|
||||
expect(screen.getByPlaceholderText(/hf_/)).toHaveValue('hf_replacement');
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled();
|
||||
fireEvent.keyDown(screen.getByPlaceholderText(/hf_/), { key: 'Enter' });
|
||||
await waitFor(() => expect(resolveSave).toBeTypeOf('function'));
|
||||
expect(global.fetch.mock.calls.filter(([, opts]) => opts?.method === 'POST')).toHaveLength(1);
|
||||
resolveSave({ ok: true, status: 200, json: async () => STATE_NONE_ACTIVE });
|
||||
await screen.findByText('Hugging Face token saved');
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,6 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useAppStore } from '../store';
|
||||
import NetworkToggle from './NetworkToggle';
|
||||
import ComputeQuickSettings from './ComputeQuickSettings';
|
||||
import EngineQuickSwitch from './EngineQuickSwitch';
|
||||
import { APP_VERSION, whatsNewPending } from '../utils/appVersion';
|
||||
import DonateMomentPopover, { DONATE_POPOVER_AUTO_DISMISS_MS } from './DonateMomentPopover';
|
||||
import { DONATION_MOMENT_EVENT, optOutOfDonationMoments } from '../utils/donationMoments';
|
||||
@@ -257,9 +256,10 @@ export default function LogsFooter() {
|
||||
return SOURCES.some((s) => s.id === v) ? v : 'backend';
|
||||
});
|
||||
|
||||
// Raw log state per source. Backend / Tauri come from HTTP; frontend
|
||||
// comes from the in-process ring buffer in consoleBuffer.js.
|
||||
const [lines, setLines] = useState({ backend: [], frontend: [], tauri: [] });
|
||||
// Only the local console ring needs state; HTTP logs render from their query.
|
||||
const [frontendLines, setFrontendLines] = useState(() =>
|
||||
getFrontendLogs().map(formatFrontendLine),
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const scrollRef = useRef(null);
|
||||
|
||||
@@ -285,20 +285,16 @@ export default function LogsFooter() {
|
||||
const backendLogs = useSystemLogs(300, true, collapsed ? 45_000 : 10_000);
|
||||
const tauriLogs = useTauriLogs(300, true, collapsed ? 45_000 : 10_000);
|
||||
|
||||
// Sync query data into local state for the rendering pipeline
|
||||
useEffect(() => {
|
||||
if (backendLogs.data) {
|
||||
setLines((prev) => ({ ...prev, backend: backendLogs.data.lines || [] }));
|
||||
}
|
||||
}, [backendLogs.data]);
|
||||
const lines = useMemo(
|
||||
() => ({
|
||||
backend: backendLogs.data?.lines || [],
|
||||
frontend: frontendLines,
|
||||
tauri: tauriLogs.data?.lines || [],
|
||||
}),
|
||||
[backendLogs.data, tauriLogs.data, frontendLines],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (tauriLogs.data) {
|
||||
setLines((prev) => ({ ...prev, tauri: tauriLogs.data.lines || [] }));
|
||||
}
|
||||
}, [tauriLogs.data]);
|
||||
|
||||
// Skip the setLines (and the re-render it forces) when the console ring
|
||||
// Skip the state update (and the re-render it forces) when the console ring
|
||||
// buffer hasn't changed since the last pull — same length + same last
|
||||
// timestamp means nothing new arrived.
|
||||
const lastFrontendPull = useRef({ len: -1, t: 0 });
|
||||
@@ -308,10 +304,7 @@ export default function LogsFooter() {
|
||||
const seen = lastFrontendPull.current;
|
||||
if (raw.length === seen.len && lastT === seen.t) return;
|
||||
lastFrontendPull.current = { len: raw.length, t: lastT };
|
||||
setLines((prev) => ({
|
||||
...prev,
|
||||
frontend: raw.map(formatFrontendLine),
|
||||
}));
|
||||
setFrontendLines(raw.map(formatFrontendLine));
|
||||
}, []);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
@@ -331,7 +324,8 @@ export default function LogsFooter() {
|
||||
|
||||
// ── Notifications (shared TanStack Query cache with the header bell) ────
|
||||
// Already filtered to what the user hasn't dismissed — badge and tab agree.
|
||||
const { notifications } = useVisibleNotifications();
|
||||
const { notifications, isSuccess: notificationsReady } = useVisibleNotifications();
|
||||
const allSourcesReady = backendLogs.isSuccess && tauriLogs.isSuccess && notificationsReady;
|
||||
const dismissNotification = useAppStore((s) => s.dismissNotification);
|
||||
|
||||
// ── Donation moment popover (see utils/donationMoments.js) ─────────────
|
||||
@@ -422,10 +416,16 @@ export default function LogsFooter() {
|
||||
// ── Actions ─────────────────────────────────────────────────────────
|
||||
const onClear = async () => {
|
||||
try {
|
||||
if (active === 'backend') await clearSystemLogs();
|
||||
else if (active === 'tauri') await clearTauriLogs();
|
||||
else if (active === 'frontend') clearFrontendLogs();
|
||||
setLines((prev) => ({ ...prev, [active]: [] }));
|
||||
if (active === 'backend') {
|
||||
await clearSystemLogs();
|
||||
await backendLogs.refetch({ throwOnError: true });
|
||||
} else if (active === 'tauri') {
|
||||
await clearTauriLogs();
|
||||
await tauriLogs.refetch({ throwOnError: true });
|
||||
} else if (active === 'frontend') {
|
||||
clearFrontendLogs();
|
||||
pullFrontend();
|
||||
}
|
||||
toast.success(t('logs.log_cleared', { source: active }));
|
||||
} catch (e) {
|
||||
toast.error(t('logs.clear_failed', { message: e?.message || e }));
|
||||
@@ -640,7 +640,6 @@ export default function LogsFooter() {
|
||||
)}
|
||||
</button>
|
||||
<ComputeQuickSettings />
|
||||
<EngineQuickSwitch shortcutTarget dropUp />
|
||||
<NetworkToggle />
|
||||
<button
|
||||
type="button"
|
||||
@@ -772,7 +771,10 @@ export default function LogsFooter() {
|
||||
|
||||
{!collapsed && active === 'notifications' && (
|
||||
<div className="logs-footer__body flex flex-col gap-[2px] px-[8px] py-[6px] flex-1 min-h-0 overflow-y-auto select-text">
|
||||
{notifications.length === 0 ? (
|
||||
{allSourcesReady &&
|
||||
notifications.length === 0 &&
|
||||
mergedCounts.error === 0 &&
|
||||
mergedCounts.warn === 0 ? (
|
||||
<div className="p-[12px] [color:var(--chrome-fg-dim)] not-italic text-[11px] text-center">
|
||||
{t('logs.all_clear')}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useMemo, useRef, useEffect, useLayoutEffect, useId } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Check, X, Search, Globe } from 'lucide-react';
|
||||
import { Check, X, Search, Globe, ChevronDown } from 'lucide-react';
|
||||
import { POPULAR_LANGS } from '../utils/constants';
|
||||
import { LANG_CODES } from '../utils/languages';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -17,6 +17,10 @@ export default function MultiLangPicker({
|
||||
onChange, // (newSelected) => void
|
||||
disabled = false,
|
||||
progressByCode = {},
|
||||
single = false,
|
||||
compact = false,
|
||||
options = LANG_CODES,
|
||||
ariaLabel,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [dropOpen, setDropOpen] = useState(false);
|
||||
@@ -116,6 +120,13 @@ export default function MultiLangPicker({
|
||||
}, [query, selected]);
|
||||
|
||||
const addLang = (lang, code) => {
|
||||
if (single) {
|
||||
onChange?.([{ lang, code }]);
|
||||
setDropOpen(false);
|
||||
setQuery('');
|
||||
triggerRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (selectedCodes.has(code)) return;
|
||||
onChange?.([...selected, { lang, code }]);
|
||||
setQuery('');
|
||||
@@ -127,17 +138,17 @@ export default function MultiLangPicker({
|
||||
|
||||
const filteredLangs = useMemo(() => {
|
||||
const q = query.toLowerCase().trim();
|
||||
return LANG_CODES.filter(
|
||||
return options.filter(
|
||||
(lc) =>
|
||||
!selectedCodes.has(lc.code) &&
|
||||
(!q || lc.label.toLowerCase().includes(q) || lc.code.toLowerCase().includes(q)),
|
||||
);
|
||||
}, [query, selectedCodes]);
|
||||
}, [query, selectedCodes, options]);
|
||||
|
||||
const popularFiltered = useMemo(() => {
|
||||
const q = query.toLowerCase().trim();
|
||||
return POPULAR_LANGS.map((lang) => {
|
||||
const match = LANG_CODES.find((lc) => lc.label.toLowerCase() === lang.toLowerCase());
|
||||
const match = options.find((lc) => lc.label.toLowerCase() === lang.toLowerCase());
|
||||
return match ? { lang, code: match.code } : null;
|
||||
}).filter(
|
||||
(item) =>
|
||||
@@ -145,7 +156,7 @@ export default function MultiLangPicker({
|
||||
!selectedCodes.has(item.code) &&
|
||||
(!q || item.lang.toLowerCase().includes(q) || item.code.includes(q)),
|
||||
);
|
||||
}, [query, selectedCodes]);
|
||||
}, [query, selectedCodes, options]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropRef}>
|
||||
@@ -155,21 +166,32 @@ export default function MultiLangPicker({
|
||||
className="flex min-h-[30px] max-w-full items-center gap-[7px] rounded-[var(--chrome-radius-pill)] border border-transparent bg-[var(--chrome-hover-bg)] px-[9px] py-[4px] text-left text-[color:var(--chrome-fg)] cursor-pointer transition-colors hover:bg-[var(--chrome-accent-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--chrome-accent)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => setDropOpen((open) => !open)}
|
||||
disabled={disabled}
|
||||
style={single ? { minHeight: compact ? 36 : 44, width: '100%' } : undefined}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={dropOpen}
|
||||
aria-controls={dropOpen ? menuId : undefined}
|
||||
aria-label={t('dub.manage_languages')}
|
||||
aria-label={ariaLabel || t('dub.manage_languages')}
|
||||
>
|
||||
<Globe size={11} className="shrink-0" aria-hidden="true" />
|
||||
<span className="truncate text-[0.7rem] font-medium">{t('dub.manage_languages')}</span>
|
||||
<span className="shrink-0 font-mono text-[0.62rem] text-[color:var(--chrome-fg-muted)]">
|
||||
{t('dub.languages_selected', { count: selected.length })}
|
||||
</span>
|
||||
{selected.length > 0 && (
|
||||
<span className="min-w-0 truncate font-mono text-[0.6rem] text-[color:var(--chrome-fg-dim)]">
|
||||
{t('dub.languages_done')}: {completedCount} · {t('dub.languages_pending')}:{' '}
|
||||
{selected.length - completedCount}
|
||||
</span>
|
||||
{single ? (
|
||||
<>
|
||||
<LanguageFlag code={selected[0]?.code} />
|
||||
<span className="min-w-0 truncate text-sm">{selected[0]?.lang}</span>
|
||||
<ChevronDown size={14} className="shrink-0" aria-hidden="true" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Globe size={11} className="shrink-0" aria-hidden="true" />
|
||||
<span className="truncate text-[0.7rem] font-medium">{t('dub.manage_languages')}</span>
|
||||
<span className="shrink-0 font-mono text-[0.62rem] text-[color:var(--chrome-fg-muted)]">
|
||||
{t('dub.languages_selected', { count: selected.length })}
|
||||
</span>
|
||||
{selected.length > 0 && (
|
||||
<span className="min-w-0 truncate font-mono text-[0.6rem] text-[color:var(--chrome-fg-dim)]">
|
||||
{t('dub.languages_done')}: {completedCount} · {t('dub.languages_pending')}:{' '}
|
||||
{selected.length - completedCount}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -181,7 +203,26 @@ export default function MultiLangPicker({
|
||||
id={menuId}
|
||||
className="multi-lang__drop"
|
||||
role="dialog"
|
||||
aria-label={t('dub.manage_languages')}
|
||||
aria-label={ariaLabel || t('dub.manage_languages')}
|
||||
onKeyDown={(event) => {
|
||||
if (!single) return;
|
||||
const buttons = Array.from(menuRef.current.querySelectorAll('button'));
|
||||
if (event.key === 'Enter' && event.target === inputRef.current) {
|
||||
event.preventDefault();
|
||||
buttons[0]?.click();
|
||||
}
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
const index = buttons.indexOf(document.activeElement);
|
||||
const next =
|
||||
event.key === 'ArrowDown'
|
||||
? index + 1
|
||||
: index < 0
|
||||
? buttons.length - 1
|
||||
: index - 1;
|
||||
buttons[(next + buttons.length) % buttons.length]?.focus();
|
||||
}
|
||||
}}
|
||||
style={
|
||||
menuPos
|
||||
? {
|
||||
@@ -193,7 +234,7 @@ export default function MultiLangPicker({
|
||||
: { visibility: 'hidden' }
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-[6px] px-[10px] py-[8px] border-b border-solid border-b-transparent text-[color:var(--chrome-fg-muted)]">
|
||||
<div className="flex items-center gap-[6px] px-[10px] py-[8px] border-0 text-[color:var(--chrome-fg-muted)]">
|
||||
<Search size={10} aria-hidden="true" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -207,7 +248,7 @@ export default function MultiLangPicker({
|
||||
className="flex-1 bg-transparent border-0 text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[0.78rem] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--chrome-accent)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-y-auto overscroll-contain flex-1 py-[4px]">
|
||||
<div className="multi-lang-options overflow-y-auto overscroll-contain flex-1 py-[4px]">
|
||||
{selectedFiltered.length > 0 && (
|
||||
<>
|
||||
<div className="[font-family:var(--font-mono)] text-[0.62rem] font-semibold uppercase [letter-spacing:0.04em] text-[color:var(--chrome-fg-dim)] pt-[6px] px-[10px] pb-[2px]">
|
||||
@@ -219,20 +260,25 @@ export default function MultiLangPicker({
|
||||
type="button"
|
||||
className="flex items-center gap-[8px] w-full px-[10px] py-[5px] bg-transparent border-0 text-[color:var(--chrome-fg)] [font-family:var(--font-sans)] text-[0.76rem] cursor-pointer text-left hover:bg-[var(--chrome-hover-bg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--chrome-accent)]"
|
||||
style={{ contentVisibility: 'auto', containIntrinsicSize: '30px' }}
|
||||
onClick={() => removeLang(item.code)}
|
||||
aria-label={t('common.remove', { term: item.lang })}
|
||||
onClick={() =>
|
||||
single ? addLang(item.lang, item.code) : removeLang(item.code)
|
||||
}
|
||||
aria-label={single ? item.lang : t('common.remove', { term: item.lang })}
|
||||
aria-pressed={single ? true : undefined}
|
||||
>
|
||||
<Check size={10} className="text-[var(--chrome-accent)]" aria-hidden="true" />
|
||||
<LanguageFlag code={item.code} />
|
||||
<span className="min-w-[28px] font-mono text-[0.68rem] font-semibold uppercase text-[var(--chrome-accent)]">
|
||||
{item.code}
|
||||
{item.code !== item.lang ? item.code : ''}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{item.lang}</span>
|
||||
<span className="shrink-0 font-mono text-[0.62rem] text-[var(--chrome-fg-dim)]">
|
||||
{progressByCode[item.code]?.ready || 0}/
|
||||
{progressByCode[item.code]?.total || 0}
|
||||
</span>
|
||||
<X size={10} aria-hidden="true" />
|
||||
{!single && (
|
||||
<span className="shrink-0 font-mono text-[0.62rem] text-[var(--chrome-fg-dim)]">
|
||||
{progressByCode[item.code]?.ready || 0}/
|
||||
{progressByCode[item.code]?.total || 0}
|
||||
</span>
|
||||
)}
|
||||
{!single && <X size={10} aria-hidden="true" />}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
@@ -252,7 +298,7 @@ export default function MultiLangPicker({
|
||||
>
|
||||
<LanguageFlag code={item.code} />
|
||||
<span className="[font-family:var(--font-mono)] text-[0.68rem] text-[color:var(--chrome-accent)] min-w-[28px] font-semibold">
|
||||
{item.code}
|
||||
{item.code !== item.lang ? item.code : ''}
|
||||
</span>
|
||||
<span className="min-w-0 truncate">{item.lang}</span>
|
||||
</button>
|
||||
@@ -262,7 +308,7 @@ export default function MultiLangPicker({
|
||||
<div className="[font-family:var(--font-mono)] text-[0.62rem] font-semibold uppercase [letter-spacing:0.04em] text-[color:var(--chrome-fg-dim)] pt-[6px] px-[10px] pb-[2px]">
|
||||
{t('dub.all_languages')}
|
||||
</div>
|
||||
{filteredLangs.slice(0, 50).map((lc) => (
|
||||
{(single ? filteredLangs : filteredLangs.slice(0, 50)).map((lc) => (
|
||||
<button
|
||||
key={lc.code}
|
||||
type="button"
|
||||
@@ -272,12 +318,12 @@ export default function MultiLangPicker({
|
||||
>
|
||||
<LanguageFlag code={lc.code} />
|
||||
<span className="[font-family:var(--font-mono)] text-[0.68rem] text-[color:var(--chrome-accent)] min-w-[28px] font-semibold">
|
||||
{lc.code}
|
||||
{lc.code !== lc.label ? lc.code : ''}
|
||||
</span>
|
||||
<span className="min-w-0 truncate">{lc.label}</span>
|
||||
</button>
|
||||
))}
|
||||
{filteredLangs.length > 50 && (
|
||||
{!single && filteredLangs.length > 50 && (
|
||||
<div className="px-[10px] py-[8px] text-[0.7rem] text-[color:var(--chrome-fg-dim)] text-center">
|
||||
{t('dub.more_to_narrow', { count: filteredLangs.length - 50 })}
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,38 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('MultiLangPicker viewport-safe menu', () => {
|
||||
it('offers compact editor sizing without shrinking the default picker', () => {
|
||||
const { rerender } = render(<MultiLangPicker single compact onChange={() => {}} />);
|
||||
expect(screen.getByRole('button', { name: 'Manage languages' })).toHaveStyle({
|
||||
minHeight: '36px',
|
||||
});
|
||||
rerender(<MultiLangPicker single onChange={() => {}} />);
|
||||
expect(screen.getByRole('button', { name: 'Manage languages' })).toHaveStyle({
|
||||
minHeight: '44px',
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces a single selection, preserves Auto, and closes after choosing', () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<MultiLangPicker
|
||||
single
|
||||
selected={[{ lang: 'English', code: 'en' }]}
|
||||
options={[{ label: 'Auto', code: 'Auto' }, ...LANG_CODES]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
const trigger = screen.getByRole('button', { name: 'Manage languages' });
|
||||
expect(trigger).toHaveTextContent('English');
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.queryByRole('button', { name: 'Remove English' })).not.toBeInTheDocument();
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Auto' } });
|
||||
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' });
|
||||
expect(onChange).toHaveBeenCalledWith([{ lang: 'Auto', code: 'Auto' }]);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(trigger).toHaveFocus();
|
||||
});
|
||||
|
||||
it('portals outside clipping ancestors and flips above a bottom-edge trigger', () => {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1000 });
|
||||
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 });
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
.nav-rail {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
justify-self: start;
|
||||
min-height: 0;
|
||||
}
|
||||
.nav-rail[data-side="right"] { justify-self: end; }
|
||||
.nav-rail[data-expanded="true"] {
|
||||
width: min(232px, 80vw);
|
||||
/* Solid themed base keeps the workspace from showing through. Elliptical
|
||||
washes form quiet waves without moving behind text or clipping tooltips. */
|
||||
background-color: color-mix(in srgb, var(--chrome-accent) 2%, var(--chrome-bg));
|
||||
background-image:
|
||||
radial-gradient(ellipse at 0% 25%, color-mix(in srgb, var(--chrome-accent) 4%, transparent), transparent 60%),
|
||||
radial-gradient(ellipse at 110% 100%, color-mix(in srgb, var(--chrome-accent) 3%, transparent), transparent 65%);
|
||||
box-shadow: 8px 0 24px rgb(0 0 0 / 18%);
|
||||
}
|
||||
.nav-rail-waves { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; fill: none; stroke: var(--chrome-accent); stroke-width: .7; opacity: .055; }
|
||||
.nav-rail[data-expanded="true"] > :not(.nav-rail-waves) { position: relative; }
|
||||
.nav-rail[data-side="right"][data-expanded="true"] { box-shadow: -8px 0 24px rgb(0 0 0 / 18%); }
|
||||
.nav-rail[data-expanded="true"] > div { width: 100%; padding-inline: 12px; }
|
||||
.nav-rail-links { min-height: 0; }
|
||||
.nav-rail[data-expanded="true"] .nav-rail-links { gap: 4px; overflow-y: auto; }
|
||||
.nav-rail-item[data-expanded="true"],
|
||||
.nav-rail[data-expanded="true"] .nav-rail-toggle {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
.nav-rail-item { flex-shrink: 0; }
|
||||
.nav-rail-item[data-expanded="true"] { height: 42px; border-radius: 8px; }
|
||||
.nav-rail-item[data-expanded="true"] span { font-weight: 450; }
|
||||
.nav-rail-item[data-expanded="true"][aria-current="page"] {
|
||||
border-color: transparent;
|
||||
background: color-mix(in srgb, var(--rail-accent) 13%, var(--color-bg));
|
||||
}
|
||||
.nav-rail-item[data-expanded="true"][aria-current="page"] span { font-weight: 600; }
|
||||
.nav-rail-item[data-expanded="true"]::before { left: 0; right: auto; width: 2px; box-shadow: none; }
|
||||
.nav-rail[data-side="right"] .nav-rail-item[data-expanded="true"]::before { right: 0; left: auto; }
|
||||
.nav-rail[data-expanded="true"] .nav-rail-toggle { width: calc(100% - 24px); margin-bottom: 8px; justify-content: space-between; }
|
||||
.nav-rail[data-expanded="true"] .nav-rail-toggle span { order: -1; font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.nav-rail[data-expanded="true"] .nav-rail-footer { padding-top: 12px; border-top: 1px solid var(--chrome-border); }
|
||||
.nav-rail-item:focus-visible, .nav-rail-toggle:focus-visible { outline: 2px solid var(--chrome-accent); outline-offset: 2px; }
|
||||
.nav-rail-item svg { flex-shrink: 0; transition: transform 160ms ease-out; }
|
||||
.nav-rail-item:is(:hover, :focus-visible) svg { transform: translateY(-1px) rotate(-5deg); }
|
||||
.nav-rail-item:active svg { transform: scale(0.94); }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nav-rail, .nav-rail-item svg { transition: none; transform: none !important; }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeftRight } from 'lucide-react';
|
||||
import { ArrowLeftRight, PanelLeftOpen, PanelLeftClose } from 'lucide-react';
|
||||
import './NavRail.css';
|
||||
import { NAV_ITEMS as ITEM_DEFS, NAV_FOOTER_ITEMS as FOOTER_DEFS } from './navItems';
|
||||
|
||||
// Shared icon-button base for the chrome rail (was `.rail-btn`). `group` enables
|
||||
@@ -18,7 +19,7 @@ function railLabelCls(side) {
|
||||
return `pointer-events-none absolute top-1/2 z-[10000] whitespace-nowrap rounded-[var(--chrome-radius-pill)] bg-[var(--chrome-bg)] px-[8px] py-[3px] font-sans text-[11px] font-medium text-[var(--chrome-fg)] opacity-0 [border:1px_solid_var(--chrome-border-strong)] [transition:opacity_0.15s,transform_0.15s] group-hover:opacity-100 group-hover:[transform:translate(0,-50%)] ${sideCls}`;
|
||||
}
|
||||
|
||||
function RailBtn({ active, Icon, label, accent, side, onClick }) {
|
||||
function RailBtn({ active, Icon, label, accent, side, onClick, expanded }) {
|
||||
// Active = accent-tinted fill/border + an accent indicator bar (`::before`)
|
||||
// hanging off the rail edge; flips edges with the rail side.
|
||||
const stateCls = active
|
||||
@@ -29,19 +30,40 @@ function RailBtn({ active, Icon, label, accent, side, onClick }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className={`${RAIL_BTN_BASE} ${stateCls}`}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={`nav-rail-item ${RAIL_BTN_BASE} ${stateCls}`}
|
||||
data-expanded={expanded}
|
||||
style={{ '--rail-accent': accent }}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span className={railLabelCls(side)}>{label}</span>
|
||||
<Icon size={18} aria-hidden="true" />
|
||||
<span className={expanded ? 'min-w-0 truncate text-sm' : railLabelCls(side)}>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NavRail({ mode, setMode, side = 'left', onFlipSide }) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const railRef = React.useRef(null);
|
||||
React.useEffect(() => {
|
||||
if (!expanded) return;
|
||||
const closeOutside = (event) => {
|
||||
if (!railRef.current?.contains(event.target)) setExpanded(false);
|
||||
};
|
||||
const escape = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setExpanded(false);
|
||||
railRef.current?.querySelector('[aria-expanded]')?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', closeOutside);
|
||||
document.addEventListener('keydown', escape);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', closeOutside);
|
||||
document.removeEventListener('keydown', escape);
|
||||
};
|
||||
}, [expanded]);
|
||||
const items = React.useMemo(
|
||||
() => ITEM_DEFS.map((d) => ({ ...d, label: t(`nav.${d.tKey}`) })),
|
||||
[t],
|
||||
@@ -61,27 +83,65 @@ export default function NavRail({ mode, setMode, side = 'left', onFlipSide }) {
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={railRef}
|
||||
data-expanded={expanded}
|
||||
data-side={side}
|
||||
className={`nav-rail z-50 flex select-none flex-col items-center gap-[10px] bg-[var(--chrome-bg)] pb-[10px] pt-[18px] ${asideBorder}`}
|
||||
>
|
||||
<div className="flex flex-1 flex-col items-center gap-[9px]">
|
||||
{expanded && (
|
||||
<svg
|
||||
className="nav-rail-waves"
|
||||
viewBox="0 0 232 1000"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
{[0, 12, 24, 36].map((offset) => (
|
||||
<path
|
||||
key={offset}
|
||||
transform={`translate(${offset} 0)`}
|
||||
d="M-90 80 C270 240 -170 450 60 650 S270 860 120 1040"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('nav.workspaces')}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
className="nav-rail-toggle inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md border-0 bg-transparent text-[var(--chrome-fg-muted)] cursor-pointer hover:bg-[var(--chrome-hover-bg)]"
|
||||
>
|
||||
{expanded ? <PanelLeftClose size={18} /> : <PanelLeftOpen size={18} />}
|
||||
{expanded && <span className="text-sm">{t('nav.workspaces')}</span>}
|
||||
</button>
|
||||
<div className="nav-rail-links flex flex-1 flex-col items-center gap-[9px]">
|
||||
{items.map((it) => (
|
||||
<RailBtn
|
||||
key={it.id}
|
||||
{...it}
|
||||
side={side}
|
||||
expanded={expanded}
|
||||
active={mode === it.id}
|
||||
onClick={() => setMode(it.id)}
|
||||
onClick={() => {
|
||||
setMode(it.id);
|
||||
setExpanded(false);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-[8px]">
|
||||
<div className="nav-rail-footer flex flex-col items-center gap-[8px]">
|
||||
{footerItems.map((it) => (
|
||||
<RailBtn
|
||||
key={it.id}
|
||||
{...it}
|
||||
side={side}
|
||||
expanded={expanded}
|
||||
active={mode === it.id}
|
||||
onClick={() => setMode(it.id)}
|
||||
onClick={() => {
|
||||
setMode(it.id);
|
||||
setExpanded(false);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import NavRail from './NavRail';
|
||||
|
||||
describe('expandable workspace rail', () => {
|
||||
it('reveals labels without duplicate tooltips and collapses on selection', () => {
|
||||
const setMode = vi.fn();
|
||||
render(<NavRail mode="launchpad" setMode={setMode} />);
|
||||
const toggle = screen.getByRole('button', { name: 'Workspaces' });
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
const voice = screen.getByRole('button', { name: 'Voice', exact: true });
|
||||
expect(voice).not.toHaveAttribute('title');
|
||||
expect(voice).toHaveAttribute('data-expanded', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Launchpad', exact: true })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'page',
|
||||
);
|
||||
fireEvent.click(voice);
|
||||
expect(setMode).toHaveBeenCalledWith('studio');
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('closes on Escape and restores toggle focus', () => {
|
||||
render(<NavRail mode="studio" setMode={vi.fn()} side="right" />);
|
||||
const toggle = screen.getByRole('button', { name: 'Workspaces' });
|
||||
fireEvent.click(toggle);
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(toggle).toHaveFocus();
|
||||
});
|
||||
});
|
||||
@@ -26,10 +26,10 @@ export default function NotificationPanel() {
|
||||
aria-label={`Notifications (${count})`}
|
||||
title="Notifications"
|
||||
>
|
||||
<Bell size={14} />
|
||||
<Bell size={14} className={count > 0 ? '-translate-x-0.5 translate-y-0.5' : ''} />
|
||||
{count > 0 && (
|
||||
<span
|
||||
className={`pointer-events-none absolute -top-[4px] -right-[4px] flex h-[14px] min-w-[14px] items-center justify-center rounded-[7px] px-[3px] font-mono text-[9px] font-bold leading-none text-white shadow-[0_1px_3px_rgba(0,0,0,0.4)] ${!hasErrors && hasWarns ? 'bg-warn' : 'bg-danger'}`}
|
||||
className={`pointer-events-none absolute top-0 right-0 flex h-[14px] min-w-[14px] items-center justify-center rounded-[7px] px-[3px] font-mono text-[9px] font-bold leading-none text-white shadow-[0_1px_3px_rgba(0,0,0,0.4)] ${!hasErrors && hasWarns ? 'bg-warn' : 'bg-danger'}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import NotificationPanel from './NotificationPanel';
|
||||
|
||||
vi.mock('../api/hooks', () => ({
|
||||
useVisibleNotifications: () => ({ notifications: [{ level: 'error' }, { level: 'warn' }] }),
|
||||
}));
|
||||
|
||||
it('keeps the badge inside the title-bar button', () => {
|
||||
render(<NotificationPanel />);
|
||||
expect(screen.getByText('2')).toHaveClass('top-0', 'right-0');
|
||||
expect(screen.getByText('2')).not.toHaveClass('-top-[4px]');
|
||||
});
|
||||
@@ -128,7 +128,17 @@ export default function SearchableSelect({
|
||||
return out;
|
||||
}, [query, recents, popular, byVal]);
|
||||
|
||||
const displayed = useMemo(() => filtered.slice(0, MAX_DISPLAY), [filtered]);
|
||||
const displayed = useMemo(() => {
|
||||
const seen = new Set(pinned.map(({ o }) => getVal(o)));
|
||||
return filtered
|
||||
.filter((option) => {
|
||||
const key = getVal(option);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.slice(0, MAX_DISPLAY);
|
||||
}, [filtered, pinned, getVal]);
|
||||
|
||||
const flatItems = useMemo(() => {
|
||||
const list = [];
|
||||
@@ -160,8 +170,8 @@ export default function SearchableSelect({
|
||||
const r = el.getBoundingClientRect();
|
||||
// Clamp within the viewport so a right-edge (narrow-column) trigger can't
|
||||
// push the min-220px menu off-screen and force a horizontal scrollbar.
|
||||
const width = Math.max(r.width, 220);
|
||||
const left = Math.min(r.left, Math.max(8, window.innerWidth - width - 8));
|
||||
const width = Math.min(Math.max(r.width, 220), window.innerWidth - 16);
|
||||
const left = Math.max(8, Math.min(r.left, window.innerWidth - width - 8));
|
||||
// Flip above the trigger when there isn't enough room below (e.g. the last
|
||||
// dub segment row, near the viewport bottom) — otherwise a below-anchored
|
||||
// fixed menu runs off-screen and scrolling just re-pins it there. Also cap
|
||||
@@ -174,8 +184,8 @@ export default function SearchableSelect({
|
||||
const listMax = Math.max(120, Math.floor(Math.min(280, openUp ? above : below)));
|
||||
setMenuPos(
|
||||
openUp
|
||||
? { bottom: vh - r.top + GAP, left, width: r.width, listMax }
|
||||
: { top: r.bottom + GAP, left, width: r.width, listMax },
|
||||
? { bottom: vh - r.top + GAP, left, width, listMax }
|
||||
: { top: r.bottom + GAP, left, width, listMax },
|
||||
);
|
||||
};
|
||||
place();
|
||||
@@ -220,6 +230,7 @@ export default function SearchableSelect({
|
||||
writeRecents(recentsKey, next);
|
||||
}
|
||||
setOpen(false);
|
||||
wrapRef.current?.querySelector('button')?.focus();
|
||||
};
|
||||
|
||||
const onKey = (e) => {
|
||||
@@ -236,6 +247,9 @@ export default function SearchableSelect({
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
wrapRef.current?.querySelector('button')?.focus();
|
||||
} else if (e.key === 'Tab') {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -273,7 +287,7 @@ export default function SearchableSelect({
|
||||
wrapMenu(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`z-[1000] bg-[rgba(29,32,33,0.98)] [border:1px_solid_rgba(255,255,255,0.1)] rounded-[6px] shadow-[0_8px_24px_rgba(0,0,0,0.5)] [backdrop-filter:blur(12px)] overflow-hidden min-w-[220px] max-w-[min(360px,90vw)] ${
|
||||
className={`z-[1000] bg-[var(--color-bg)] border-0 rounded-lg shadow-xl overflow-hidden max-w-[calc(100vw-16px)] ${
|
||||
menuPortal ? 'fixed' : 'absolute top-[calc(100%+4px)] left-0 right-0'
|
||||
}`}
|
||||
style={
|
||||
@@ -287,14 +301,15 @@ export default function SearchableSelect({
|
||||
}
|
||||
role="listbox"
|
||||
>
|
||||
<div className="relative p-[6px] [border-bottom:1px_solid_rgba(255,255,255,0.06)] flex items-center gap-[6px]">
|
||||
<div className="relative p-3 flex items-center gap-2">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-[12px] top-1/2 -translate-y-1/2 text-[color:var(--text-secondary)] pointer-events-none"
|
||||
className="absolute left-5 top-1/2 -translate-y-1/2 text-[var(--chrome-fg-muted)] pointer-events-none"
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="flex-1 w-full bg-[rgba(0,0,0,0.25)] [border:1px_solid_rgba(255,255,255,0.08)] rounded-[4px] py-[5px] pr-[8px] pl-[24px] text-[0.72rem] text-[color:var(--text-primary)] outline-none [font-family:inherit] focus:[border-color:rgba(250,189,47,0.4)]"
|
||||
className="flex-1 min-w-0 w-full min-h-10 bg-[var(--chrome-hover-bg)] border-0 rounded-md py-2 pr-3 pl-8 text-sm text-[var(--chrome-fg)] focus-visible:outline-2 focus-visible:outline-[var(--chrome-accent)] [font-family:inherit]"
|
||||
aria-label={t('common.search')}
|
||||
placeholder={t('common.search')}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
@@ -357,19 +372,19 @@ export default function SearchableSelect({
|
||||
// stronger amber wash; highlight (and hover, when neither
|
||||
// selected nor highlighted) gets the amber accent.
|
||||
className={[
|
||||
'flex items-center gap-[6px] py-[5px] px-[10px] text-[0.72rem] cursor-pointer select-none',
|
||||
'flex min-h-10 items-center gap-2 py-2 px-3 mx-2 my-1 rounded-md text-sm cursor-pointer select-none',
|
||||
selected
|
||||
? 'text-[#8ec07c] font-medium'
|
||||
? 'text-[var(--chrome-accent)] font-medium'
|
||||
: highlighted
|
||||
? 'text-[color:var(--accent)]'
|
||||
: 'text-[color:var(--text-primary)] hover:text-[color:var(--accent)]',
|
||||
? 'text-[var(--chrome-accent)]'
|
||||
: 'text-[var(--chrome-fg)] hover:text-[var(--chrome-accent)]',
|
||||
selected && highlighted
|
||||
? 'bg-[rgba(250,189,47,0.18)]'
|
||||
? 'bg-[var(--chrome-accent-bg)]'
|
||||
: selected
|
||||
? 'bg-[rgba(142,192,124,0.1)]'
|
||||
? 'bg-[var(--chrome-accent-bg)]'
|
||||
: highlighted
|
||||
? 'bg-[rgba(250,189,47,0.12)]'
|
||||
: 'hover:bg-[rgba(250,189,47,0.12)]',
|
||||
? 'bg-[var(--chrome-accent-bg)]'
|
||||
: 'hover:bg-[var(--chrome-accent-bg)]',
|
||||
].join(' ')}
|
||||
onMouseEnter={() => setHighlight(idx)}
|
||||
onMouseDown={(e) => {
|
||||
@@ -388,7 +403,13 @@ export default function SearchableSelect({
|
||||
<span className="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{renderOption ? renderOption(it.o) : getLabel(it.o)}
|
||||
</span>
|
||||
{selected && <Check size={10} className="text-[#8ec07c] shrink-0" />}
|
||||
{selected && (
|
||||
<Check
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
className="text-[var(--chrome-accent)] shrink-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import SearchableSelect from './SearchableSelect';
|
||||
|
||||
describe('SearchableSelect menu', () => {
|
||||
it('shows pinned options only once and keeps keyboard selection aligned', () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<SearchableSelect
|
||||
options={['Alpha', 'Beta']}
|
||||
popular={['Alpha']}
|
||||
onChange={onChange}
|
||||
ariaLabel="Voice"
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Voice' }));
|
||||
expect(screen.getAllByRole('option')).toHaveLength(2);
|
||||
expect(screen.getAllByRole('option', { name: 'Alpha' })).toHaveLength(1);
|
||||
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'ArrowDown' });
|
||||
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' });
|
||||
expect(onChange).toHaveBeenCalledWith('Beta');
|
||||
});
|
||||
|
||||
it('matches a wide trigger without the old 360px menu cap', () => {
|
||||
const { container } = render(
|
||||
<SearchableSelect options={['Alpha']} menuPortal ariaLabel="Voice" />,
|
||||
);
|
||||
vi.spyOn(container.querySelector('.ss-wrap'), 'getBoundingClientRect').mockReturnValue({
|
||||
left: 20,
|
||||
top: 100,
|
||||
bottom: 140,
|
||||
width: 700,
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Voice' }));
|
||||
expect(screen.getByRole('listbox').style.width).toBe('700px');
|
||||
expect(screen.getByRole('listbox').className).not.toContain('360px');
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppStore } from '../store';
|
||||
import { unlockAudio } from '../utils/audioUnlock';
|
||||
import { bindWaveformPan } from '../utils/waveformPan';
|
||||
import SegmentTrack from './SegmentTrack';
|
||||
|
||||
// Transport-control button chrome (migrated from the .waveform-btn family in
|
||||
@@ -257,13 +258,9 @@ function WaveformTimeline(
|
||||
// ── 3. Init WaveSurfer with that single media element ────────────────────
|
||||
let ws;
|
||||
try {
|
||||
// Start at the container's measured height; a ResizeObserver below
|
||||
// keeps WaveSurfer in sync when the column resizes. Fallback to 200
|
||||
// if layout hasn't settled yet so we never render a flat sliver.
|
||||
const initialHeight = Math.max(
|
||||
80,
|
||||
Math.min(waveContainerRef.current.clientHeight || 120, 160),
|
||||
);
|
||||
// The compact editor reserves separate space for the ruler/minimap;
|
||||
// the waveform itself must not grow to consume those plugin rows.
|
||||
const initialHeight = 56;
|
||||
const minimap = MinimapPlugin.create({
|
||||
height: 20,
|
||||
waveColor: 'rgba(168,153,132,0.25)',
|
||||
@@ -550,6 +547,31 @@ function WaveformTimeline(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [audioSrc, videoSrc, playbackFallbackSrc]);
|
||||
|
||||
// Native metadata/playback is usable before the full waveform is decoded.
|
||||
useEffect(() => {
|
||||
const media = mediaElRef.current;
|
||||
if (!media) return;
|
||||
const metadata = () => {
|
||||
if (Number.isFinite(media.duration) && media.duration > 0) setDuration(media.duration);
|
||||
};
|
||||
const time = () => setCurrentTime(media.currentTime || 0);
|
||||
const playing = () => setIsPlaying(!media.paused);
|
||||
metadata();
|
||||
media.addEventListener('loadedmetadata', metadata);
|
||||
media.addEventListener('durationchange', metadata);
|
||||
media.addEventListener('timeupdate', time);
|
||||
media.addEventListener('play', playing);
|
||||
media.addEventListener('pause', playing);
|
||||
return () => {
|
||||
media.removeEventListener('loadedmetadata', metadata);
|
||||
media.removeEventListener('durationchange', metadata);
|
||||
media.removeEventListener('timeupdate', time);
|
||||
media.removeEventListener('play', playing);
|
||||
media.removeEventListener('pause', playing);
|
||||
setDuration(0);
|
||||
};
|
||||
}, [audioSrc, videoSrc, playbackFallbackSrc]);
|
||||
|
||||
// ── Alignment metrics — the single {pxPerSec, scrollLeft} source ────────────
|
||||
// Everything the SegmentTrack draws derives from these two numbers, read
|
||||
// off WaveSurfer's wrapper after every zoom/scroll/redraw/resize. rAF-
|
||||
@@ -599,11 +621,13 @@ function WaveformTimeline(
|
||||
/* ignore */
|
||||
}
|
||||
const scrollEl = wrap?.parentElement || null;
|
||||
const unbindPan = wrap ? bindWaveformPan(wrap) : () => {};
|
||||
if (scrollEl) scrollEl.addEventListener('scroll', syncMetrics, { passive: true });
|
||||
const ro = scrollEl ? new ResizeObserver(syncMetrics) : null;
|
||||
if (ro && scrollEl) ro.observe(scrollEl);
|
||||
syncMetrics();
|
||||
return () => {
|
||||
unbindPan();
|
||||
try {
|
||||
ws.un('redraw', syncMetrics);
|
||||
ws.un('zoom', syncMetrics);
|
||||
@@ -770,7 +794,7 @@ function WaveformTimeline(
|
||||
} catch {
|
||||
/* play() will surface real errors */
|
||||
}
|
||||
if (wsRef.current) {
|
||||
if (wsRef.current && ready) {
|
||||
try {
|
||||
await wsRef.current.playPause();
|
||||
} catch (e) {
|
||||
@@ -785,14 +809,17 @@ function WaveformTimeline(
|
||||
el.pause();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
const seekTo = useCallback((t) => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.setTime(t);
|
||||
} else if (mediaElRef.current) {
|
||||
mediaElRef.current.currentTime = t;
|
||||
}
|
||||
}, []);
|
||||
}, [ready]);
|
||||
const seekTo = useCallback(
|
||||
(t) => {
|
||||
if (wsRef.current && ready) {
|
||||
wsRef.current.setTime(t);
|
||||
} else if (mediaElRef.current) {
|
||||
mediaElRef.current.currentTime = t;
|
||||
}
|
||||
},
|
||||
[ready],
|
||||
);
|
||||
|
||||
const fmt = (t) => {
|
||||
const m = Math.floor(t / 60);
|
||||
@@ -816,6 +843,67 @@ function WaveformTimeline(
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [currentTime, duration, togglePlay, seekTo]);
|
||||
|
||||
const playbackControls = (
|
||||
<div
|
||||
className={`wfm-controls ${videoSrc ? 'wfm-controls--overlay' : ''}`}
|
||||
role="toolbar"
|
||||
aria-label={t('waveform.controls')}
|
||||
>
|
||||
<div className="wfm-controls__group">
|
||||
<button
|
||||
className={WF_BTN}
|
||||
onClick={() => seekTo(0)}
|
||||
title={t('waveform.restart')}
|
||||
aria-label={t('waveform.restart')}
|
||||
>
|
||||
<SkipBack size={11} />
|
||||
</button>
|
||||
<button
|
||||
className={WF_BTN_PLAY}
|
||||
onClick={togglePlay}
|
||||
disabled={!ready && duration <= 0}
|
||||
aria-label={isPlaying ? t('player.pause') : t('player.play')}
|
||||
>
|
||||
{isPlaying ? <Pause size={11} /> : <Play size={11} />}
|
||||
</button>
|
||||
<span
|
||||
className="rounded-[3px] bg-[var(--chrome-hover-bg)] px-[5px] py-[1px] text-[0.62rem] text-[color:var(--chrome-fg-muted)] [border:1px_solid_var(--chrome-border)] [font-family:var(--font-mono)] [font-variant-numeric:tabular-nums]"
|
||||
aria-live="off"
|
||||
>
|
||||
{fmt(currentTime)} / {fmt(duration)}
|
||||
</span>
|
||||
<span className="wfm-kbd-hint" title={t('waveform.shortcuts')}>
|
||||
<Keyboard size={10} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="wfm-controls__group">
|
||||
<button
|
||||
className={WF_BTN}
|
||||
onClick={() => setZoom((z) => Math.max(10, z - 20))}
|
||||
aria-label={t('trimmer.zoom_out')}
|
||||
>
|
||||
<ZoomOut size={11} />
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="10"
|
||||
max="300"
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="!mt-0 !h-[2px] !w-[60px]"
|
||||
aria-label={t('waveform.zoom')}
|
||||
/>
|
||||
<button
|
||||
className={WF_BTN}
|
||||
onClick={() => setZoom((z) => Math.min(300, z + 20))}
|
||||
aria-label={t('trimmer.zoom_in')}
|
||||
>
|
||||
<ZoomIn size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Error fallback ──────────────────────────────────────────────────────────
|
||||
if (loadError) {
|
||||
return (
|
||||
@@ -829,117 +917,60 @@ function WaveformTimeline(
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-[6px] wfm-layout" role="region" aria-label="Audio waveform timeline">
|
||||
{/* Video + Waveform stacked vertically */}
|
||||
<div className="mb-[6px] wfm-layout" role="region" aria-label={t('waveform.timeline')}>
|
||||
{/* Video transport and one compact, aligned waveform/transcript surface. */}
|
||||
<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} className="wfm-video-preview" />}
|
||||
{/* Keep the imperative media host separate from React-owned controls. */}
|
||||
{videoSrc && (
|
||||
<div className="wfm-video-preview">
|
||||
<div ref={videoContainerRef} className="wfm-video-media" />
|
||||
{playbackControls}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Waveform — fills the rest. This is the actual editing surface. */}
|
||||
<div className="wfm-wave-wrap">
|
||||
<div
|
||||
ref={waveContainerRef}
|
||||
className="waveform-container wfm-wave-inner"
|
||||
onWheel={onWaveWheel}
|
||||
/>
|
||||
<div className="wfm-editor" onWheel={onWaveWheel}>
|
||||
<div className="wfm-wave-wrap">
|
||||
<div ref={waveContainerRef} className="waveform-container wfm-wave-inner" />
|
||||
|
||||
{/* Loading shimmer */}
|
||||
{!ready && !loadError && (
|
||||
<div className="wfm-loading">
|
||||
<Loader className="spinner" size={12} color="#d3869b" />
|
||||
<span className="wfm-loading__text">Loading waveform…</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Loading shimmer */}
|
||||
{!ready && !loadError && (
|
||||
<div className="wfm-loading">
|
||||
<Loader className="spinner" size={12} color="#d3869b" />
|
||||
<span className="wfm-loading__text">{t('waveform.loading')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay slot — transcription / dubbing progress */}
|
||||
{overlayContent && <div className="wfm-overlay">{overlayContent}</div>}
|
||||
</div>
|
||||
{/* Overlay slot — transcription / dubbing progress */}
|
||||
{overlayContent && <div className="wfm-overlay">{overlayContent}</div>}
|
||||
</div>
|
||||
|
||||
{/* Segment editing lane — pixel-aligned with the waveform via the
|
||||
{/* Segment editing lane — pixel-aligned with the waveform via the
|
||||
shared {pxPerSec, scrollLeft} metrics. In the WebKit fallback
|
||||
(no WaveSurfer) it self-scrolls with a fixed px/sec scale. */}
|
||||
{ready && segments.length > 0 && (
|
||||
<SegmentTrack
|
||||
segments={segments}
|
||||
pxPerSec={fallbackMode ? zoom : metrics.pxPerSec}
|
||||
scrollLeft={metrics.scrollLeft}
|
||||
duration={duration}
|
||||
currentTime={currentTime}
|
||||
onsets={onsets}
|
||||
disabled={disabled}
|
||||
selectedId={selectedSegId}
|
||||
onSelectSeg={onSelectSeg}
|
||||
incrementalPlan={incrementalPlan}
|
||||
onCommit={onSegmentCommit}
|
||||
onDelete={onSegmentDelete}
|
||||
onPlayRange={playRange}
|
||||
onPreviewSegment={onPreviewSegment}
|
||||
onEnsureVisible={ensureTimeVisible}
|
||||
selfScroll={fallbackMode}
|
||||
/>
|
||||
)}
|
||||
{ready && segments.length > 0 && (
|
||||
<SegmentTrack
|
||||
segments={segments}
|
||||
pxPerSec={fallbackMode ? zoom : metrics.pxPerSec}
|
||||
scrollLeft={metrics.scrollLeft}
|
||||
duration={duration}
|
||||
currentTime={currentTime}
|
||||
onsets={onsets}
|
||||
disabled={disabled}
|
||||
selectedId={selectedSegId}
|
||||
onSelectSeg={onSelectSeg}
|
||||
incrementalPlan={incrementalPlan}
|
||||
onCommit={onSegmentCommit}
|
||||
onDelete={onSegmentDelete}
|
||||
onPlayRange={playRange}
|
||||
onPreviewSegment={onPreviewSegment}
|
||||
onEnsureVisible={ensureTimeVisible}
|
||||
selfScroll={fallbackMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div
|
||||
className="flex items-center justify-between gap-[4px] wfm-controls"
|
||||
role="toolbar"
|
||||
aria-label="Playback controls"
|
||||
>
|
||||
<div className="flex items-center gap-[4px]">
|
||||
<button
|
||||
className={WF_BTN}
|
||||
onClick={() => seekTo(0)}
|
||||
title="Restart"
|
||||
aria-label="Restart playback"
|
||||
>
|
||||
<SkipBack size={11} />
|
||||
</button>
|
||||
<button
|
||||
className={WF_BTN_PLAY}
|
||||
onClick={togglePlay}
|
||||
disabled={!ready}
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? <Pause size={11} /> : <Play size={11} />}
|
||||
</button>
|
||||
<span
|
||||
className="rounded-[3px] bg-[var(--chrome-hover-bg)] px-[5px] py-[1px] text-[0.62rem] text-[color:var(--chrome-fg-muted)] [border:1px_solid_var(--chrome-border)] [font-family:var(--font-mono)] [font-variant-numeric:tabular-nums]"
|
||||
aria-live="off"
|
||||
>
|
||||
{fmt(currentTime)} / {fmt(duration)}
|
||||
</span>
|
||||
<span className="wfm-kbd-hint" title="J/K/L: rewind, play/pause, forward">
|
||||
<Keyboard size={10} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-[4px]">
|
||||
<button
|
||||
className={WF_BTN}
|
||||
onClick={() => setZoom((z) => Math.max(10, z - 20))}
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<ZoomOut size={11} />
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="10"
|
||||
max="300"
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="!mt-0 !h-[2px] !w-[60px]"
|
||||
aria-label="Zoom level"
|
||||
/>
|
||||
<button
|
||||
className={WF_BTN}
|
||||
onClick={() => setZoom((z) => Math.min(300, z + 20))}
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<ZoomIn size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{!videoSrc && playbackControls}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import { act, render, waitFor, screen, fireEvent } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const wave = vi.hoisted(() => ({ handlers: {}, instance: null }));
|
||||
@@ -75,6 +75,20 @@ describe('WaveformTimeline audible error recovery (#1692)', () => {
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('enables native playback as soon as metadata arrives, before waveform ready', async () => {
|
||||
const { container } = render(
|
||||
React.createElement(WaveformTimeline, { audioSrc: '/audio.wav', videoSrc: '/video.mp4' }),
|
||||
);
|
||||
const video = container.querySelector('video');
|
||||
expect(screen.getByRole('button', { name: 'Play' })).toBeDisabled();
|
||||
Object.defineProperty(video, 'duration', { configurable: true, value: 1980.4 });
|
||||
fireEvent.loadedMetadata(video);
|
||||
expect(screen.getByRole('button', { name: 'Play' })).toBeEnabled();
|
||||
expect(screen.getByText(/33:00.4/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Play' }));
|
||||
await waitFor(() => expect(video.play).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('loads unequal-duration peaks from the selected dub and synchronizes its video', async () => {
|
||||
let releaseFetch;
|
||||
fetch.mockImplementationOnce(
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('WaveformTimeline autoplay-unlock wiring (#595)', () => {
|
||||
// Each playback handler must resume the context first. Grab the body of
|
||||
// each handler and assert the unlock await precedes the play/playPause call.
|
||||
const handlers = {
|
||||
togglePlay: /const togglePlay = useCallback\(async \(\) => \{([\s\S]*?)\}, \[\]\);/.exec(
|
||||
togglePlay: /const togglePlay = useCallback\(async \(\) => \{([\s\S]*?)\}, \[ready\]\);/.exec(
|
||||
src,
|
||||
)?.[1],
|
||||
playRange:
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useRef } from 'react';
|
||||
import { BookMarked, BookOpen, FileUp, ListTree, Loader, Sparkles, Square } from 'lucide-react';
|
||||
|
||||
import { Button } from '../../ui';
|
||||
import EngineQuickSwitch from '../EngineQuickSwitch';
|
||||
|
||||
/** The inviting front door for the long-form workflow: context, path, and actions. */
|
||||
export default function AudiobookHero({
|
||||
@@ -33,7 +32,6 @@ export default function AudiobookHero({
|
||||
<h2 className="m-0 [font-family:var(--font-serif)] text-[var(--text-lg)] font-semibold text-fg">
|
||||
{t('audiobook.title')}
|
||||
</h2>
|
||||
<EngineQuickSwitch />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-[4px]">
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
import {
|
||||
Globe,
|
||||
SlidersHorizontal,
|
||||
Settings2,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Play,
|
||||
Square,
|
||||
Focus,
|
||||
Gauge,
|
||||
Timer,
|
||||
Thermometer,
|
||||
Shuffle,
|
||||
Layers,
|
||||
Clock,
|
||||
AudioLines,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { Button, Progress } from '../../ui';
|
||||
import SearchableSelect from '../SearchableSelect';
|
||||
import MultiLangPicker from '../MultiLangPicker';
|
||||
import ALL_LANGUAGES from '../../languages.json';
|
||||
import { POPULAR_LANGS } from '../../utils/constants';
|
||||
import { LANG_CODES } from '../../utils/languages';
|
||||
import { stopActivePlayback } from '../../utils/playback';
|
||||
|
||||
const CLONE_LANGUAGES = ALL_LANGUAGES.map((label) => ({
|
||||
label,
|
||||
code: LANG_CODES.find((language) => language.label === label)?.code || label,
|
||||
}));
|
||||
|
||||
export default function ActionBar({
|
||||
t,
|
||||
showOverrides,
|
||||
@@ -55,153 +68,144 @@ export default function ActionBar({
|
||||
<div className="studio-action-bar overflow-visible relative z-[10]">
|
||||
{showOverrides && (
|
||||
<div className="override-content">
|
||||
<div className="grid [grid-template-columns:repeat(auto-fit,minmax(120px,1fr))] gap-[6px] max-[500px]:grid-cols-2">
|
||||
<label className="min-w-0">
|
||||
<span className="label-row justify-between">
|
||||
<span className="inline-flex items-center gap-[6px]">
|
||||
<SlidersHorizontal size={12} className="label-icon" />
|
||||
{t('clone.steps')}
|
||||
</span>
|
||||
<span className="text-[0.65rem] bg-black/35 px-[5px] py-px rounded-[3px] [border:1px_solid_rgba(255,255,255,0.04)] [font-variant-numeric:tabular-nums]">
|
||||
{steps}
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,180px),1fr))] gap-3">
|
||||
{[
|
||||
{
|
||||
label: t('clone.steps'),
|
||||
Icon: SlidersHorizontal,
|
||||
value: steps,
|
||||
set: setSteps,
|
||||
min: 8,
|
||||
max: 64,
|
||||
step: 1,
|
||||
},
|
||||
{
|
||||
label: t('clone.cfg'),
|
||||
Icon: Focus,
|
||||
value: cfg,
|
||||
set: setCfg,
|
||||
min: 1,
|
||||
max: 4,
|
||||
step: 0.1,
|
||||
},
|
||||
{
|
||||
label: t('clone.speed'),
|
||||
Icon: Gauge,
|
||||
value: speed,
|
||||
set: setSpeed,
|
||||
min: 0.5,
|
||||
max: 2,
|
||||
step: 0.1,
|
||||
suffix: '×',
|
||||
},
|
||||
{
|
||||
label: t('clone.tshift'),
|
||||
Icon: Timer,
|
||||
value: tShift,
|
||||
set: setTShift,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
},
|
||||
{
|
||||
label: t('clone.pos_temp'),
|
||||
Icon: Thermometer,
|
||||
value: posTemp,
|
||||
set: setPosTemp,
|
||||
min: 0,
|
||||
max: 10,
|
||||
step: 0.5,
|
||||
},
|
||||
{
|
||||
label: t('clone.class_temp'),
|
||||
Icon: Shuffle,
|
||||
value: classTemp,
|
||||
set: setClassTemp,
|
||||
min: 0,
|
||||
max: 2,
|
||||
step: 0.1,
|
||||
},
|
||||
{
|
||||
label: t('clone.layer_pen'),
|
||||
Icon: Layers,
|
||||
value: layerPenalty,
|
||||
set: setLayerPenalty,
|
||||
min: 0,
|
||||
max: 10,
|
||||
step: 0.5,
|
||||
},
|
||||
].map(({ label, Icon, value, set, min, max, step, suffix = '' }) => (
|
||||
<label key={label} className="min-w-0 rounded-lg bg-[var(--chrome-hover-bg)] p-3">
|
||||
<span className="flex items-center justify-between gap-2 mb-3 text-sm text-[var(--chrome-fg)]">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Icon size={15} aria-hidden="true" className="text-[var(--chrome-fg-muted)]" />
|
||||
{label}
|
||||
</span>
|
||||
<output className="tabular-nums text-[var(--chrome-accent)] font-medium">
|
||||
{value}
|
||||
{suffix}
|
||||
</output>
|
||||
</span>
|
||||
<input
|
||||
className="w-full"
|
||||
type="range"
|
||||
aria-label={label}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(event) => set(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<label className="min-w-0 rounded-lg bg-[var(--chrome-hover-bg)] p-3">
|
||||
<span className="flex items-center gap-2 mb-2 text-sm text-[var(--chrome-fg)]">
|
||||
<Clock size={15} aria-hidden="true" />
|
||||
{t('clone.duration')}
|
||||
</span>
|
||||
<input
|
||||
className="w-full"
|
||||
type="range"
|
||||
aria-label={t('clone.steps')}
|
||||
min="8"
|
||||
max="64"
|
||||
value={steps}
|
||||
onChange={(event) => setSteps(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<div className="label-row justify-between">
|
||||
<span>CFG</span>
|
||||
<span className="text-[0.65rem] bg-black/35 px-[5px] py-px rounded-[3px] [border:1px_solid_rgba(255,255,255,0.04)] [font-variant-numeric:tabular-nums]">
|
||||
{cfg}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="1.0"
|
||||
max="4.0"
|
||||
step="0.1"
|
||||
value={cfg}
|
||||
onChange={(e) => setCfg(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="label-row justify-between">
|
||||
<span>{t('clone.speed')}</span>
|
||||
<span className="text-[0.65rem] bg-black/35 px-[5px] py-px rounded-[3px] [border:1px_solid_rgba(255,255,255,0.04)] [font-variant-numeric:tabular-nums]">
|
||||
{speed}x
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="2.0"
|
||||
step="0.1"
|
||||
value={speed}
|
||||
onChange={(e) => setSpeed(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="label-row justify-between">
|
||||
<span>{t('clone.tshift')}</span>
|
||||
<span className="text-[0.65rem] bg-black/35 px-[5px] py-px rounded-[3px] [border:1px_solid_rgba(255,255,255,0.04)] [font-variant-numeric:tabular-nums]">
|
||||
{tShift}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1.0"
|
||||
step="0.05"
|
||||
value={tShift}
|
||||
onChange={(e) => setTShift(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="label-row justify-between">
|
||||
<span>{t('clone.pos_temp')}</span>
|
||||
<span className="text-[0.65rem] bg-black/35 px-[5px] py-px rounded-[3px] [border:1px_solid_rgba(255,255,255,0.04)] [font-variant-numeric:tabular-nums]">
|
||||
{posTemp}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.5"
|
||||
value={posTemp}
|
||||
onChange={(e) => setPosTemp(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="label-row justify-between">
|
||||
<span>{t('clone.class_temp')}</span>
|
||||
<span className="text-[0.65rem] bg-black/35 px-[5px] py-px rounded-[3px] [border:1px_solid_rgba(255,255,255,0.04)] [font-variant-numeric:tabular-nums]">
|
||||
{classTemp}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={classTemp}
|
||||
onChange={(e) => setClassTemp(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="label-row justify-between">
|
||||
<span>{t('clone.layer_pen')}</span>
|
||||
<span className="text-[0.65rem] bg-black/35 px-[5px] py-px rounded-[3px] [border:1px_solid_rgba(255,255,255,0.04)] [font-variant-numeric:tabular-nums]">
|
||||
{layerPenalty}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.5"
|
||||
value={layerPenalty}
|
||||
onChange={(e) => setLayerPenalty(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="label-row">
|
||||
<span>{t('clone.duration')}</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="input-base text-[0.8rem]"
|
||||
aria-label={t('clone.duration')}
|
||||
className="input-base text-sm"
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(e.target.value)}
|
||||
onChange={(event) => setDuration(event.target.value)}
|
||||
placeholder={t('clone.auto')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-[6px]">
|
||||
<label className="text-[0.75rem] flex items-center gap-[6px] cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={denoise}
|
||||
onChange={(e) => setDenoise(e.target.checked)}
|
||||
/>{' '}
|
||||
{t('clone.denoise')}
|
||||
</label>
|
||||
<label className="text-[0.75rem] flex items-center gap-[6px] cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={postprocess}
|
||||
onChange={(e) => setPostprocess(e.target.checked)}
|
||||
/>{' '}
|
||||
{t('clone.postprocess')}
|
||||
</label>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,200px),1fr))] gap-3 mt-3">
|
||||
{[
|
||||
{ label: t('clone.denoise'), Icon: AudioLines, checked: denoise, set: setDenoise },
|
||||
{
|
||||
label: t('clone.postprocess'),
|
||||
Icon: Sparkles,
|
||||
checked: postprocess,
|
||||
set: setPostprocess,
|
||||
},
|
||||
].map(({ label, Icon, checked, set }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
onClick={() => set(!checked)}
|
||||
className="flex min-h-12 items-center justify-between gap-3 rounded-lg border-0 bg-[var(--chrome-hover-bg)] px-3 py-2 text-sm text-[var(--chrome-fg)] cursor-pointer hover:bg-[var(--chrome-accent-bg)] focus-visible:outline-2 focus-visible:outline-[var(--chrome-accent)]"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Icon size={16} aria-hidden="true" />
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`relative w-9 h-5 rounded-full transition-colors ${checked ? 'bg-[var(--chrome-accent)]' : 'bg-[var(--chrome-fg-dim)]'}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 size-4 rounded-full bg-[var(--color-bg)] transition-transform motion-reduce:transition-none ${checked ? 'translate-x-4' : ''}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -209,13 +213,17 @@ export default function ActionBar({
|
||||
{/* Keep the everyday row focused; sampling controls live in overrides. */}
|
||||
<div className="flex items-center gap-3 min-w-0 max-[520px]:flex-wrap">
|
||||
<div className="flex items-center gap-[6px] flex-[1_1_220px] min-w-[140px] [&>:last-child]:flex-1 [&>:last-child]:min-w-0">
|
||||
<Globe size={12} className="label-icon" />
|
||||
<SearchableSelect
|
||||
value={language}
|
||||
options={ALL_LANGUAGES}
|
||||
popular={POPULAR_LANGS}
|
||||
recentsKey="omnivoice.recents.genLang"
|
||||
onChange={setLanguage}
|
||||
<MultiLangPicker
|
||||
single
|
||||
ariaLabel={t('clone.language')}
|
||||
selected={[
|
||||
{
|
||||
lang: language,
|
||||
code: CLONE_LANGUAGES.find((item) => item.label === language)?.code || language,
|
||||
},
|
||||
]}
|
||||
options={CLONE_LANGUAGES}
|
||||
onChange={([item]) => setLanguage(item.lang)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../SearchableSelect', () => ({
|
||||
default: () => <button type="button">Language</button>,
|
||||
}));
|
||||
import '../../i18n';
|
||||
|
||||
import ActionBar from './ActionBar';
|
||||
|
||||
@@ -49,6 +46,50 @@ function Harness() {
|
||||
}
|
||||
|
||||
describe('ActionBar', () => {
|
||||
it('labels every tuning slider and exposes audio cleanup as switches', () => {
|
||||
const setDenoise = vi.fn();
|
||||
const setPostprocess = vi.fn();
|
||||
render(
|
||||
<ActionBar
|
||||
{...baseProps}
|
||||
showOverrides
|
||||
setShowOverrides={setter}
|
||||
setDenoise={setDenoise}
|
||||
setPostprocess={setPostprocess}
|
||||
/>,
|
||||
);
|
||||
for (const slider of screen.getAllByRole('slider')) expect(slider).toHaveAccessibleName();
|
||||
const denoise = screen.getByRole('switch', { name: 'clone.denoise' });
|
||||
expect(denoise).toHaveAttribute('aria-checked', 'false');
|
||||
fireEvent.click(denoise);
|
||||
expect(setDenoise).toHaveBeenCalledWith(true);
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'clone.postprocess' }));
|
||||
expect(setPostprocess).toHaveBeenCalledWith(true);
|
||||
});
|
||||
it('opens the language list above the bottom bar outside its clipping ancestors', () => {
|
||||
const { container } = render(<Harness />);
|
||||
const wrapper = screen.getByRole('button', { name: 'clone.language' });
|
||||
vi.spyOn(wrapper, 'getBoundingClientRect').mockReturnValue({
|
||||
top: 650,
|
||||
bottom: 680,
|
||||
left: 20,
|
||||
right: 320,
|
||||
width: 300,
|
||||
height: 30,
|
||||
});
|
||||
fireEvent.click(wrapper);
|
||||
const list = screen.getByRole('dialog');
|
||||
expect(container).not.toContainElement(list);
|
||||
expect(list).toHaveClass('multi-lang__drop');
|
||||
expect(list.style.bottom).not.toBe('');
|
||||
expect(screen.getAllByTestId('language-flag-es').length).toBeGreaterThan(0);
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Zulu' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Zulu/ }));
|
||||
expect(setter).toHaveBeenCalledWith('Zulu');
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(wrapper).toHaveFocus();
|
||||
});
|
||||
|
||||
it('keeps sampling steps inside Production Overrides', () => {
|
||||
render(<Harness />);
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState, useSyncExternalStore } from 'react';
|
||||
import { ChevronDown, Save, UploadCloud, X } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Button, Input, Segmented, Select } from '../../ui';
|
||||
import { Button, Input, Segmented } from '../../ui';
|
||||
import VoiceSelect from './VoiceSelect';
|
||||
import MicButton from './MicButton';
|
||||
import WaveformPlayer from '../WaveformPlayer';
|
||||
import VoiceModeIcon from './VoiceModeIcon';
|
||||
|
||||
const EMPTY_LEVEL_STORE = {
|
||||
getSnapshot: () => 0,
|
||||
@@ -57,7 +59,7 @@ export default function AudioMethodPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-1 flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-3 max-[520px]:flex-col">
|
||||
<div>
|
||||
<div className="text-[length:var(--text-sm)] font-semibold text-fg">
|
||||
@@ -75,15 +77,31 @@ export default function AudioMethodPanel({
|
||||
disabled={isStartingRecording || isRecording}
|
||||
aria-label={t('clone.reference_audio')}
|
||||
items={[
|
||||
{ value: 'upload', label: t('clone.upload_audio') },
|
||||
{ value: 'record', label: t('clone.record') },
|
||||
{
|
||||
value: 'upload',
|
||||
label: (
|
||||
<span className="voice-source-tab inline-flex items-center gap-2">
|
||||
<VoiceModeIcon mode="upload" />
|
||||
{t('clone.upload_audio')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'record',
|
||||
label: (
|
||||
<span className="voice-source-tab inline-flex items-center gap-2">
|
||||
<VoiceModeIcon mode="record" />
|
||||
{t('clone.record')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasReference && sourceMode === 'upload' && (
|
||||
<div>
|
||||
<div className="flex flex-1 flex-col min-h-32">
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*,.mp3,.wav,.m4a,.flac,.ogg"
|
||||
@@ -96,7 +114,7 @@ export default function AudioMethodPanel({
|
||||
/>
|
||||
<label
|
||||
htmlFor="audio-upload"
|
||||
className="flex min-h-20 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg bg-[var(--chrome-hover-bg)] px-4 py-3 text-center transition-[background] duration-[var(--dur-fast)] hover:bg-[var(--chrome-accent-bg)] focus-within:bg-[var(--chrome-accent-bg)] [&.is-dragging]:bg-[var(--chrome-accent-bg)]"
|
||||
className="flex flex-1 min-h-32 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg bg-[var(--chrome-hover-bg)] px-4 py-3 text-center transition-[background] duration-[var(--dur-fast)] hover:bg-[var(--chrome-accent-bg)] focus-within:bg-[var(--chrome-accent-bg)] [&.is-dragging]:bg-[var(--chrome-accent-bg)]"
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
event.currentTarget.classList.add('is-dragging');
|
||||
@@ -117,54 +135,57 @@ export default function AudioMethodPanel({
|
||||
)}
|
||||
|
||||
{!hasReference && sourceMode === 'record' && (
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)] items-stretch gap-3 max-[520px]:grid-cols-1">
|
||||
<MicButton
|
||||
isCleaning={isCleaning}
|
||||
isStarting={isStartingRecording}
|
||||
isRecording={isRecording}
|
||||
recordingTime={recordingTime}
|
||||
onStart={startRecording}
|
||||
onStop={stopRecording}
|
||||
/>
|
||||
<div className="grid grid-cols-[minmax(0,2fr)_minmax(0,1fr)] gap-2 max-[520px]:grid-cols-1">
|
||||
<div className="flex flex-1 flex-col gap-5 rounded-lg bg-[var(--chrome-hover-bg)] p-4">
|
||||
<div className="flex flex-1 min-h-28 items-center justify-center [&>button]:min-h-24 [&>button]:min-w-32">
|
||||
<MicButton
|
||||
isCleaning={isCleaning}
|
||||
isStarting={isStartingRecording}
|
||||
isRecording={isRecording}
|
||||
recordingTime={recordingTime}
|
||||
onStart={startRecording}
|
||||
onStop={stopRecording}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-[minmax(0,2fr)_minmax(0,1fr)] gap-4 max-[520px]:grid-cols-1">
|
||||
<label className="min-w-0 text-[length:var(--text-xs)] text-fg-muted">
|
||||
<span className="mb-1 block">{t('recording.input_device')}</span>
|
||||
<Select
|
||||
size="sm"
|
||||
className="w-full"
|
||||
<VoiceSelect
|
||||
label={t('recording.input_device')}
|
||||
value={selectedAudioInputId}
|
||||
onChange={(event) => {
|
||||
onChange={(value) => {
|
||||
if (!isStartingRecording && !isRecording && !isCleaning) {
|
||||
setSelectedAudioInputId?.(event.target.value);
|
||||
setSelectedAudioInputId?.(value);
|
||||
}
|
||||
}}
|
||||
disabled={isStartingRecording || isRecording || isCleaning}
|
||||
>
|
||||
<option value="">{t('recording.default_input')}</option>
|
||||
{audioInputs.map((device, index) => (
|
||||
<option key={device.deviceId || `input-${index}`} value={device.deviceId}>
|
||||
{device.label || t('recording.microphone_number', { number: index + 1 })}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
options={[
|
||||
{ value: '', label: t('recording.default_input') },
|
||||
...audioInputs
|
||||
.filter((device) => device.deviceId)
|
||||
.map((device, index) => ({
|
||||
value: device.deviceId,
|
||||
label:
|
||||
device.label || t('recording.microphone_number', { number: index + 1 }),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
<label className="min-w-0 text-[length:var(--text-xs)] text-fg-muted">
|
||||
<span className="mb-1 block">{t('recording.channels')}</span>
|
||||
<Select
|
||||
size="sm"
|
||||
className="w-full"
|
||||
<VoiceSelect
|
||||
label={t('recording.channels')}
|
||||
value={channelMode}
|
||||
onChange={(event) => {
|
||||
onChange={(value) => {
|
||||
if (!isStartingRecording && !isRecording && !isCleaning) {
|
||||
setChannelMode?.(event.target.value);
|
||||
setChannelMode?.(value);
|
||||
}
|
||||
}}
|
||||
disabled={isStartingRecording || isRecording || isCleaning}
|
||||
>
|
||||
<option value="auto">{t('recording.channels_auto')}</option>
|
||||
<option value="mono">{t('recording.channels_mono')}</option>
|
||||
<option value="stereo">{t('recording.channels_stereo')}</option>
|
||||
</Select>
|
||||
options={['auto', 'mono', 'stereo'].map((value) => ({
|
||||
value,
|
||||
label: t(`recording.channels_${value}`),
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{isRecording && (
|
||||
|
||||
@@ -118,13 +118,13 @@ describe('AudioMethodPanel', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'clone.record' }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText('recording.input_device'), {
|
||||
target: { value: 'built-in' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('recording.channels'), { target: { value: 'mono' } });
|
||||
fireEvent.keyDown(screen.getByLabelText('recording.input_device'), { key: 'Enter' });
|
||||
expect(screen.getByRole('option', { name: 'recording.microphone_number' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('option', { name: 'Built-in microphone' }));
|
||||
fireEvent.keyDown(screen.getByLabelText('recording.channels'), { key: 'Enter' });
|
||||
fireEvent.click(screen.getByRole('option', { name: 'recording.channels_mono' }));
|
||||
expect(setDevice).toHaveBeenCalledWith('built-in');
|
||||
expect(setChannels).toHaveBeenCalledWith('mono');
|
||||
expect(screen.getByRole('option', { name: 'recording.microphone_number' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('locks recording settings while microphone startup is pending', () => {
|
||||
@@ -143,8 +143,8 @@ describe('AudioMethodPanel', () => {
|
||||
const channels = screen.getByLabelText('recording.channels');
|
||||
expect(device).toBeDisabled();
|
||||
expect(channels).toBeDisabled();
|
||||
fireEvent.change(device, { target: { value: 'built-in' } });
|
||||
fireEvent.change(channels, { target: { value: 'mono' } });
|
||||
fireEvent.click(device);
|
||||
fireEvent.click(channels);
|
||||
expect(setDevice).not.toHaveBeenCalled();
|
||||
expect(setChannels).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { UploadCloud, X, ArrowRightLeft, Loader } from 'lucide-react';
|
||||
import {
|
||||
UploadCloud,
|
||||
X,
|
||||
ArrowRightLeft,
|
||||
Loader,
|
||||
AudioLines,
|
||||
Fingerprint,
|
||||
Timer,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Button } from '../../ui';
|
||||
import { API } from '../../api/client';
|
||||
@@ -50,6 +59,12 @@ export default function ConvertMethodPanel({ t, profiles = [], onRecordingBusyCh
|
||||
|
||||
const ingestSource = (file) => {
|
||||
if (!file) return;
|
||||
if (
|
||||
!(file.type.startsWith('audio/') || /\.(mp3|wav|m4a|flac|ogg|aac|webm)$/i.test(file.name))
|
||||
) {
|
||||
toast.error(t('clone.unsupported_audio'));
|
||||
return;
|
||||
}
|
||||
invalidateInFlight();
|
||||
// Re-wrap the picked/dropped File with a metacharacter-free name before it
|
||||
// enters state (CodeQL js/xss-through-dom): a file's NAME is DOM-derived
|
||||
@@ -80,9 +95,11 @@ export default function ConvertMethodPanel({ t, profiles = [], onRecordingBusyCh
|
||||
} = useRecording(async (file) => ingestSource(file));
|
||||
|
||||
useEffect(() => {
|
||||
onRecordingBusyChange?.(Boolean(isStartingRecording || isRecording));
|
||||
onRecordingBusyChange?.(
|
||||
Boolean(isStartingRecording || isRecording || isCleaning || isConverting),
|
||||
);
|
||||
return () => onRecordingBusyChange?.(false);
|
||||
}, [isStartingRecording, isRecording, onRecordingBusyChange]);
|
||||
}, [isStartingRecording, isRecording, isCleaning, isConverting, onRecordingBusyChange]);
|
||||
|
||||
const canConvert = !!sourceFile && !!voiceId && !isConverting;
|
||||
|
||||
@@ -140,140 +157,173 @@ export default function ConvertMethodPanel({ t, profiles = [], onRecordingBusyCh
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="convert-method-panel">
|
||||
{/* ── Source clip: drop / pick / record ── */}
|
||||
<div className="label-row mt-[6px]">{t('convert.source_kicker')}</div>
|
||||
<div className="flex gap-[8px] items-stretch">
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*,.mp3,.wav,.m4a,.flac,.ogg"
|
||||
onChange={(e) => {
|
||||
ingestSource(e.target.files[0]);
|
||||
e.target.value = '';
|
||||
}}
|
||||
className="dub-hidden-file"
|
||||
id="convert-audio-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="convert-audio-upload"
|
||||
className="flex-1 [border:1px_dashed_var(--chrome-border-strong)] rounded-[var(--chrome-radius-pill)] p-[6px] text-center cursor-pointer flex flex-col items-center gap-[4px] bg-transparent [transition:border-color_var(--dur-fast),background_var(--dur-fast)] hover:[border-color:var(--chrome-accent)] hover:bg-[var(--chrome-accent-bg)] [&.is-dragging]:[border-color:var(--chrome-accent)] [&.is-dragging]:bg-[var(--chrome-accent-bg)]"
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.add('is-dragging');
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.currentTarget.classList.remove('is-dragging');
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.remove('is-dragging');
|
||||
const file = e.dataTransfer.files[0];
|
||||
const okType =
|
||||
file &&
|
||||
(file.type.startsWith('audio/') ||
|
||||
/\.(mp3|wav|m4a|flac|ogg|aac|webm)$/i.test(file.name));
|
||||
if (okType) ingestSource(file);
|
||||
}}
|
||||
>
|
||||
<UploadCloud color="#a89984" size={18} />
|
||||
<p className="m-0 text-[0.72rem] text-[color:var(--chrome-fg-muted)] font-[family-name:var(--font-sans)] font-medium">
|
||||
{sourceFile ? (
|
||||
<span className="text-fg">{sourceFile.name}</span>
|
||||
) : (
|
||||
t('convert.drop_audio')
|
||||
)}
|
||||
</p>
|
||||
</label>
|
||||
<MicButton
|
||||
isCleaning={isCleaning}
|
||||
isStarting={isStartingRecording}
|
||||
isRecording={isRecording}
|
||||
recordingTime={recordingTime}
|
||||
onStart={startRecording}
|
||||
onStop={stopRecording}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{sourceFile && (
|
||||
<div className="mt-2 flex items-center gap-[8px]">
|
||||
<div className="flex-1 min-w-0">
|
||||
<WaveformPlayer src={sourceFile} source="convert-source" height={34} compact />
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
invalidateInFlight();
|
||||
setSourceFile(null);
|
||||
setResult(null);
|
||||
}}
|
||||
leading={<X size={11} />}
|
||||
>
|
||||
{t('clone.clear')}
|
||||
</Button>
|
||||
<div data-testid="convert-method-panel" className="flex flex-1 min-h-0 flex-col">
|
||||
<div
|
||||
data-testid="convert-form"
|
||||
className="convert-form flex-1 min-h-0 overflow-y-auto px-3 py-2 space-y-5"
|
||||
>
|
||||
{/* ── Source clip: drop / pick / record ── */}
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-[var(--chrome-fg)]">
|
||||
<AudioLines size={17} aria-hidden="true" />
|
||||
{t('convert.source_kicker')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Target voice ── */}
|
||||
<div className="label-row mt-[var(--space-4)]">{t('convert.target_voice')}</div>
|
||||
<VoiceSelector
|
||||
value={voiceId}
|
||||
onChange={selectVoice}
|
||||
profiles={profiles}
|
||||
engineDefault={false}
|
||||
gallery={false}
|
||||
placeholder={t('convert.pick_voice')}
|
||||
ariaLabel={t('convert.target_voice')}
|
||||
recentsKey="convert-target"
|
||||
/>
|
||||
|
||||
{/* ── Options + action ── */}
|
||||
<div className="mt-[var(--space-4)] flex flex-wrap items-center gap-[var(--space-4)]">
|
||||
<label
|
||||
className="inline-flex items-center gap-[6px] text-[0.85em] text-fg-muted cursor-pointer select-none whitespace-nowrap"
|
||||
title={t('convert.match_duration_hint')}
|
||||
>
|
||||
<div className="flex flex-wrap gap-3 items-stretch rounded-lg bg-[var(--chrome-hover-bg)] p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={matchDuration}
|
||||
type="file"
|
||||
accept="audio/*,.mp3,.wav,.m4a,.flac,.ogg"
|
||||
onChange={(e) => {
|
||||
invalidateInFlight();
|
||||
setMatchDuration(e.target.checked);
|
||||
setResult(null);
|
||||
ingestSource(e.target.files[0]);
|
||||
e.target.value = '';
|
||||
}}
|
||||
className="sr-only"
|
||||
id="convert-audio-upload"
|
||||
/>
|
||||
<span>{t('convert.match_duration')}</span>
|
||||
</label>
|
||||
<label
|
||||
htmlFor="convert-audio-upload"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
document.getElementById('convert-audio-upload')?.click();
|
||||
}
|
||||
}}
|
||||
className="flex-1 basis-56 min-w-0 min-h-48 rounded-lg p-5 text-center cursor-pointer flex flex-col justify-center items-center gap-3 bg-[var(--chrome-hover-bg)] transition-colors hover:bg-[var(--chrome-accent-bg)] focus-visible:outline-2 focus-visible:outline-[var(--chrome-accent)] [&.is-dragging]:bg-[var(--chrome-accent-bg)]"
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.add('is-dragging');
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.currentTarget.classList.remove('is-dragging');
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.remove('is-dragging');
|
||||
const file = e.dataTransfer.files[0];
|
||||
ingestSource(file);
|
||||
}}
|
||||
>
|
||||
<UploadCloud className="text-[var(--chrome-accent)]" size={28} aria-hidden="true" />
|
||||
<p className="m-0 text-sm text-[color:var(--chrome-fg-muted)] font-[family-name:var(--font-sans)] font-medium">
|
||||
{sourceFile ? (
|
||||
<span className="text-fg">{sourceFile.name}</span>
|
||||
) : (
|
||||
t('convert.drop_audio')
|
||||
)}
|
||||
</p>
|
||||
</label>
|
||||
<MicButton
|
||||
isCleaning={isCleaning}
|
||||
isStarting={isStartingRecording}
|
||||
isRecording={isRecording}
|
||||
recordingTime={recordingTime}
|
||||
onStart={startRecording}
|
||||
onStop={stopRecording}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{sourceFile && (
|
||||
<div className="mt-2 flex items-center gap-[8px]">
|
||||
<div className="flex-1 min-w-0">
|
||||
<WaveformPlayer src={sourceFile} source="convert-source" height={34} compact />
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
invalidateInFlight();
|
||||
setSourceFile(null);
|
||||
setResult(null);
|
||||
}}
|
||||
leading={<X size={11} />}
|
||||
>
|
||||
{t('clone.clear')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Target voice ── */}
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-[var(--chrome-fg)]">
|
||||
<Fingerprint size={17} aria-hidden="true" />
|
||||
{t('convert.target_voice')}
|
||||
</div>
|
||||
<div className="rounded-lg bg-[var(--chrome-hover-bg)] p-3">
|
||||
<VoiceSelector
|
||||
value={voiceId}
|
||||
onChange={selectVoice}
|
||||
profiles={profiles}
|
||||
engineDefault={false}
|
||||
gallery={false}
|
||||
placeholder={t('convert.pick_voice')}
|
||||
ariaLabel={t('convert.target_voice')}
|
||||
recentsKey="convert-target"
|
||||
menuPortal
|
||||
buttonClassName="min-h-12 px-3 text-sm border-0 rounded-lg bg-transparent text-[var(--chrome-fg)] hover:bg-[var(--chrome-accent-bg)] focus-visible:outline-2 focus-visible:outline-[var(--chrome-accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Options + action ── */}
|
||||
<div className="flex flex-col items-start gap-3 rounded-lg bg-[var(--chrome-hover-bg)] p-4">
|
||||
<label
|
||||
className="inline-flex items-center gap-[6px] text-[0.85em] text-fg-muted cursor-pointer select-none whitespace-nowrap"
|
||||
title={t('convert.match_duration_hint')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={matchDuration}
|
||||
onChange={(e) => {
|
||||
invalidateInFlight();
|
||||
setMatchDuration(e.target.checked);
|
||||
setResult(null);
|
||||
}}
|
||||
/>
|
||||
<Timer size={16} aria-hidden="true" />
|
||||
<span>{t('convert.match_duration')}</span>
|
||||
</label>
|
||||
<p className="m-0 text-xs text-[var(--chrome-fg-muted)]">
|
||||
{t('convert.match_duration_hint')}
|
||||
</p>
|
||||
{!sourceFile || !voiceId ? (
|
||||
<span className="inline-flex items-center gap-2 text-xs text-fg-muted">
|
||||
<Info size={14} aria-hidden="true" />
|
||||
{t('convert.need_source_and_voice')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* ── Result: converted take + what the ASR heard ── */}
|
||||
{result && (
|
||||
<div className="mt-[var(--space-4)]" data-testid="convert-result">
|
||||
<div className="label-row">{t('convert.result_kicker')}</div>
|
||||
<WaveformPlayer
|
||||
src={`${API}${result.audio_url}`}
|
||||
source="output"
|
||||
height={40}
|
||||
autoPlay
|
||||
/>
|
||||
<div className="mt-2 text-[0.78rem] text-fg-muted">
|
||||
<span className="font-medium">{t('convert.transcript')}:</span> {result.text}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="studio-action-bar" data-testid="convert-action-bar">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
block
|
||||
onClick={handleConvert}
|
||||
disabled={!canConvert}
|
||||
leading={
|
||||
isConverting ? (
|
||||
<Loader size={12} className="animate-[spin_1s_linear_infinite]" />
|
||||
<Loader size={14} className="animate-spin" />
|
||||
) : (
|
||||
<ArrowRightLeft size={12} />
|
||||
<ArrowRightLeft size={14} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{isConverting ? t('convert.converting') : t('convert.convert')}
|
||||
</Button>
|
||||
{!sourceFile || !voiceId ? (
|
||||
<span className="text-[0.72rem] text-fg-muted">{t('convert.need_source_and_voice')}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* ── Result: converted take + what the ASR heard ── */}
|
||||
{result && (
|
||||
<div className="mt-[var(--space-4)]" data-testid="convert-result">
|
||||
<div className="label-row">{t('convert.result_kicker')}</div>
|
||||
<WaveformPlayer src={`${API}${result.audio_url}`} source="output" height={40} autoPlay />
|
||||
<div className="mt-2 text-[0.78rem] text-fg-muted">
|
||||
<span className="font-medium">{t('convert.transcript')}:</span> {result.text}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ function addSourceClip() {
|
||||
|
||||
beforeEach(() => {
|
||||
recordingState.isStartingRecording = false;
|
||||
recordingState.isCleaning = false;
|
||||
convertSpeech.mockReset();
|
||||
toastAsrModelMissing.mockReset();
|
||||
toastModelNotDownloaded.mockReset();
|
||||
@@ -101,6 +102,13 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('ConvertMethodPanel', () => {
|
||||
it('keeps the primary action outside the scrolling form', () => {
|
||||
render(<ConvertMethodPanel t={t} profiles={profiles} />);
|
||||
const action = screen.getByTestId('convert-action-bar');
|
||||
expect(action).toHaveClass('studio-action-bar');
|
||||
expect(screen.getByTestId('convert-form')).not.toContainElement(action);
|
||||
expect(action).toContainElement(screen.getByRole('button', { name: 'convert.convert' }));
|
||||
});
|
||||
it('shows microphone startup instead of a duplicate record action', () => {
|
||||
recordingState.isStartingRecording = true;
|
||||
const onRecordingBusyChange = vi.fn();
|
||||
@@ -119,6 +127,39 @@ describe('ConvertMethodPanel', () => {
|
||||
expect(onRecordingBusyChange).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('keeps method switching busy through recording cleanup', () => {
|
||||
const onRecordingBusyChange = vi.fn();
|
||||
recordingState.isCleaning = true;
|
||||
const { rerender } = render(
|
||||
<ConvertMethodPanel
|
||||
t={t}
|
||||
profiles={profiles}
|
||||
onRecordingBusyChange={onRecordingBusyChange}
|
||||
/>,
|
||||
);
|
||||
expect(onRecordingBusyChange).toHaveBeenLastCalledWith(true);
|
||||
recordingState.isCleaning = false;
|
||||
rerender(
|
||||
<ConvertMethodPanel
|
||||
t={t}
|
||||
profiles={profiles}
|
||||
onRecordingBusyChange={onRecordingBusyChange}
|
||||
/>,
|
||||
);
|
||||
expect(onRecordingBusyChange).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
it.each(['picker', 'drop'])('reports unsupported audio from %s', (source) => {
|
||||
render(<ConvertMethodPanel t={t} profiles={profiles} />);
|
||||
const file = new File(['text'], 'notes.txt', { type: 'text/plain' });
|
||||
const input = document.getElementById('convert-audio-upload');
|
||||
if (source === 'picker') fireEvent.change(input, { target: { files: [file] } });
|
||||
else
|
||||
fireEvent.drop(document.querySelector('label[for="convert-audio-upload"]'), {
|
||||
dataTransfer: { files: [file] },
|
||||
});
|
||||
expect(toastError).toHaveBeenCalledWith('clone.unsupported_audio');
|
||||
expect(screen.queryByTestId('waveform-convert-source')).not.toBeInTheDocument();
|
||||
});
|
||||
it('keeps Convert disabled until a source clip AND a target voice are set', () => {
|
||||
render(<ConvertMethodPanel t={t} profiles={profiles} />);
|
||||
const button = screen.getByRole('button', { name: 'convert.convert' });
|
||||
@@ -133,6 +174,38 @@ describe('ConvertMethodPanel', () => {
|
||||
expect(screen.queryByText('convert.need_source_and_voice')).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['success', 'failure'])(
|
||||
'keeps method switching busy until conversion %s settles',
|
||||
async (outcome) => {
|
||||
let resolveConversion, rejectConversion;
|
||||
convertSpeech.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve, reject) => {
|
||||
resolveConversion = resolve;
|
||||
rejectConversion = reject;
|
||||
}),
|
||||
);
|
||||
const onRecordingBusyChange = vi.fn();
|
||||
render(
|
||||
<ConvertMethodPanel
|
||||
t={t}
|
||||
profiles={profiles}
|
||||
onRecordingBusyChange={onRecordingBusyChange}
|
||||
/>,
|
||||
);
|
||||
addSourceClip();
|
||||
fireEvent.change(screen.getByLabelText('voice-selector'), {
|
||||
target: { value: 'vp-1' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'convert.convert' }));
|
||||
await waitFor(() => expect(convertSpeech).toHaveBeenCalled());
|
||||
expect(onRecordingBusyChange).toHaveBeenLastCalledWith(true);
|
||||
if (outcome === 'success') resolveConversion({ id: 'take', audio_url: '/audio/take.wav' });
|
||||
else rejectConversion(new Error('Conversion failed'));
|
||||
await waitFor(() => expect(onRecordingBusyChange).toHaveBeenLastCalledWith(false));
|
||||
},
|
||||
);
|
||||
|
||||
it('previews the source clip with the shared waveform player', () => {
|
||||
render(<ConvertMethodPanel t={t} profiles={profiles} />);
|
||||
addSourceClip();
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronUp, ChevronDown, Save } from 'lucide-react';
|
||||
import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Save,
|
||||
Sparkles,
|
||||
Users,
|
||||
User,
|
||||
Baby,
|
||||
Clock,
|
||||
AudioLines,
|
||||
SlidersHorizontal,
|
||||
Languages,
|
||||
Wind,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ChevronsDown,
|
||||
ChevronsUp,
|
||||
Minus,
|
||||
} from 'lucide-react';
|
||||
import { Button, Input } from '../../ui';
|
||||
import { PRESETS, CATEGORIES } from '../../utils/constants';
|
||||
import {
|
||||
@@ -10,6 +28,7 @@ import {
|
||||
stripVoiceEmoji,
|
||||
} from '../../utils/voiceIcons';
|
||||
import { buildDesignInstruct } from '../../utils/voiceInstruct';
|
||||
import VoiceSelect from './VoiceSelect';
|
||||
|
||||
// Chip / personality-chip class families migrated from index.css to Tailwind
|
||||
// utilities (shadcn P4). The token utilities reference the same --chrome-* vars
|
||||
@@ -20,7 +39,7 @@ import { buildDesignInstruct } from '../../utils/voiceInstruct';
|
||||
// an opaque accent outline at 1px offset, on top of the app's global ring.
|
||||
const CHIP_FOCUS =
|
||||
'focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:1px]';
|
||||
const PCHIP_BASE = `inline-flex items-center gap-[5px] px-[12px] py-[5px] font-[var(--font-sans)] text-[0.72rem] font-medium rounded-[var(--chrome-radius-pill)] border bg-transparent flex-none cursor-pointer transition-colors duration-[120ms] ${CHIP_FOCUS}`;
|
||||
const PCHIP_BASE = `inline-flex min-h-11 items-center gap-[5px] px-[12px] py-[5px] font-[var(--font-sans)] text-sm font-medium rounded-[var(--chrome-radius-pill)] border bg-transparent flex-none cursor-pointer transition-colors duration-[120ms] ${CHIP_FOCUS}`;
|
||||
const PCHIP_INACTIVE =
|
||||
'border-transparent text-[var(--chrome-fg-muted)] hover:bg-[var(--chrome-hover-bg)] hover:border-transparent hover:text-[var(--chrome-fg)]';
|
||||
const PCHIP_ACTIVE =
|
||||
@@ -35,6 +54,32 @@ const CHIP_INACTIVE =
|
||||
// out as a 2x2 grid; kept as one array so the grid and its labels stay in
|
||||
// sync if a category is ever added/removed.
|
||||
const SELECT_CATEGORIES = ['Gender', 'Age', 'Pitch', 'Style'];
|
||||
const FIELD_ICONS = { Gender: Users, Age: Clock, Pitch: AudioLines, Style: SlidersHorizontal };
|
||||
function FieldIcon({ category }) {
|
||||
const Icon = FIELD_ICONS[category] || Languages;
|
||||
return (
|
||||
<Icon
|
||||
size={15}
|
||||
aria-hidden="true"
|
||||
className="inline-block mr-2 align-text-bottom text-[var(--chrome-fg-muted)]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const PITCH_ICONS = {
|
||||
'very low pitch': ChevronsDown,
|
||||
'low pitch': ArrowDown,
|
||||
'moderate pitch': Minus,
|
||||
'high pitch': ArrowUp,
|
||||
'very high pitch': ChevronsUp,
|
||||
};
|
||||
const designOptionIcon = (category, value) => {
|
||||
if (value === 'Auto') return Sparkles;
|
||||
if (category === 'Pitch') return PITCH_ICONS[value] || AudioLines;
|
||||
if (category === 'Gender') return User;
|
||||
if (category === 'Age') return value === 'child' ? Baby : User;
|
||||
if (category === 'Style') return Wind;
|
||||
return Languages;
|
||||
};
|
||||
// English accent and Chinese dialect are two independent CATEGORIES entries
|
||||
// (the engine's exclusivity rule lives in voiceInstruct.js's EXCLUSIVE_GROUPS)
|
||||
// but only one can ever apply, so the picker merges them into ONE <select>
|
||||
@@ -87,10 +132,10 @@ export default function DesignMethodPanel({
|
||||
// curated CATEGORIES list — guard before .replace() rather than crash;
|
||||
// 'Auto' matches how the rest of the component treats an unset category.
|
||||
const optLabel = (val) => {
|
||||
if (typeof val !== 'string' || !val) return t('clone.opt_Auto');
|
||||
if (typeof val !== 'string' || !val || val === 'Auto') return t('clone.auto');
|
||||
const tKey = `clone.opt_${val.replace(/[ -]/g, '_')}`;
|
||||
const tl = t(tKey);
|
||||
return tl !== tKey ? tl : val;
|
||||
return stripVoiceEmoji(tl !== tKey ? tl : val);
|
||||
};
|
||||
|
||||
const accentValue = vdStates.EnglishAccent;
|
||||
@@ -134,12 +179,13 @@ export default function DesignMethodPanel({
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-3">
|
||||
{/* ── Describe your voice (#317) — free text drives the controls.
|
||||
The placeholder explains itself; no extra header (10x §1.2). ── */}
|
||||
<div className="mb-[8px]">
|
||||
<textarea
|
||||
className="input-base w-full resize-y min-h-[44px] mb-1"
|
||||
className="input-base w-full resize-y min-h-24 mb-1"
|
||||
aria-label={t('clone.define_by_design')}
|
||||
rows={2}
|
||||
placeholder={t('clone.describe_placeholder')}
|
||||
value={describeText}
|
||||
@@ -155,7 +201,7 @@ export default function DesignMethodPanel({
|
||||
{t('clone.describe_unmatched', { items: describeUnmatched.join(', ') })}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[0.62rem] text-[var(--chrome-fg-muted)]">
|
||||
<div className="text-xs leading-relaxed text-[var(--chrome-fg-muted)]">
|
||||
{t('clone.describe_hint')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,7 +277,7 @@ export default function DesignMethodPanel({
|
||||
All-Auto (first run) starts expanded. */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-[8px] w-full mt-[4px] mb-[8px] px-[10px] py-[6px] bg-[var(--chrome-hover-bg)] border border-transparent rounded-[8px] cursor-pointer text-left transition-[border-color] duration-[var(--dur-fast)] hover:border-transparent focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:1px]"
|
||||
className="flex min-h-11 items-center gap-[8px] w-full mt-[4px] mb-[8px] px-[10px] py-[6px] bg-[var(--chrome-hover-bg)] border border-transparent rounded-[8px] cursor-pointer text-left transition-[border-color] duration-[var(--dur-fast)] hover:border-transparent focus-visible:[outline:2px_solid_var(--chrome-accent)] focus-visible:[outline-offset:1px]"
|
||||
onClick={() => setIdentityOpen((o) => !o)}
|
||||
aria-expanded={identityOpen}
|
||||
aria-controls="design-details-fields"
|
||||
@@ -249,57 +295,53 @@ export default function DesignMethodPanel({
|
||||
</button>
|
||||
{identityOpen && (
|
||||
<div id="design-details-fields">
|
||||
<div className="grid grid-cols-2 gap-x-[12px] gap-y-[8px]">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,220px),1fr))] gap-4">
|
||||
{SELECT_CATEGORIES.map((key) => (
|
||||
<div key={key} className="min-w-0">
|
||||
<label htmlFor={`vd-${key}`} className="label-row text-[0.7rem]">
|
||||
<label
|
||||
htmlFor={`vd-${key}`}
|
||||
className="block mb-2 text-sm font-medium text-[var(--chrome-fg)]"
|
||||
>
|
||||
<FieldIcon category={key} />
|
||||
{t(`clone.cat_${key}`)}
|
||||
</label>
|
||||
<select
|
||||
<VoiceSelect
|
||||
id={`vd-${key}`}
|
||||
className="input-base"
|
||||
label={t(`clone.cat_${key}`)}
|
||||
value={vdStates[key] || 'Auto'}
|
||||
onChange={(e) => onVdChange(key, e.target.value)}
|
||||
>
|
||||
{CATEGORIES[key].map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{optLabel(opt)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(value) => onVdChange(key, value)}
|
||||
options={CATEGORIES[key]}
|
||||
optionLabel={optLabel}
|
||||
optionIcon={(value) => designOptionIcon(key, value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="col-[1/-1] min-w-0">
|
||||
<label htmlFor="vd-AccentDialect" className="label-row text-[0.7rem]">
|
||||
<label
|
||||
htmlFor="vd-AccentDialect"
|
||||
className="block mb-2 text-sm font-medium text-[var(--chrome-fg)]"
|
||||
>
|
||||
<FieldIcon category="Accent" />
|
||||
{t('clone.cat_AccentDialect', { defaultValue: 'Accent or Dialect' })}
|
||||
<span className="ml-[6px] text-[0.58rem] text-[var(--chrome-fg-muted)] font-medium">
|
||||
<span className="block mt-1 text-xs text-[var(--chrome-fg-muted)] font-normal">
|
||||
{t('clone.accent_dialect_hint', {
|
||||
defaultValue: 'one or the other, never both',
|
||||
})}
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
<VoiceSelect
|
||||
id="vd-AccentDialect"
|
||||
className="input-base"
|
||||
label={t('clone.cat_AccentDialect', { defaultValue: 'Accent or Dialect' })}
|
||||
value={accentDialectValue}
|
||||
onChange={(e) => onAccentDialectChange(e.target.value)}
|
||||
>
|
||||
<option value="Auto">{optLabel('Auto')}</option>
|
||||
<optgroup label={t('clone.cat_EnglishAccent')}>
|
||||
{ACCENT_OPTIONS.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{optLabel(opt)}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label={t('clone.cat_ChineseDialect')}>
|
||||
{DIALECT_OPTIONS.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{optLabel(opt)}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
</select>
|
||||
onChange={onAccentDialectChange}
|
||||
options={['Auto']}
|
||||
optionLabel={optLabel}
|
||||
optionIcon={(value) => designOptionIcon('Accent', value)}
|
||||
groups={[
|
||||
{ label: t('clone.cat_EnglishAccent'), options: ACCENT_OPTIONS },
|
||||
{ label: t('clone.cat_ChineseDialect'), options: DIALECT_OPTIONS },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -127,7 +127,8 @@ describe('DesignMethodPanel — merged accent/dialect field', () => {
|
||||
const onVdChange = vi.fn();
|
||||
setup({ EnglishAccent: 'Auto', ChineseDialect: 'Auto' }, { identityOpen: true, onVdChange });
|
||||
const select = document.getElementById('vd-AccentDialect');
|
||||
fireEvent.change(select, { target: { value: 'british accent' } });
|
||||
fireEvent.keyDown(select, { key: 'Enter' });
|
||||
fireEvent.click(screen.getByRole('option', { name: 'british accent' }));
|
||||
expect(onVdChange).toHaveBeenCalledWith('EnglishAccent', 'british accent');
|
||||
});
|
||||
|
||||
@@ -135,7 +136,8 @@ describe('DesignMethodPanel — merged accent/dialect field', () => {
|
||||
const onVdChange = vi.fn();
|
||||
setup({ EnglishAccent: 'Auto', ChineseDialect: 'Auto' }, { identityOpen: true, onVdChange });
|
||||
const select = document.getElementById('vd-AccentDialect');
|
||||
fireEvent.change(select, { target: { value: '四川话' } });
|
||||
fireEvent.keyDown(select, { key: 'Enter' });
|
||||
fireEvent.click(screen.getByRole('option', { name: '四川话' }));
|
||||
expect(onVdChange).toHaveBeenCalledWith('ChineseDialect', '四川话');
|
||||
});
|
||||
|
||||
@@ -143,15 +145,27 @@ describe('DesignMethodPanel — merged accent/dialect field', () => {
|
||||
const onVdChange = vi.fn();
|
||||
setup({ EnglishAccent: 'Auto', ChineseDialect: '四川话' }, { identityOpen: true, onVdChange });
|
||||
const select = document.getElementById('vd-AccentDialect');
|
||||
fireEvent.change(select, { target: { value: 'Auto' } });
|
||||
fireEvent.keyDown(select, { key: 'Enter' });
|
||||
fireEvent.click(screen.getByRole('option', { name: 'clone.auto' }));
|
||||
expect(onVdChange).toHaveBeenCalledWith('ChineseDialect', 'Auto');
|
||||
expect(onVdChange).not.toHaveBeenCalledWith('EnglishAccent', expect.anything());
|
||||
});
|
||||
|
||||
it('keeps a restored uncurated dialect visible and selectable', () => {
|
||||
setup({ EnglishAccent: 'Auto', ChineseDialect: 'cosyvoice-speaker-601' });
|
||||
const select = document.getElementById('vd-AccentDialect');
|
||||
expect(select).toHaveTextContent('cosyvoice-speaker-601');
|
||||
fireEvent.keyDown(select, { key: 'Enter' });
|
||||
expect(screen.getByRole('option', { name: 'cosyvoice-speaker-601' })).toHaveAttribute(
|
||||
'data-state',
|
||||
'checked',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the currently-set dialect as the merged select value', () => {
|
||||
setup({ EnglishAccent: 'Auto', ChineseDialect: '四川话' }, { identityOpen: true });
|
||||
const select = document.getElementById('vd-AccentDialect');
|
||||
expect(select.value).toBe('四川话');
|
||||
expect(select).toHaveTextContent('四川话');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Command, Plus, ChevronDown } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { AlignLeft, ClipboardPaste, Plus, ChevronDown } from 'lucide-react';
|
||||
import DemoPresetGrid from '../DemoPresetGrid';
|
||||
import { TAGS } from '../../utils/constants';
|
||||
|
||||
@@ -32,6 +33,36 @@ export default function ScriptPanel({
|
||||
setInsertOpen,
|
||||
insertTag,
|
||||
}) {
|
||||
const [pasting, setPasting] = useState(false);
|
||||
const [pasteFailed, setPasteFailed] = useState(false);
|
||||
const pasteText = async () => {
|
||||
const field = textAreaRef.current;
|
||||
setPasting(true);
|
||||
setPasteFailed(false);
|
||||
try {
|
||||
const value = await navigator.clipboard.readText();
|
||||
if (value) {
|
||||
// The store setter accepts a string, unlike React's functional setter.
|
||||
// Read the current field after the async permission prompt so edits made
|
||||
// while clipboard access was pending are retained.
|
||||
const currentField = textAreaRef.current;
|
||||
const current = currentField?.value ?? text;
|
||||
const start = currentField?.selectionStart ?? current.length;
|
||||
const end = currentField?.selectionEnd ?? current.length;
|
||||
setText(current.slice(0, start) + value + current.slice(end));
|
||||
setShowDemoCoachmark(false);
|
||||
requestAnimationFrame(() => {
|
||||
field?.focus();
|
||||
field?.setSelectionRange(start + value.length, start + value.length);
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setPasteFailed(true);
|
||||
field?.focus();
|
||||
} finally {
|
||||
setPasting(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col gap-[6px] flex-none min-h-0 relative z-[2]">
|
||||
{/* overflow-visible: the ⊕ Insert popover opens BELOW the textarea and
|
||||
@@ -42,10 +73,26 @@ export default function ScriptPanel({
|
||||
screenshot showed the CMU chips clipped). Below always has room
|
||||
here: the panel is the topmost element in every mount. */}
|
||||
<div className={`${STUDIO_PANEL} relative z-[10] overflow-visible`}>
|
||||
<div className="label-row">
|
||||
<Command className="label-icon" size={14} />{' '}
|
||||
{t('clone.script', { defaultValue: 'Script' })}
|
||||
<div className="label-row justify-between">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<AlignLeft className="label-icon" size={14} />
|
||||
{t('clone.text_label')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pasting}
|
||||
onClick={pasteText}
|
||||
className="inline-flex min-h-9 items-center gap-2 rounded-md border-0 bg-transparent px-2 text-xs normal-case text-[var(--chrome-fg-muted)] cursor-pointer hover:bg-[var(--chrome-hover-bg)] disabled:opacity-50"
|
||||
>
|
||||
<ClipboardPaste size={14} />
|
||||
{t('clone.paste')}
|
||||
</button>
|
||||
</div>
|
||||
{pasteFailed && (
|
||||
<p role="alert" className="text-xs text-[var(--chrome-fg-muted)]">
|
||||
{t('clone.paste_failed')}
|
||||
</p>
|
||||
)}
|
||||
{/* Design-tab empty state: 7-card demo grid until the user
|
||||
interacts; then it steps aside for the standard form. */}
|
||||
{defineMethod === 'design' && !text && !activePersonality && demoPresets.length > 0 && (
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { useAppStore } from '../../store';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import ScriptPanel from './ScriptPanel';
|
||||
|
||||
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
|
||||
afterEach(() => {
|
||||
if (originalClipboard) Object.defineProperty(navigator, 'clipboard', originalClipboard);
|
||||
else delete navigator.clipboard;
|
||||
});
|
||||
function Harness() {
|
||||
const text = useAppStore((state) => state.text);
|
||||
const setText = useAppStore((state) => state.setText);
|
||||
const textAreaRef = useRef(null);
|
||||
return (
|
||||
<ScriptPanel
|
||||
t={(key) => key}
|
||||
defineMethod="audio"
|
||||
text={text}
|
||||
setText={setText}
|
||||
textAreaRef={textAreaRef}
|
||||
demoPresets={[]}
|
||||
setShowDemoCoachmark={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
describe('ScriptPanel paste', () => {
|
||||
it('pastes at the selection without replacing the rest of the text', async () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { readText: vi.fn().mockResolvedValue('friend') },
|
||||
});
|
||||
useAppStore.getState().setText('Hello world');
|
||||
render(<Harness />);
|
||||
expect(screen.getByText('clone.text_label')).toBeInTheDocument();
|
||||
const field = screen.getByRole('textbox');
|
||||
field.setSelectionRange(6, 11);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'clone.paste' }));
|
||||
await waitFor(() => expect(field).toHaveValue('Hello friend'));
|
||||
});
|
||||
it('uses the current selection when clipboard access resolves', async () => {
|
||||
let resolveClipboard;
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: {
|
||||
readText: () =>
|
||||
new Promise((resolve) => {
|
||||
resolveClipboard = resolve;
|
||||
}),
|
||||
},
|
||||
});
|
||||
useAppStore.getState().setText('Hello world');
|
||||
render(<Harness />);
|
||||
const field = screen.getByRole('textbox');
|
||||
field.setSelectionRange(6, 11);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'clone.paste' }));
|
||||
fireEvent.change(field, { target: { value: 'New greeting' } });
|
||||
field.setSelectionRange(0, 3);
|
||||
resolveClipboard('Fresh');
|
||||
await waitFor(() => expect(field).toHaveValue('Fresh greeting'));
|
||||
});
|
||||
it('preserves text and offers keyboard paste when clipboard access fails', async () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { readText: vi.fn().mockRejectedValue(new Error('denied')) },
|
||||
});
|
||||
useAppStore.getState().setText('Hello world');
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'clone.paste' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('clone.paste_failed');
|
||||
expect(screen.getByRole('textbox')).toHaveValue('Hello world');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
.voice-mode-icon {
|
||||
flex: none;
|
||||
transition: transform 140ms ease-out;
|
||||
}
|
||||
.voice-mode-tab:active:not(:disabled) .voice-mode-icon { transform: scale(0.94); }
|
||||
.voice-mode-tab:is(:hover, :focus-visible, [data-state="active"]):not(:disabled) .voice-mode-wave {
|
||||
transform-origin: center;
|
||||
transform-box: fill-box;
|
||||
animation: voice-mode-wave 360ms ease-in-out both;
|
||||
}
|
||||
.voice-mode-tab:is(:hover, :focus-visible, [data-state="active"]):not(:disabled) .voice-mode-dial {
|
||||
animation: voice-mode-nudge 380ms ease-in-out;
|
||||
--voice-mode-offset: 2px;
|
||||
}
|
||||
.voice-mode-tab:is(:hover, :focus-visible, [data-state="active"]):not(:disabled) .voice-mode-arrow {
|
||||
animation: voice-mode-nudge 380ms ease-in-out;
|
||||
--voice-mode-offset: 1.5px;
|
||||
}
|
||||
.voice-mode-tab .voice-mode-dial--second,
|
||||
.voice-mode-tab .voice-mode-arrow--back { --voice-mode-offset: -2px !important; }
|
||||
button:is(:hover, :focus-visible, [data-state="on"]):not(:disabled) .voice-mode-upload {
|
||||
animation: voice-mode-lift 380ms ease-out;
|
||||
}
|
||||
button:is(:hover, :focus-visible, [data-state="on"]):not(:disabled) .voice-mode-mic {
|
||||
animation: voice-mode-listen 400ms ease-out;
|
||||
}
|
||||
@keyframes voice-mode-lift {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-2px); }
|
||||
}
|
||||
@keyframes voice-mode-listen {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
@keyframes voice-mode-wave {
|
||||
0%, 100% { transform: scaleY(1); }
|
||||
45% { transform: scaleY(0.65); }
|
||||
}
|
||||
@keyframes voice-mode-nudge {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
50% { transform: translateX(var(--voice-mode-offset)); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
button .voice-mode-icon,
|
||||
button .voice-mode-icon * {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import './VoiceModeIcon.css';
|
||||
|
||||
export default function VoiceModeIcon({ mode }) {
|
||||
return (
|
||||
<svg
|
||||
className={`voice-mode-icon voice-mode-icon--${mode}`}
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
{mode === 'audio' ? (
|
||||
[4, 8, 12, 16, 20].map((x, index) => (
|
||||
<path
|
||||
key={x}
|
||||
className="voice-mode-wave"
|
||||
style={{ animationDelay: `${index * 35}ms` }}
|
||||
d={`M${x} ${[9, 6, 3, 6, 9][index]}v${[6, 12, 18, 12, 6][index]}`}
|
||||
/>
|
||||
))
|
||||
) : mode === 'design' ? (
|
||||
<>
|
||||
<path d="M4 7h16M4 17h16" />
|
||||
<circle className="voice-mode-dial" cx="9" cy="7" r="2.5" fill="var(--chrome-bg)" />
|
||||
<circle
|
||||
className="voice-mode-dial voice-mode-dial--second"
|
||||
cx="15"
|
||||
cy="17"
|
||||
r="2.5"
|
||||
fill="var(--chrome-bg)"
|
||||
/>
|
||||
</>
|
||||
) : mode === 'upload' ? (
|
||||
<>
|
||||
<path d="M5 15v5h14v-5" />
|
||||
<path className="voice-mode-upload" d="M12 16V3m-4 4 4-4 4 4" />
|
||||
</>
|
||||
) : mode === 'record' ? (
|
||||
<>
|
||||
<rect x="9" y="3" width="6" height="12" rx="3" />
|
||||
<path className="voice-mode-mic" d="M6 11v1a6 6 0 0 0 12 0v-1" />
|
||||
<path d="M12 18v3m-3 0h6" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path className="voice-mode-arrow" d="M4 7h15m-4-4 4 4-4 4" />
|
||||
<path className="voice-mode-arrow voice-mode-arrow--back" d="M20 17H5m4-4-4 4 4 4" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectGroup,
|
||||
SelectLabel,
|
||||
} from '../ui/select';
|
||||
|
||||
export default function VoiceSelect({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
groups = [],
|
||||
optionLabel = (option) => option.label ?? option,
|
||||
disabled = false,
|
||||
optionIcon,
|
||||
}) {
|
||||
const items = (values) =>
|
||||
values.map((option) => {
|
||||
const key = typeof option === 'string' ? option : option.value;
|
||||
const Icon = optionIcon?.(key);
|
||||
return (
|
||||
<SelectItem
|
||||
key={key}
|
||||
value={key || '__default_input__'}
|
||||
className="min-h-10 cursor-pointer px-3 text-sm text-[var(--chrome-fg)] focus:bg-[var(--chrome-accent-bg)] focus:text-[var(--chrome-accent)] data-[state=checked]:text-[var(--chrome-accent)]"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{Icon && <Icon size={16} aria-hidden="true" className="shrink-0 opacity-75" />}
|
||||
<span>{optionLabel(option)}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<Select
|
||||
value={value || '__default_input__'}
|
||||
onValueChange={(next) => onChange(next === '__default_input__' ? '' : next)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
aria-label={label}
|
||||
className="w-full min-h-12 border-transparent bg-[var(--chrome-hover-bg)] px-3 text-sm text-[var(--chrome-fg)] shadow-none hover:bg-[var(--chrome-accent-bg)]"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
collisionPadding={12}
|
||||
className="z-[150] max-h-80 bg-[var(--color-bg)] border-transparent rounded-lg shadow-xl"
|
||||
>
|
||||
{/* Restored profiles can contain a valid value outside today's curated
|
||||
choices. Keep it visible and selectable instead of showing blank. */}
|
||||
{value &&
|
||||
![...options, ...groups.flatMap((group) => group.options)].some(
|
||||
(option) => (typeof option === 'string' ? option : option.value) === value,
|
||||
) &&
|
||||
items([value])}
|
||||
{items(options)}
|
||||
{groups.map((group) => (
|
||||
<SelectGroup key={group.label}>
|
||||
<SelectLabel className="text-xs text-[var(--chrome-fg-muted)] px-3 pt-3">
|
||||
{group.label}
|
||||
</SelectLabel>
|
||||
{items(group.options)}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { LayoutGrid } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { LayoutGrid, Users, UserRound, Fingerprint } from 'lucide-react';
|
||||
import SearchableSelect from '../SearchableSelect';
|
||||
import { PRESET_ICONS, FALLBACK_VOICE_ICON, stripVoiceEmoji } from '../../utils/voiceIcons';
|
||||
import toast from 'react-hot-toast';
|
||||
import { PRESETS } from '../../utils/constants';
|
||||
import { autoProfileId, assignSpeakerProfile, castParts, castSpeakers } from '../../utils/segments';
|
||||
@@ -19,15 +21,6 @@ const DEFAULT_VOICE = '__default__';
|
||||
export default function CastingBoard({ t, dubSegments, setDubSegments, speakerClones, profiles }) {
|
||||
const [boardOpen, setBoardOpen] = useState(false);
|
||||
const [dropTarget, setDropTarget] = useState(null); // speaker id under a drag
|
||||
const [pickerFor, setPickerFor] = useState(null); // speaker id with the listbox open
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
const optionRefs = useRef([]);
|
||||
const rowBtnRefs = useRef({});
|
||||
|
||||
// Roving focus: while a listbox is open, the active option owns focus.
|
||||
useEffect(() => {
|
||||
if (pickerFor !== null) optionRefs.current[activeIdx]?.focus();
|
||||
}, [pickerFor, activeIdx]);
|
||||
const speakers = castSpeakers(dubSegments);
|
||||
if (!speakers.length) return null;
|
||||
|
||||
@@ -40,33 +33,47 @@ export default function CastingBoard({ t, dubSegments, setDubSegments, speakerCl
|
||||
if (!val) return t('dub.default');
|
||||
const clone = speakerClones[spk];
|
||||
if (clone && val === autoProfileId(spk)) {
|
||||
return t('dub.from_video', { duration: clone.duration.toFixed(1) });
|
||||
return stripVoiceEmoji(t('dub.from_video', { duration: clone.duration.toFixed(1) }));
|
||||
}
|
||||
if (val === autoProfileId(spk)) return `${t('dub.auto')} · ${spk}`;
|
||||
if (val.startsWith('preset:')) {
|
||||
return PRESETS.find((p) => p.id === val.replace('preset:', ''))?.name || val;
|
||||
return stripVoiceEmoji(t(`clone.preset_${val.replace('preset:', '')}`));
|
||||
}
|
||||
return profiles.find((p) => p.id === val)?.name || val;
|
||||
return stripVoiceEmoji(profiles.find((p) => p.id === val)?.name || val);
|
||||
};
|
||||
|
||||
// Per-speaker option list — the exact choices the <select> offers, in the
|
||||
// same order (auto-clone first when available, then Default, then saved
|
||||
// clone profiles, then design presets).
|
||||
const voiceOptions = (spk) => {
|
||||
const clone = speakerClones[spk];
|
||||
const auto = clone
|
||||
? [
|
||||
{
|
||||
value: autoProfileId(spk),
|
||||
label: t('dub.from_video', { duration: clone.duration.toFixed(1) }),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
return [
|
||||
...auto,
|
||||
{ value: '', label: t('dub.default') },
|
||||
...profiles.map((p) => ({ value: p.id, label: p.name })),
|
||||
...PRESETS.map((p) => ({ value: `preset:${p.id}`, label: p.name })),
|
||||
];
|
||||
const paletteChips = [
|
||||
{ value: '', label: t('dub.default'), Icon: UserRound },
|
||||
...profiles.map((p) => ({
|
||||
value: p.id,
|
||||
label: stripVoiceEmoji(p.name),
|
||||
group: 'clones',
|
||||
groupLabel: t('dub.clone_profiles'),
|
||||
Icon: Fingerprint,
|
||||
})),
|
||||
...PRESETS.map((p) => ({
|
||||
value: `preset:${p.id}`,
|
||||
label: stripVoiceEmoji(t(`clone.preset_${p.id}`)),
|
||||
group: 'presets',
|
||||
groupLabel: t('dub.design_presets'),
|
||||
Icon: PRESET_ICONS[p.id] || FALLBACK_VOICE_ICON,
|
||||
})),
|
||||
];
|
||||
// One option source keeps the compact selector, board, and drag validation in sync.
|
||||
const voiceOptions = (spk) => [
|
||||
...(speakerClones[spk] || currentVoice(spk) === autoProfileId(spk)
|
||||
? [{ value: autoProfileId(spk), label: voiceLabel(autoProfileId(spk), spk), Icon: UserRound }]
|
||||
: []),
|
||||
...paletteChips,
|
||||
];
|
||||
const renderVoice = (option) => {
|
||||
const Icon = option.Icon;
|
||||
return (
|
||||
<span className="inline-flex min-w-0 items-center gap-2">
|
||||
<Icon size={16} aria-hidden="true" className="shrink-0 opacity-75" />
|
||||
<span>{option.label}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const assign = (spk, val) => {
|
||||
@@ -74,40 +81,6 @@ export default function CastingBoard({ t, dubSegments, setDubSegments, speakerCl
|
||||
toast.success(t('dub.casting_assigned', { voice: voiceLabel(val, spk), speaker: spk }));
|
||||
};
|
||||
|
||||
const closePicker = (refocus = true) => {
|
||||
const spk = pickerFor;
|
||||
setPickerFor(null);
|
||||
setActiveIdx(0);
|
||||
if (refocus && spk !== null) rowBtnRefs.current[spk]?.focus();
|
||||
};
|
||||
|
||||
const onListboxKeyDown = (e, spk, options) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveIdx((i) => (i + 1) % options.length);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIdx((i) => (i - 1 + options.length) % options.length);
|
||||
} else if (e.key === 'Home') {
|
||||
e.preventDefault();
|
||||
setActiveIdx(0);
|
||||
} else if (e.key === 'End') {
|
||||
e.preventDefault();
|
||||
setActiveIdx(options.length - 1);
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
assign(spk, options[activeIdx].value);
|
||||
closePicker();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closePicker();
|
||||
} else if (e.key === 'Tab') {
|
||||
// Let the browser move focus forward/backward. Refocusing the trigger
|
||||
// here traps keyboard users inside the picker.
|
||||
closePicker(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDrop = (e, spk) => {
|
||||
e.preventDefault();
|
||||
setDropTarget(null);
|
||||
@@ -118,65 +91,37 @@ export default function CastingBoard({ t, dubSegments, setDubSegments, speakerCl
|
||||
assign(spk, value);
|
||||
};
|
||||
|
||||
// Speaker-independent palette (the auto-clone chip lives on its row).
|
||||
const paletteChips = [
|
||||
{ value: '', label: t('dub.default'), group: '' },
|
||||
...profiles.map((p) => ({ value: p.id, label: p.name, group: t('dub.clone_profiles') })),
|
||||
...PRESETS.map((p) => ({
|
||||
value: `preset:${p.id}`,
|
||||
label: p.name,
|
||||
group: t('dub.design_presets'),
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mt-[2px] px-[var(--space-3)] py-[3px] bg-[var(--chrome-bg)] rounded-[var(--chrome-radius-pill)] border border-transparent">
|
||||
<div className="dub-casting-controls my-3 p-4 bg-[var(--chrome-hover-bg)] rounded-lg border-0">
|
||||
<div className="flex gap-[var(--space-2)] items-center flex-wrap">
|
||||
<span
|
||||
className="font-[family-name:var(--chrome-font-mono)] text-[length:var(--chrome-label-size)] text-[var(--chrome-fg-muted)] tracking-[var(--chrome-label-track)] uppercase font-semibold"
|
||||
title={t('dub.cast_title')}
|
||||
>
|
||||
<Users size={16} aria-hidden="true" className="inline-block mr-2 align-text-bottom" />
|
||||
{t('dub.cast')}
|
||||
</span>
|
||||
{speakers.map((spk) => {
|
||||
const clone = speakerClones[spk];
|
||||
return (
|
||||
<div key={spk} className="dub-cast__pair">
|
||||
<span className="font-[family-name:var(--chrome-font-mono)] text-[0.62rem] text-[var(--chrome-fg)]">
|
||||
{spk}:
|
||||
<UserRound
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
className="inline-block mr-1 align-text-bottom"
|
||||
/>
|
||||
{spk}
|
||||
</span>
|
||||
<select
|
||||
className="input-base dub-cast__select"
|
||||
<SearchableSelect
|
||||
ariaLabel={spk}
|
||||
menuPortal
|
||||
renderGroupHeaders
|
||||
buttonClassName="dub-cast__select min-h-10 rounded-lg border-0 bg-[var(--chrome-hover-bg)] px-3 py-2 text-sm text-[var(--chrome-fg)] hover:bg-[var(--chrome-accent-bg)] focus-visible:outline-2 focus-visible:outline-[var(--chrome-accent)]"
|
||||
value={currentVoice(spk)}
|
||||
onChange={(e) =>
|
||||
setDubSegments(assignSpeakerProfile(dubSegments, spk, e.target.value))
|
||||
}
|
||||
>
|
||||
{clone && (
|
||||
<option value={autoProfileId(spk)}>
|
||||
{t('dub.from_video', { duration: clone.duration.toFixed(1) })}
|
||||
</option>
|
||||
)}
|
||||
<option value="">{t('dub.default')}</option>
|
||||
{profiles.length > 0 && (
|
||||
<optgroup label={t('dub.clone_profiles')}>
|
||||
{profiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{PRESETS.length > 0 && (
|
||||
<optgroup label={t('dub.design_presets')}>
|
||||
{PRESETS.map((p) => (
|
||||
<option key={p.id} value={`preset:${p.id}`}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
onChange={(value) => setDubSegments(assignSpeakerProfile(dubSegments, spk, value))}
|
||||
options={voiceOptions(spk)}
|
||||
renderOption={renderVoice}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -187,7 +132,7 @@ export default function CastingBoard({ t, dubSegments, setDubSegments, speakerCl
|
||||
onClick={() => setBoardOpen((o) => !o)}
|
||||
title={t('dub.casting_board_hint')}
|
||||
>
|
||||
<LayoutGrid size={10} /> {t('dub.casting_board')}
|
||||
<LayoutGrid size={15} aria-hidden="true" /> {t('dub.casting_board')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -201,13 +146,17 @@ export default function CastingBoard({ t, dubSegments, setDubSegments, speakerCl
|
||||
className="dub-cast__chip"
|
||||
draggable
|
||||
data-profile={chip.value}
|
||||
title={chip.group ? `${chip.group} · ${chip.label}` : chip.label}
|
||||
title={chip.groupLabel ? `${chip.groupLabel} · ${chip.label}` : chip.label}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', chip.value || DEFAULT_VOICE);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
>
|
||||
{chip.label}
|
||||
<chip.Icon size={18} aria-hidden="true" />
|
||||
<span className="dub-cast__chip-text">
|
||||
<span>{chip.label}</span>
|
||||
{chip.groupLabel && <small>{chip.groupLabel}</small>}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -234,66 +183,31 @@ export default function CastingBoard({ t, dubSegments, setDubSegments, speakerCl
|
||||
}}
|
||||
onDrop={(e) => onDrop(e, spk)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
ref={(el) => {
|
||||
rowBtnRefs.current[spk] = el;
|
||||
}}
|
||||
className="dub-cast__row-btn"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={pickerFor === spk}
|
||||
aria-label={t('dub.casting_assign_to', { speaker: spk })}
|
||||
title={t('dub.casting_assign_to', { speaker: spk })}
|
||||
onClick={() => {
|
||||
if (pickerFor === spk) {
|
||||
closePicker();
|
||||
return;
|
||||
}
|
||||
optionRefs.current = [];
|
||||
const idx = options.findIndex((o) => o.value === current);
|
||||
setActiveIdx(idx >= 0 ? idx : 0);
|
||||
setPickerFor(spk);
|
||||
}}
|
||||
>
|
||||
<span className="dub-cast__row-speaker">{spk}</span>
|
||||
<span className="dub-cast__row-voice">{voiceLabel(current, spk)}</span>
|
||||
</button>
|
||||
<span className="dub-cast__row-speaker">
|
||||
<UserRound size={16} aria-hidden="true" />
|
||||
{spk}
|
||||
</span>
|
||||
<SearchableSelect
|
||||
ariaLabel={t('dub.casting_assign_to', { speaker: spk })}
|
||||
menuPortal
|
||||
renderGroupHeaders
|
||||
buttonClassName="dub-cast__row-select"
|
||||
value={current}
|
||||
options={options}
|
||||
onChange={(value) => assign(spk, value)}
|
||||
renderOption={renderVoice}
|
||||
/>
|
||||
{clone && (
|
||||
<span
|
||||
className="dub-cast__auto-chip"
|
||||
title={t('dub.from_video', { duration: clone.duration.toFixed(1) })}
|
||||
>
|
||||
{t('dub.from_video', { duration: clone.duration.toFixed(1) })}
|
||||
<UserRound size={13} aria-hidden="true" />
|
||||
{stripVoiceEmoji(
|
||||
t('dub.from_video', { duration: clone.duration.toFixed(1) }),
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{pickerFor === spk && (
|
||||
<div
|
||||
className="dub-cast__listbox"
|
||||
role="listbox"
|
||||
aria-label={t('dub.casting_assign_to', { speaker: spk })}
|
||||
onKeyDown={(e) => onListboxKeyDown(e, spk, options)}
|
||||
>
|
||||
{options.map((opt, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={opt.value || DEFAULT_VOICE}
|
||||
ref={(el) => {
|
||||
optionRefs.current[i] = el;
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={opt.value === current}
|
||||
tabIndex={i === activeIdx ? 0 : -1}
|
||||
className={`dub-cast__option ${opt.value === current ? 'is-current' : ''}`}
|
||||
onClick={() => {
|
||||
assign(spk, opt.value);
|
||||
closePicker();
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user