diff --git a/.gitignore b/.gitignore index bceca49..c84c621 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,22 @@ venv/ node_modules/ dist/ build/ + +# Next.js (frontend/) +frontend/.next/ +frontend/next-env.d.ts +frontend/out/ +frontend/.turbo/ +frontend/tsconfig.tsbuildinfo +frontend/*.tsbuildinfo + +# Editor / OS +*.swp +*.swo +.idea/ +.vscode/ +Thumbs.db + backend/data/*.json backend/data/*.db backend/data/*.db-shm @@ -28,6 +44,3 @@ backend/data/qc_graph_exploration.zip backend/logs/ repomix-output.xml docs/review_bundles/*.patch -frontend/*.tsbuildinfo -frontend/vite.config.js -frontend/vite.config.d.ts diff --git a/INSTALL.md b/INSTALL.md index ce9cd2a..4ae2908 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -68,7 +68,7 @@ make dev-frontend Default frontend URL: -- `http://127.0.0.1:5173` +- `http://127.0.0.1:3002` The Makefile points the frontend at `http://127.0.0.1:8010` by default. @@ -105,7 +105,7 @@ FRONTEND_API_BASE_URL=http://127.0.0.1:8000 make dev-frontend The frontend can also be configured directly with: ```bash -VITE_API_BASE_URL=http://127.0.0.1:8010 npm run dev +SPEECHCRAFT_BACKEND_URL=http://127.0.0.1:8010 bun run dev ``` ## ASR Configuration @@ -176,7 +176,7 @@ uv run python -m unittest discover -s tests -p 'test_*.py' ```bash cd frontend -npm run build +bun run build ``` Smoke test against a running backend: @@ -197,7 +197,7 @@ Expected local files/directories: - `backend/.venv/` - `frontend/node_modules/` -- `frontend/dist/` +- `frontend/.next/` - `backend/data/project.db` - `backend/data/media/` - `backend/exports/` diff --git a/Makefile b/Makefile index 59bf0c6..5fd8468 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ help: @printf " make dev-backend Run the FastAPI API and ProcessingJob worker\n" @printf " make dev-api Run only the FastAPI API with reload\n" @printf " make dev-worker Run only the ProcessingJob worker\n" - @printf " make dev-frontend Run the Vite frontend dev server\n" + @printf " make dev-frontend Run the Next.js frontend dev server (http://127.0.0.1:3002)\n" @printf " make check Run backend and frontend verification\n" @printf " make check-backend Compile-check backend Python code\n" @printf " make check-frontend Build the frontend production bundle\n" @@ -44,7 +44,7 @@ setup-backend: cd $(BACKEND_DIR) && UV_CACHE_DIR=$(UV_CACHE_DIR) uv sync setup-frontend: - cd $(FRONTEND_DIR) && npm install + cd $(FRONTEND_DIR) && bun install dev-backend: cd $(BACKEND_DIR) && UV_CACHE_DIR=$(UV_CACHE_DIR) sh -c 'set -e; mkdir -p logs; : > logs/dev-backend.log; (uv run uvicorn app.main:app --reload --host $(BACKEND_HOST) --port $(BACKEND_PORT) 2>&1 | tee -a logs/dev-backend.log) & api_pid=$$!; (uv run python -m app.worker 2>&1 | tee -a logs/dev-backend.log) & worker_pid=$$!; trap "kill $$api_pid $$worker_pid 2>/dev/null || true" INT TERM EXIT; wait' @@ -58,7 +58,7 @@ dev-worker: backend-docs: dev-backend dev-frontend: - cd $(FRONTEND_DIR) && VITE_API_BASE_URL=$(FRONTEND_API_BASE_URL) npm run dev + cd $(FRONTEND_DIR) && SPEECHCRAFT_BACKEND_URL=$(FRONTEND_API_BASE_URL) bun run dev check: check-backend check-frontend @@ -68,7 +68,7 @@ check-backend: cd $(WORKER_ROOT) && PYTHONPATH=. $(BACKEND_DIR)/.venv/bin/python tests/test_clip_lab_coordination.py check-frontend: - cd $(FRONTEND_DIR) && npm run build + cd $(FRONTEND_DIR) && bun run build smoke-backend: cd $(BACKEND_DIR) && UV_CACHE_DIR=$(UV_CACHE_DIR) uv run python scripts/smoke_backend.py --base-url $(SMOKE_BACKEND_BASE_URL) --project-id $(SMOKE_BACKEND_PROJECT_ID) diff --git a/README.md b/README.md index affd6ad..3cbd9f8 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Export is present in navigation as the downstream handoff stage. The backend has ## Project Layout - `backend/`: FastAPI API, SQLite persistence, media management, processing worker, slicing/QC/export logic -- `frontend/`: React + Vite UI for the staged workstation +- `frontend/`: Next.js UI — the setup wizard and the Lab workstation. - `docs/`: product, workflow, and implementation notes ## Core Docs @@ -104,11 +104,11 @@ The combined backend/API dev output is also written to `backend/logs/dev-backend By default the Makefile uses: -- frontend: `http://127.0.0.1:5173` +- frontend: `http://127.0.0.1:3002` - backend: `http://127.0.0.1:8010` - backend docs: `http://127.0.0.1:8010/docs` -The frontend API target can be overridden with `VITE_API_BASE_URL`. +The frontend reaches the backend same-origin through the `/sc-api` rewrite (see `frontend/next.config.ts`); override the target with `SPEECHCRAFT_BACKEND_URL`. ## Local State @@ -148,7 +148,7 @@ Useful focused checks: ```bash python3 -m compileall backend/app cd backend && uv run python -m unittest discover -s tests -p 'test_*.py' -cd frontend && npm run build +cd frontend && bun run build ``` ## Current Status diff --git a/backend/app/canonical_export.py b/backend/app/canonical_export.py new file mode 100644 index 0000000..cf2f5d9 --- /dev/null +++ b/backend/app/canonical_export.py @@ -0,0 +1,409 @@ +from __future__ import annotations + +import json +import os +import shutil +import wave +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from sqlmodel import Session + +from .clip_lab_audio import sha256_file +from .clip_lab_audio_ops import render_cache_path +from .clip_lab_state import ( + ClipLabValidationError, + build_clip_lab_view, + clip_lab_run_lock, + index_manifest_by_clip_id, + load_candidate_manifest, + load_clip_lab_state, +) +from .dataset_runs import _run_root +from .models import ( + CanonicalExportBlockedReasonView, + CanonicalExportPreviewView, + CanonicalExportSummaryView, + ProcessingRun, +) + +SCHEMA_VERSION = 1 +EXPORTS_RELATIVE_DIR = Path("artifacts/canonical_exports") +MANIFEST_FILENAME = "speechcraft_dataset.jsonl" +METADATA_FILENAME = "speechcraft_export.json" +REPORT_FILENAME = "export_report.json" + + +class CanonicalExportConflictError(ValueError): + """Current Clip Lab state cannot produce a canonical export.""" + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc).replace(microsecond=0) + + +def _utc_now_iso() -> str: + return _utc_now().isoformat().replace("+00:00", "Z") + + +def _canonical_exports_root(run_root: Path) -> Path: + return run_root / EXPORTS_RELATIVE_DIR + + +def _format_export_id(now: datetime) -> str: + return f"canonical_export_{now.strftime('%Y-%m-%d_%H%M%S')}_{uuid4().hex[:8]}" + + +def _next_export_id(exports_root: Path) -> str: + while True: + candidate = _format_export_id(_utc_now()) + if not (exports_root / candidate).exists(): + return candidate + + +def _read_wave_metadata(path: Path) -> dict[str, int | float]: + with wave.open(str(path), "rb") as handle: + frame_count = int(handle.getnframes()) + sample_rate_hz = int(handle.getframerate()) + channels = int(handle.getnchannels()) + if sample_rate_hz <= 0: + raise ClipLabValidationError(f"audio file has invalid sample rate: {path}") + return { + "sample_rate_hz": sample_rate_hz, + "channels": channels, + "duration_sec": round(frame_count / sample_rate_hz, 6), + } + + +def _resolve_manifest_audio_path(run_root: Path, manifest_row: dict[str, Any], *, clip_id: str) -> Path: + audio_path = manifest_row.get("audio_path") + if not isinstance(audio_path, str) or not audio_path.strip(): + raise ClipLabValidationError(f"{clip_id} candidate manifest row is missing audio_path") + return run_root / audio_path + + +def _relative_path(from_dir: Path, to_path: Path) -> str: + return os.path.relpath(to_path, start=from_dir) + + +def _serialize_json(path: Path, payload: dict[str, Any]) -> None: + path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + + +def _quality_payload(clip_view: dict[str, Any]) -> dict[str, float] | None: + quality: dict[str, float] = {} + transcript_match = clip_view.get("transcript_match") + speaker_check = clip_view.get("speaker_check") + if isinstance(transcript_match, (int, float)): + quality["transcript_match"] = round(float(transcript_match), 2) + if isinstance(speaker_check, (int, float)): + quality["speaker_check"] = round(float(speaker_check), 2) + return quality or None + + +def _build_clip_export_row( + *, + export_dir: Path, + run_root: Path, + manifest_row: dict[str, Any], + clip_view: dict[str, Any], + clip_entry: dict[str, Any] | None = None, +) -> tuple[dict[str, Any] | None, list[str], str | None, float]: + clip_id = str(clip_view["clip_id"]) + transcript = clip_view["transcript"] + if not isinstance(transcript, str): + return None, ["transcript_missing"], None, 0.0 + + source_audio_sha256 = clip_view.get("source_audio_sha256") + if not isinstance(source_audio_sha256, str) or not source_audio_sha256: + return None, ["source_audio_sha256_missing"], None, 0.0 + + audio_path: Path | None = None + audio_sha256: str | None = None + audio_revision_hash: str | None = None + reasons: list[str] = [] + audio_kind: str + audio_edit = clip_entry.get("audio_edit") if isinstance(clip_entry, dict) else None + has_active_audio_edit = isinstance(audio_edit, dict) and bool(audio_edit.get("ops") or []) + + if has_active_audio_edit: + audio_kind = "rendered_revision" + render_status = audio_edit.get("render_status") + audio_revision_hash = audio_edit.get("audio_revision_hash") + rendered_audio_sha256 = audio_edit.get("rendered_audio_sha256") + if render_status != "ready": + reasons.append("rendered_audio_not_ready") + if not isinstance(audio_revision_hash, str) or not audio_revision_hash: + reasons.append("audio_revision_hash_missing") + if not isinstance(rendered_audio_sha256, str) or not rendered_audio_sha256: + reasons.append("rendered_audio_sha256_missing") + if not reasons: + audio_path = render_cache_path(run_root, clip_id, audio_revision_hash) + if not audio_path.is_file(): + reasons.append("rendered_audio_missing") + elif sha256_file(audio_path) != rendered_audio_sha256: + reasons.append("rendered_audio_hash_mismatch") + else: + audio_sha256 = rendered_audio_sha256 + else: + audio_kind = "candidate_original" + audio_path = _resolve_manifest_audio_path(run_root, manifest_row, clip_id=clip_id) + if not audio_path.is_file(): + reasons.append("candidate_audio_missing") + elif sha256_file(audio_path) != source_audio_sha256: + reasons.append("candidate_audio_hash_mismatch") + else: + audio_sha256 = source_audio_sha256 + + if reasons or audio_path is None or audio_sha256 is None: + return None, reasons, audio_kind, 0.0 + + audio_meta = _read_wave_metadata(audio_path) + audio_payload: dict[str, Any] = { + "path": _relative_path(export_dir, audio_path), + "kind": audio_kind, + "sha256": audio_sha256, + "source_audio_sha256": source_audio_sha256, + "sample_rate_hz": audio_meta["sample_rate_hz"], + "channels": audio_meta["channels"], + "duration_sec": audio_meta["duration_sec"], + } + if audio_kind == "rendered_revision" and audio_revision_hash: + audio_payload["audio_revision_hash"] = audio_revision_hash + + row: dict[str, Any] = { + "schema_version": SCHEMA_VERSION, + "clip_id": clip_id, + "transcript": transcript, + "lineage": { + "source_clip_id": clip_id, + "parent_clip_ids": [], + }, + "audio": audio_payload, + "review": { + "status": "accepted", + "reviewer_tags": list(clip_view.get("reviewer_tags") or []), + }, + } + quality = _quality_payload(clip_view) + if quality: + row["quality"] = quality + return row, [], audio_kind, float(audio_meta["duration_sec"]) + + +def _collect_export_material( + repository: Any, + run_id: str, +) -> tuple[ProcessingRun, Path, list[tuple[dict[str, Any], dict[str, Any], dict[str, Any] | None]], CanonicalExportPreviewView]: + with Session(repository.engine) as session: + run = session.get(ProcessingRun, run_id) + if run is None: + raise KeyError("Dataset run not found") + run_root = _run_root(repository, run) + + with clip_lab_run_lock(run_root): + view = build_clip_lab_view(run_root, run_id=run_id) + if view.get("stale_state"): + raise CanonicalExportConflictError( + f"Clip Lab state is stale: {view.get('stale_reason') or 'candidate manifest changed'}" + ) + if view.get("invalid_state"): + raise CanonicalExportConflictError( + f"Clip Lab state is invalid: {view.get('invalid_state_reason') or 'validation failed'}" + ) + + manifest = load_candidate_manifest(run_root) + manifest_by_id = index_manifest_by_clip_id(manifest) + saved_state = load_clip_lab_state(run_root) + stored_clips = saved_state.get("clips") if isinstance(saved_state, dict) else None + accepted_clip_views = [dict(clip) for clip in view.get("clips") or [] if clip.get("review_status") == "accepted"] + accepted_manifest_rows: dict[str, dict[str, Any]] = {} + accepted_clip_entries: dict[str, dict[str, Any] | None] = {} + for clip_view in accepted_clip_views: + clip_id = str(clip_view["clip_id"]) + manifest_row = manifest_by_id.get(clip_id) + if manifest_row is not None: + accepted_manifest_rows[clip_id] = dict(manifest_row) + if isinstance(stored_clips, dict) and isinstance(stored_clips.get(clip_id), dict): + accepted_clip_entries[clip_id] = dict(stored_clips[clip_id]) + else: + accepted_clip_entries[clip_id] = None + + export_inputs: list[tuple[dict[str, Any], dict[str, Any], dict[str, Any] | None]] = [] + blocked_reasons: list[CanonicalExportBlockedReasonView] = [] + total_duration_sec = 0.0 + original_audio_count = 0 + edited_audio_count = 0 + preview_export_dir = _canonical_exports_root(run_root) / "preview" + for clip_view in accepted_clip_views: + clip_id = str(clip_view["clip_id"]) + manifest_row = accepted_manifest_rows.get(clip_id) + if manifest_row is None: + blocked_reasons.append( + CanonicalExportBlockedReasonView(clip_id=clip_id, reasons=["manifest_row_missing"]) + ) + continue + row, reasons, audio_kind, duration_sec = _build_clip_export_row( + export_dir=preview_export_dir, + run_root=run_root, + manifest_row=manifest_row, + clip_view=clip_view, + clip_entry=accepted_clip_entries.get(clip_id), + ) + if reasons: + blocked_reasons.append(CanonicalExportBlockedReasonView(clip_id=clip_id, reasons=reasons)) + continue + assert row is not None + export_inputs.append((manifest_row, clip_view, accepted_clip_entries.get(clip_id))) + total_duration_sec += duration_sec + if audio_kind == "rendered_revision": + edited_audio_count += 1 + else: + original_audio_count += 1 + + preview = CanonicalExportPreviewView( + run_id=run_id, + accepted_clip_count=len(accepted_clip_views), + total_duration_sec=round(total_duration_sec, 6), + original_audio_count=original_audio_count, + edited_audio_count=edited_audio_count, + blocked_clip_count=len(blocked_reasons), + blocked_reasons=blocked_reasons, + ) + return run, run_root, export_inputs, preview + + +def preview_canonical_export(repository: Any, run_id: str) -> CanonicalExportPreviewView: + _run, _run_root_path, _rows, preview = _collect_export_material(repository, run_id) + return preview + + +def create_canonical_export(repository: Any, run_id: str) -> CanonicalExportSummaryView: + run, run_root, export_inputs, preview = _collect_export_material(repository, run_id) + if preview.accepted_clip_count == 0: + raise CanonicalExportConflictError("no accepted clips available for canonical export") + if preview.blocked_clip_count > 0: + raise CanonicalExportConflictError("accepted clips are blocked; resolve Clip Lab export blockers first") + + exports_root = _canonical_exports_root(run_root) + exports_root.mkdir(parents=True, exist_ok=True) + export_id = _next_export_id(exports_root) + tmp_dir = exports_root / f".tmp_{export_id}_{uuid4().hex}" + final_dir = exports_root / export_id + if final_dir.exists(): + raise CanonicalExportConflictError("canonical export snapshot already exists; retry") + tmp_dir.mkdir(parents=True, exist_ok=False) + + try: + export_rows: list[dict[str, Any]] = [] + for manifest_row, clip_view, clip_entry in export_inputs: + row, reasons, _audio_kind, _duration_sec = _build_clip_export_row( + export_dir=final_dir, + run_root=run_root, + manifest_row=manifest_row, + clip_view=clip_view, + clip_entry=clip_entry, + ) + if reasons or row is None: + raise CanonicalExportConflictError("canonical export inputs changed while exporting; retry") + export_rows.append(row) + + manifest_path = tmp_dir / MANIFEST_FILENAME + with manifest_path.open("w", encoding="utf-8") as handle: + for row in export_rows: + handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True)) + handle.write("\n") + + metadata = { + "schema_version": SCHEMA_VERSION, + "export_id": export_id, + "run_id": run.id, + "project_id": run.project_id, + "created_at": _utc_now_iso(), + "path_mode": "snapshot_relative", + "audio_storage_mode": "project_artifact_reference", + "portable": False, + "dataset_manifest": MANIFEST_FILENAME, + "accepted_clip_count": preview.accepted_clip_count, + "total_duration_sec": preview.total_duration_sec, + "original_audio_count": preview.original_audio_count, + "edited_audio_count": preview.edited_audio_count, + "blocked_clip_count": preview.blocked_clip_count, + } + _serialize_json(tmp_dir / METADATA_FILENAME, metadata) + _serialize_json( + tmp_dir / REPORT_FILENAME, + { + "run_id": run.id, + "export_id": export_id, + "accepted_clip_count": preview.accepted_clip_count, + "blocked_clip_count": preview.blocked_clip_count, + "blocked_reasons": [item.model_dump() for item in preview.blocked_reasons], + }, + ) + + tmp_dir.rename(final_dir) + except Exception: + shutil.rmtree(tmp_dir, ignore_errors=True) + raise + return CanonicalExportSummaryView( + export_id=export_id, + run_id=run.id, + project_id=run.project_id, + created_at=str(metadata["created_at"]), + accepted_clip_count=preview.accepted_clip_count, + total_duration_sec=preview.total_duration_sec, + snapshot_dir=str(final_dir), + manifest_path=str(final_dir / MANIFEST_FILENAME), + ) + + +def _summary_from_snapshot(run_id: str, snapshot_dir: Path) -> CanonicalExportSummaryView | None: + metadata_path = snapshot_dir / METADATA_FILENAME + try: + payload = json.loads(metadata_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(payload, dict): + return None + export_id = payload.get("export_id") + project_id = payload.get("project_id") + created_at = payload.get("created_at") + accepted_clip_count = payload.get("accepted_clip_count") + total_duration_sec = payload.get("total_duration_sec") + if not isinstance(export_id, str) or not isinstance(project_id, str) or not isinstance(created_at, str): + return None + if not isinstance(accepted_clip_count, int) or not isinstance(total_duration_sec, (int, float)): + return None + return CanonicalExportSummaryView( + export_id=export_id, + run_id=run_id, + project_id=project_id, + created_at=created_at, + accepted_clip_count=accepted_clip_count, + total_duration_sec=round(float(total_duration_sec), 6), + snapshot_dir=str(snapshot_dir), + manifest_path=str(snapshot_dir / MANIFEST_FILENAME), + ) + + +def list_canonical_exports(repository: Any, run_id: str) -> list[CanonicalExportSummaryView]: + with Session(repository.engine) as session: + run = session.get(ProcessingRun, run_id) + if run is None: + raise KeyError("Dataset run not found") + + run_root = _run_root(repository, run) + exports_root = _canonical_exports_root(run_root) + if not exports_root.exists(): + return [] + summaries: list[CanonicalExportSummaryView] = [] + for child in exports_root.iterdir(): + if not child.is_dir() or child.name.startswith(".tmp_"): + continue + summary = _summary_from_snapshot(run_id, child) + if summary is not None: + summaries.append(summary) + return sorted(summaries, key=lambda item: item.created_at, reverse=True) diff --git a/backend/app/clip_lab_audio_ops.py b/backend/app/clip_lab_audio_ops.py index 80b38d4..9862102 100644 --- a/backend/app/clip_lab_audio_ops.py +++ b/backend/app/clip_lab_audio_ops.py @@ -76,6 +76,13 @@ def _manifest_wav_path(run_root: Path, manifest_row: dict[str, Any]) -> Path: return run_root / str(manifest_row["audio_path"]) +NATIVE_EXPORT_CLIPS_DIR = "artifacts/native_export_clips" + + +def _native_export_wav_path(run_root: Path, clip_id: str) -> Path: + return run_root / NATIVE_EXPORT_CLIPS_DIR / f"{clip_id}.wav" + + def _source_identity(manifest_row: dict[str, Any], *, clip_id: str) -> str: source_sha = resolve_manifest_source_audio_hash(manifest_row, clip_id=clip_id) if source_sha is None: @@ -705,6 +712,9 @@ def resolve_revision_media_bytes( raise ClipLabRevisionNotFoundError(f"revision key {revision_key!r} is not current for clip {clip_id}") source_sha = _source_identity(manifest_row, clip_id=clip_id) if revision_key == source_sha: + native_wav = _native_export_wav_path(run_root, clip_id) + if native_wav.is_file(): + return native_wav.read_bytes() return _manifest_wav_path(run_root, manifest_row).read_bytes() cache_path = render_cache_path(run_root, clip_id, revision_key) if not cache_path.is_file(): diff --git a/backend/app/dataset_runs.py b/backend/app/dataset_runs.py index 525bb15..20efe14 100644 --- a/backend/app/dataset_runs.py +++ b/backend/app/dataset_runs.py @@ -299,8 +299,10 @@ def create_dataset_run(repository: Any, project_id: str, request: DatasetRunCrea raise KeyError(f"Source recordings not found: {', '.join(missing)}") if not selected_ids: raise ValueError("Dataset run requires at least one source recording") - if not request.single_speaker and len(selected_ids) != 1: - raise ValueError("Multi-speaker diarization currently supports exactly one source WAV per run") + # Multi-file diarization is supported (option C): the worker concatenates + # the per-source analysis variants into a single timeline, diarizes once + # (so speaker identity is consistent across files), then remaps regions + # back per source. No per-run WAV-count limit. reserved = sorted(RESERVED_WORKER_CONFIG_KEYS.intersection(request.config)) if reserved: raise ValueError(f"Dataset run config contains backend-controlled keys: {', '.join(reserved)}") diff --git a/backend/app/main.py b/backend/app/main.py index 25c1bd4..d26f1e9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,6 +8,12 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, Response +from .canonical_export import ( + CanonicalExportConflictError, + create_canonical_export, + list_canonical_exports, + preview_canonical_export, +) from .reference_clip_candidates import mark_dataset_clip_as_reference_candidate from .dataset_worker_client import run_dataset_worker_preflight from .defaults import resolve_asr_device_and_compute_type, resolve_whisper_model @@ -54,6 +60,8 @@ ) from .native_cliplab import NativeClipLabStore from .models import ( + CanonicalExportPreviewView, + CanonicalExportSummaryView, DatasetClipLabClipView, DatasetClipLabAudioOperationRequest, DatasetClipLabAudioStackRequest, @@ -74,8 +82,6 @@ DatasetRunView, DatasetSlicerResultsView, DatasetSlicerRerunRequest, - ExportPreview, - ExportRun, ImportBatchCreate, MarkReferenceClipCandidateRequest, ProcessingJobView, @@ -351,6 +357,42 @@ def read_dataset_clip_lab(run_id: str) -> DatasetClipLabView: raise HTTPException(status_code=503, detail=str(exc)) from exc +@app.get("/api/dataset-runs/{run_id}/canonical-export-preview", response_model=CanonicalExportPreviewView) +def read_canonical_export_preview(run_id: str) -> CanonicalExportPreviewView: + try: + return preview_canonical_export(repository, run_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except CanonicalExportConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except ClipLabValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except ClipLabStateError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + + +@app.post("/api/dataset-runs/{run_id}/canonical-exports", response_model=CanonicalExportSummaryView) +def post_canonical_export(run_id: str) -> CanonicalExportSummaryView: + try: + return create_canonical_export(repository, run_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except CanonicalExportConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except ClipLabValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except ClipLabStateError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + + +@app.get("/api/dataset-runs/{run_id}/canonical-exports", response_model=list[CanonicalExportSummaryView]) +def read_canonical_exports(run_id: str) -> list[CanonicalExportSummaryView]: + try: + return list_canonical_exports(repository, run_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @app.post( "/api/dataset-runs/{run_id}/clips/{clip_id}/audio/operations", response_model=DatasetClipLabClipView, @@ -694,22 +736,6 @@ def get_source_recording_artifact(recording_id: str) -> SourceRecordingArtifactV raise HTTPException(status_code=404, detail="Source recording not found") from exc -@app.get("/api/projects/{project_id}/export-preview", response_model=ExportPreview) -def get_export_preview(project_id: str) -> ExportPreview: - try: - return repository.get_export_preview(project_id) - except KeyError as exc: - raise HTTPException(status_code=404, detail="Project not found") from exc - - -@app.get("/api/projects/{project_id}/exports", response_model=list[ExportRun]) -def list_export_runs(project_id: str) -> list[ExportRun]: - try: - return repository.list_export_runs(project_id) - except KeyError as exc: - raise HTTPException(status_code=404, detail="Project not found") from exc - - @app.post("/api/projects/{project_id}/media-cleanup") def cleanup_project_media(project_id: str) -> dict[str, object]: try: @@ -819,16 +845,6 @@ def redo_slice(clip_id: str) -> dict[str, object]: raise HTTPException(status_code=400, detail=str(exc)) from exc -@app.post("/api/projects/{project_id}/export", response_model=ExportRun) -def export_project(project_id: str) -> ExportRun: - try: - return repository.export_project(project_id) - except KeyError as exc: - raise HTTPException(status_code=404, detail="Project not found") from exc - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - - @app.post("/api/recordings/{recording_id}/jobs/transcription", response_model=ProcessingJobView) def enqueue_source_transcription( recording_id: str, diff --git a/backend/app/models.py b/backend/app/models.py index a063680..03fc221 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -293,7 +293,6 @@ class ImportBatch(SQLModel, table=True): active_preparation_job_id: str | None = None recordings: list["SourceRecording"] = Relationship(back_populates="batch", cascade_delete=True) - exports: list["ExportRun"] = Relationship(back_populates="batch", cascade_delete=True) class SourceRecording(SQLModel, table=True): @@ -913,6 +912,32 @@ class DatasetClipLabView(SQLModel): clips: list[DatasetClipLabClipView] = Field(default_factory=list) +class CanonicalExportBlockedReasonView(SQLModel): + clip_id: str + reasons: list[str] = Field(default_factory=list) + + +class CanonicalExportPreviewView(SQLModel): + run_id: str + accepted_clip_count: int = 0 + total_duration_sec: float = 0.0 + original_audio_count: int = 0 + edited_audio_count: int = 0 + blocked_clip_count: int = 0 + blocked_reasons: list[CanonicalExportBlockedReasonView] = Field(default_factory=list) + + +class CanonicalExportSummaryView(SQLModel): + export_id: str + run_id: str + project_id: str + created_at: str + accepted_clip_count: int + total_duration_sec: float + snapshot_dir: str + manifest_path: str + + class DatasetClipLabPatchRequest(SQLModel): expected_manifest_sha256: str expected_clip_version: int @@ -1011,29 +1036,6 @@ def duration_s(self) -> float: return self.num_samples / self.sample_rate if self.sample_rate else 0.0 -class ExportRun(SQLModel, table=True): - """Project-level export history for the current UI.""" - - id: str = Field(primary_key=True) - batch_id: str = Field(foreign_key="importbatch.id") - status: JobStatus = Field(default=JobStatus.PENDING, sa_column=Column(sql_enum(JobStatus))) - output_root: str - manifest_path: str - accepted_clip_count: int = 0 - failed_clip_count: int = 0 - created_at: datetime = Field(default_factory=utc_now) - completed_at: datetime | None = None - - batch: ImportBatch | None = Relationship(back_populates="exports") - - -class ExportPreview(SQLModel): - project_id: str - manifest_path: str - accepted_slice_count: int - lines: list[str] - - class ImportBatchCreate(SQLModel): id: str name: str @@ -1044,7 +1046,6 @@ class ProjectSummary(SQLModel): name: str created_at: datetime updated_at: datetime - export_status: JobStatus | None = None active_prepared_output_group_id: str | None = None active_preparation_job_id: str | None = None diff --git a/backend/app/repository.py b/backend/app/repository.py index c669bec..ee0c77a 100644 --- a/backend/app/repository.py +++ b/backend/app/repository.py @@ -45,8 +45,6 @@ load_cutpoint_windows, ) from .models import ( - ExportRun, - ExportPreview, ImportBatch, ImportBatchCreate, JobKind, @@ -827,45 +825,6 @@ def list_source_recording_jobs(self, recording_id: str) -> list[ProcessingJobVie ).all() return [self._processing_job_view(job) for job in jobs] - def list_export_runs(self, project_id: str) -> list[ExportRun]: - with self._session() as session: - self._get_batch(session, project_id) - runs = session.exec( - select(ExportRun).where(ExportRun.batch_id == project_id).order_by(ExportRun.created_at) - ).all() - return [self._normalize_export_run(run) for run in runs] - - def get_export_preview(self, project_id: str) -> ExportPreview: - with self._session() as session: - batch = self._get_batch(session, project_id) - return ExportPreview( - project_id=batch.id, - manifest_path=f"exports/{batch.id}/dataset.list", - accepted_slice_count=0, - lines=[], - ) - - - def export_project(self, project_id: str) -> ExportRun: - with self._session() as session: - self._get_batch(session, project_id) - export_id = self._new_id("export") - output_root = self.exports_root / project_id / export_id - manifest_path = output_root / "dataset.list" - export_run = ExportRun( - id=export_id, - batch_id=project_id, - status=JobStatus.COMPLETED, - output_root=str(output_root), - manifest_path=str(manifest_path), - accepted_clip_count=0, - completed_at=utc_now(), - ) - session.add(export_run) - session.commit() - return self._normalize_export_run(export_run) - - def get_source_recording_window_media_path( self, recording_id: str, @@ -927,7 +886,6 @@ def delete_project(self, project_id: str) -> dict[str, int | str]: session.exec(delete(SourceRecordingArtifact).where(SourceRecordingArtifact.source_recording_id.in_(recording_ids))) session.exec(delete(ProcessingJob).where(ProcessingJob.source_recording_id.in_(recording_ids))) session.exec(delete(SourceRecording).where(SourceRecording.id.in_(recording_ids))) - session.exec(delete(ExportRun).where(ExportRun.batch_id == project_id)) session.exec(delete(ImportBatch).where(ImportBatch.id == project_id)) session.commit() @@ -1936,7 +1894,6 @@ def _seed_from_legacy_json(self, session: Session) -> None: source_recordings: dict[tuple[str, str], SourceRecording] = {} projects = legacy.get("projects", {}) clips_by_project = legacy.get("clips_by_project", {}) - exports_by_project = legacy.get("exports_by_project", {}) source_audio_overrides = self._legacy_seed_source_audio_overrides(clips_by_project) for project_id, batch_payload in projects.items(): @@ -1995,24 +1952,6 @@ def _seed_from_legacy_json(self, session: Session) -> None: source_recordings[source_key] = source_recording session.flush() - for export_payload in exports_by_project.get(batch.id, []): - session.add( - ExportRun( - id=export_payload["id"], - batch_id=batch.id, - status=self._job_status_from_legacy(export_payload["status"]), - output_root=export_payload["output_root"], - manifest_path=export_payload["manifest_path"], - accepted_clip_count=export_payload["accepted_clip_count"], - failed_clip_count=export_payload["failed_clip_count"], - created_at=datetime.fromisoformat(export_payload["created_at"].replace("Z", "+00:00")), - completed_at=( - datetime.fromisoformat(export_payload["completed_at"].replace("Z", "+00:00")) - if export_payload.get("completed_at") - else None - ), - ) - ) def _seed_demo(self, session: Session) -> None: batch = ImportBatch(id="phase1-demo", name="Phase 1 Demo Project") @@ -2080,34 +2019,16 @@ def _coerce_datetime(self, value: datetime | str | None) -> datetime: def _project_summary(self, session: Session, batch: ImportBatch) -> ProjectSummary: created_at = self._as_utc(batch.created_at) updated_at = created_at - latest_export = session.exec( - select(ExportRun).where(ExportRun.batch_id == batch.id).order_by(ExportRun.created_at.desc()) - ).first() - export_status = None - if latest_export is not None: - export_status = latest_export.status - updated_at = max( - updated_at, - self._as_utc(latest_export.completed_at or latest_export.created_at), - ) return ProjectSummary( id=batch.id, name=batch.name, created_at=created_at, updated_at=updated_at, - export_status=export_status, active_prepared_output_group_id=batch.active_prepared_output_group_id, active_preparation_job_id=batch.active_preparation_job_id, ) - def _normalize_export_run(self, run: ExportRun) -> ExportRun: - run.created_at = self._as_utc(run.created_at) - if run.completed_at is not None: - run.completed_at = self._as_utc(run.completed_at) - return run - - def _source_recording_view( self, @@ -4505,15 +4426,6 @@ def _job_status_to_api(self, status: JobStatus) -> str: } return mapping[status] - def _export_status_from_job(self, status: JobStatus) -> str: - mapping = { - JobStatus.PENDING: "export_in_progress", - JobStatus.RUNNING: "export_in_progress", - JobStatus.COMPLETED: "export_succeeded", - JobStatus.FAILED: "export_failed", - } - return mapping[status] - _repository_instance: SQLiteRepository | None = None diff --git a/backend/tests/test_api_integration.py b/backend/tests/test_api_integration.py index 46aa785..f01b5f2 100644 --- a/backend/tests/test_api_integration.py +++ b/backend/tests/test_api_integration.py @@ -37,14 +37,8 @@ def test_project_routes_return_seeded_demo_project(self) -> None: self.assertGreaterEqual(len(recordings), 1) self.assertEqual(recordings[0]["slice_count"], 0) - preview_response = self.client.get("/api/projects/phase1-demo/export-preview") - self.assertEqual(preview_response.status_code, 200) - preview = preview_response.json() - self.assertEqual(preview["project_id"], "phase1-demo") - self.assertEqual(preview["accepted_slice_count"], 0) - def test_missing_project_route_returns_404(self) -> None: - response = self.client.get("/api/projects/missing-project/export-preview") + response = self.client.get("/api/projects/missing-project") self.assertEqual(response.status_code, 404) self.assertEqual(response.json()["detail"], "Project not found") diff --git a/backend/tests/test_clip_lab_routes.py b/backend/tests/test_clip_lab_routes.py index 605daa4..2853ab9 100644 --- a/backend/tests/test_clip_lab_routes.py +++ b/backend/tests/test_clip_lab_routes.py @@ -12,14 +12,29 @@ import numpy as np +from fastapi import HTTPException from fastapi.testclient import TestClient from sqlmodel import Session from app.clip_lab_audio import MAX_INSERT_SILENCE_SEC -from app.clip_lab_state import ClipLabStateError, compute_content_hash, compute_manifest_sha256 +from app.clip_lab_state import ( + ClipLabStateError, + compute_content_hash, + compute_manifest_sha256, + save_clip_lab_state, +) from app.dataset_runs import create_dataset_run -from app.main import app, patch_dataset_clip_lab_route, read_dataset_clip_lab +from app.main import ( + app, + patch_dataset_clip_lab_route, + post_dataset_clip_audio_operation_route, + post_canonical_export, + read_canonical_exports, + read_canonical_export_preview, + read_dataset_clip_lab, +) from app.models import ( + DatasetClipLabAudioOperationRequest, DatasetClipLabClipView, DatasetClipLabPatchRequest, DatasetClipLabPipelineFindingView, @@ -178,6 +193,405 @@ def test_get_clip_lab_returns_manifest_clips(self) -> None: self.assertTrue(payload["qc_available"]) self.assertEqual(len(payload["clips"]), 4) + def test_canonical_export_preview_allows_zero_accepted(self) -> None: + with self.repo(): + response = read_canonical_export_preview(self.run.id) + + self.assertEqual(response.accepted_clip_count, 0) + self.assertEqual(response.blocked_clip_count, 0) + + def test_create_canonical_export_rejects_zero_accepted(self) -> None: + with self.repo(): + with self.assertRaises(HTTPException) as exc_info: + post_canonical_export(self.run.id) + self.assertEqual(exc_info.exception.status_code, 409) + + def test_create_canonical_export_writes_manifest_snapshot(self) -> None: + with self.repo(): + accept_response = patch_dataset_clip_lab_route( + self.run.id, + "candidate_review_clip_000001", + DatasetClipLabPatchRequest( + expected_manifest_sha256=self.manifest_sha, + expected_clip_version=0, + review_status="accepted", + transcript_override="Final transcript.", + reviewer_tags=["good energy"], + ), + ) + self.assertEqual(accept_response.review_status, "accepted") + + export_response = post_canonical_export(self.run.id) + + export_dir = Path(export_response.snapshot_dir) + self.assertTrue((export_dir / "speechcraft_dataset.jsonl").is_file()) + self.assertTrue((export_dir / "speechcraft_export.json").is_file()) + self.assertTrue((export_dir / "export_report.json").is_file()) + + rows = [ + json.loads(line) + for line in (export_dir / "speechcraft_dataset.jsonl").read_text(encoding="utf-8").splitlines() + if line.strip() + ] + self.assertEqual(len(rows), 1) + row = rows[0] + self.assertEqual(row["schema_version"], 1) + self.assertEqual(row["clip_id"], "candidate_review_clip_000001") + self.assertEqual(row["transcript"], "Final transcript.") + self.assertEqual(row["lineage"]["source_clip_id"], "candidate_review_clip_000001") + self.assertEqual(row["lineage"]["parent_clip_ids"], []) + self.assertEqual(row["review"]["status"], "accepted") + self.assertEqual(row["review"]["reviewer_tags"], ["good energy"]) + self.assertEqual(row["audio"]["kind"], "candidate_original") + self.assertEqual(row["audio"]["source_audio_sha256"], row["audio"]["sha256"]) + self.assertIn("candidate_review_clips/candidate_review_clip_000001.wav", row["audio"]["path"]) + metadata = json.loads((export_dir / "speechcraft_export.json").read_text(encoding="utf-8")) + self.assertEqual(metadata["path_mode"], "snapshot_relative") + self.assertEqual(metadata["audio_storage_mode"], "project_artifact_reference") + self.assertFalse(metadata["portable"]) + self.assertEqual(export_dir.parent, self.run_root / "artifacts" / "canonical_exports") + + def test_create_canonical_export_uses_rendered_revision_for_accepted_audio_edit(self) -> None: + with self.repo(): + edited = post_dataset_clip_audio_operation_route( + self.run.id, + "candidate_review_clip_000001", + DatasetClipLabAudioOperationRequest( + expected_manifest_sha256=self.manifest_sha, + expected_clip_version=0, + operation={ + "kind": "insert_silence", + "at_sample": 10, + "duration_samples": 16, + }, + ), + ) + self.assertEqual(edited.effective_audio_kind, "rendered_revision") + accepted = patch_dataset_clip_lab_route( + self.run.id, + "candidate_review_clip_000001", + DatasetClipLabPatchRequest( + expected_manifest_sha256=self.manifest_sha, + expected_clip_version=edited.clip_version, + review_status="accepted", + ), + ) + self.assertEqual(accepted.review_status, "accepted") + + export_response = post_canonical_export(self.run.id) + + rows = [ + json.loads(line) + for line in (Path(export_response.snapshot_dir) / "speechcraft_dataset.jsonl") + .read_text(encoding="utf-8") + .splitlines() + if line.strip() + ] + row = rows[0] + self.assertEqual(row["audio"]["kind"], "rendered_revision") + self.assertIn("clip_lab_renders", row["audio"]["path"]) + self.assertIsInstance(row["audio"]["audio_revision_hash"], str) + + def test_canonical_export_preview_blocks_pending_rendered_audio(self) -> None: + manifest = json.loads((self.run_root / "artifacts" / "candidate_review_manifest.json").read_text(encoding="utf-8")) + clip_row = next(row for row in manifest if row["id"] == "candidate_review_clip_000001") + source_sha = str(clip_row["audio_sha256"]) + audio_revision_hash = "ab" * 32 + accepted_hash = compute_content_hash( + manifest_transcript=str(clip_row["training_text"]), + transcript_override=None, + audio_revision_hash=audio_revision_hash, + base_audio_hash=source_sha, + ) + save_clip_lab_state( + self.run_root, + { + "schema_version": 1, + "stage": "clip_lab_state", + "candidate_manifest_sha256": self.manifest_sha, + "updated_at": "2026-07-08T00:00:00Z", + "clips": { + "candidate_review_clip_000001": { + "clip_version": 1, + "review_status": "accepted", + "accepted_content_hash": accepted_hash, + "accepted_at": "2026-07-08T00:00:00Z", + "reviewer_tags": [], + "updated_at": "2026-07-08T00:00:00Z", + "audio_edit": { + "schema_version": 1, + "source_audio_sha256": source_sha, + "source_sample_rate_hz": SAMPLE_RATE, + "ops": [{"kind": "insert_silence", "at_sample": 10, "duration_samples": 16}], + "redo_ops": [], + "audio_revision_hash": audio_revision_hash, + "rendered_audio_sha256": None, + "render_status": "pending", + }, + } + }, + }, + ) + + with self.repo(): + preview = read_canonical_export_preview(self.run.id) + + self.assertEqual(preview.accepted_clip_count, 1) + self.assertEqual(preview.blocked_clip_count, 1) + self.assertIn("rendered_audio_not_ready", preview.blocked_reasons[0].reasons) + + def test_canonical_export_preview_blocks_failed_rendered_audio(self) -> None: + manifest = json.loads((self.run_root / "artifacts" / "candidate_review_manifest.json").read_text(encoding="utf-8")) + clip_row = next(row for row in manifest if row["id"] == "candidate_review_clip_000001") + source_sha = str(clip_row["audio_sha256"]) + audio_revision_hash = "cd" * 32 + accepted_hash = compute_content_hash( + manifest_transcript=str(clip_row["training_text"]), + transcript_override=None, + audio_revision_hash=audio_revision_hash, + base_audio_hash=source_sha, + ) + save_clip_lab_state( + self.run_root, + { + "schema_version": 1, + "stage": "clip_lab_state", + "candidate_manifest_sha256": self.manifest_sha, + "updated_at": "2026-07-08T00:00:00Z", + "clips": { + "candidate_review_clip_000001": { + "clip_version": 1, + "review_status": "accepted", + "accepted_content_hash": accepted_hash, + "accepted_at": "2026-07-08T00:00:00Z", + "reviewer_tags": [], + "updated_at": "2026-07-08T00:00:00Z", + "audio_edit": { + "schema_version": 1, + "source_audio_sha256": source_sha, + "source_sample_rate_hz": SAMPLE_RATE, + "ops": [{"kind": "insert_silence", "at_sample": 10, "duration_samples": 16}], + "redo_ops": [], + "audio_revision_hash": audio_revision_hash, + "rendered_audio_sha256": None, + "render_status": "failed", + }, + } + }, + }, + ) + + with self.repo(): + preview = read_canonical_export_preview(self.run.id) + + self.assertEqual(preview.accepted_clip_count, 1) + self.assertEqual(preview.blocked_clip_count, 1) + self.assertIn("rendered_audio_not_ready", preview.blocked_reasons[0].reasons) + + def test_canonical_export_preview_blocks_missing_rendered_wav(self) -> None: + manifest = json.loads((self.run_root / "artifacts" / "candidate_review_manifest.json").read_text(encoding="utf-8")) + clip_row = next(row for row in manifest if row["id"] == "candidate_review_clip_000001") + source_sha = str(clip_row["audio_sha256"]) + audio_revision_hash = "ef" * 32 + rendered_sha = "11" * 32 + accepted_hash = compute_content_hash( + manifest_transcript=str(clip_row["training_text"]), + transcript_override=None, + audio_revision_hash=audio_revision_hash, + base_audio_hash=source_sha, + ) + save_clip_lab_state( + self.run_root, + { + "schema_version": 1, + "stage": "clip_lab_state", + "candidate_manifest_sha256": self.manifest_sha, + "updated_at": "2026-07-08T00:00:00Z", + "clips": { + "candidate_review_clip_000001": { + "clip_version": 1, + "review_status": "accepted", + "accepted_content_hash": accepted_hash, + "accepted_at": "2026-07-08T00:00:00Z", + "reviewer_tags": [], + "updated_at": "2026-07-08T00:00:00Z", + "audio_edit": { + "schema_version": 1, + "source_audio_sha256": source_sha, + "source_sample_rate_hz": SAMPLE_RATE, + "ops": [{"kind": "insert_silence", "at_sample": 10, "duration_samples": 16}], + "redo_ops": [], + "audio_revision_hash": audio_revision_hash, + "rendered_audio_sha256": rendered_sha, + "render_status": "ready", + }, + } + }, + }, + ) + + with self.repo(): + preview = read_canonical_export_preview(self.run.id) + + self.assertEqual(preview.blocked_clip_count, 1) + self.assertIn("rendered_audio_missing", preview.blocked_reasons[0].reasons) + + def test_canonical_export_preview_blocks_rendered_hash_mismatch(self) -> None: + manifest = json.loads((self.run_root / "artifacts" / "candidate_review_manifest.json").read_text(encoding="utf-8")) + clip_row = next(row for row in manifest if row["id"] == "candidate_review_clip_000001") + source_sha = str(clip_row["audio_sha256"]) + audio_revision_hash = "12" * 32 + accepted_hash = compute_content_hash( + manifest_transcript=str(clip_row["training_text"]), + transcript_override=None, + audio_revision_hash=audio_revision_hash, + base_audio_hash=source_sha, + ) + render_path = self.run_root / "artifacts" / "clip_lab_renders" / "candidate_review_clip_000001" / f"{audio_revision_hash}.wav" + render_path.parent.mkdir(parents=True, exist_ok=True) + render_path.write_bytes(b"not-the-expected-hash") + save_clip_lab_state( + self.run_root, + { + "schema_version": 1, + "stage": "clip_lab_state", + "candidate_manifest_sha256": self.manifest_sha, + "updated_at": "2026-07-08T00:00:00Z", + "clips": { + "candidate_review_clip_000001": { + "clip_version": 1, + "review_status": "accepted", + "accepted_content_hash": accepted_hash, + "accepted_at": "2026-07-08T00:00:00Z", + "reviewer_tags": [], + "updated_at": "2026-07-08T00:00:00Z", + "audio_edit": { + "schema_version": 1, + "source_audio_sha256": source_sha, + "source_sample_rate_hz": SAMPLE_RATE, + "ops": [{"kind": "insert_silence", "at_sample": 10, "duration_samples": 16}], + "redo_ops": [], + "audio_revision_hash": audio_revision_hash, + "rendered_audio_sha256": "22" * 32, + "render_status": "ready", + }, + } + }, + }, + ) + + with self.repo(): + preview = read_canonical_export_preview(self.run.id) + + self.assertEqual(preview.blocked_clip_count, 1) + self.assertIn("rendered_audio_hash_mismatch", preview.blocked_reasons[0].reasons) + + def test_canonical_export_preview_blocks_candidate_hash_mismatch(self) -> None: + with self.repo(): + accepted = patch_dataset_clip_lab_route( + self.run.id, + "candidate_review_clip_000001", + DatasetClipLabPatchRequest( + expected_manifest_sha256=self.manifest_sha, + expected_clip_version=0, + review_status="accepted", + ), + ) + self.assertEqual(accepted.review_status, "accepted") + + candidate_wav = self.run_root / "artifacts" / "candidate_review_clips" / "candidate_review_clip_000001.wav" + candidate_wav.write_bytes(b"mutated-audio") + + with self.repo(): + preview = read_canonical_export_preview(self.run.id) + + self.assertEqual(preview.accepted_clip_count, 1) + self.assertEqual(preview.blocked_clip_count, 1) + self.assertIn("candidate_audio_hash_mismatch", preview.blocked_reasons[0].reasons) + + def test_canonical_export_preview_rejects_stale_state(self) -> None: + save_clip_lab_state( + self.run_root, + { + "schema_version": 1, + "stage": "clip_lab_state", + "candidate_manifest_sha256": "stale-manifest-sha", + "updated_at": "2026-07-08T00:00:00Z", + "clips": {}, + }, + ) + + with self.repo(): + with self.assertRaises(HTTPException) as exc_info: + read_canonical_export_preview(self.run.id) + self.assertEqual(exc_info.exception.status_code, 409) + + def test_canonical_export_create_rejects_invalid_state(self) -> None: + save_clip_lab_state( + self.run_root, + { + "schema_version": 1, + "stage": "clip_lab_state", + "candidate_manifest_sha256": self.manifest_sha, + "updated_at": "2026-07-08T00:00:00Z", + "clips": { + "candidate_review_clip_000001": { + "clip_version": 1, + "review_status": "accepted", + "reviewer_tags": ["Accepted"], + } + }, + }, + ) + + with self.repo(): + with self.assertRaises(HTTPException) as exc_info: + post_canonical_export(self.run.id) + self.assertEqual(exc_info.exception.status_code, 409) + + def test_list_canonical_exports_returns_newest_first_and_skips_corrupt_dirs(self) -> None: + exports_root = self.run_root / "artifacts" / "canonical_exports" + older = exports_root / "canonical_export_2026-07-08_010101_aaaaaaaa" + newer = exports_root / "canonical_export_2026-07-08_020202_bbbbbbbb" + corrupt = exports_root / "canonical_export_corrupt" + older.mkdir(parents=True, exist_ok=True) + newer.mkdir(parents=True, exist_ok=True) + corrupt.mkdir(parents=True, exist_ok=True) + (older / "speechcraft_export.json").write_text( + json.dumps( + { + "schema_version": 1, + "export_id": older.name, + "run_id": self.run.id, + "project_id": "project-1", + "created_at": "2026-07-08T01:01:01Z", + "accepted_clip_count": 2, + "total_duration_sec": 12.5, + } + ), + encoding="utf-8", + ) + (newer / "speechcraft_export.json").write_text( + json.dumps( + { + "schema_version": 1, + "export_id": newer.name, + "run_id": self.run.id, + "project_id": "project-1", + "created_at": "2026-07-08T02:02:02Z", + "accepted_clip_count": 3, + "total_duration_sec": 18.0, + } + ), + encoding="utf-8", + ) + (corrupt / "speechcraft_export.json").write_text("{bad json", encoding="utf-8") + + with self.repo(): + exports = read_canonical_exports(self.run.id) + + self.assertEqual([item.export_id for item in exports], [newer.name, older.name]) + def test_patch_transcript_override_then_get(self) -> None: with self.repo(): patch_response = self.client.patch( diff --git a/backend/tests/test_dataset_run_routes.py b/backend/tests/test_dataset_run_routes.py index 8d81639..411b250 100644 --- a/backend/tests/test_dataset_run_routes.py +++ b/backend/tests/test_dataset_run_routes.py @@ -114,12 +114,10 @@ def test_create_list_get_and_log_routes_serialize(self) -> None: self.assertEqual(log.text, "worker output") self.assertTrue(create.call_args.args[2].single_speaker) - def test_create_route_surfaces_multi_speaker_validation_errors(self) -> None: + def test_create_route_surfaces_validation_errors(self) -> None: with patch( "app.main.create_dataset_run", - side_effect=ValueError( - "Multi-speaker diarization currently supports exactly one source WAV per run" - ), + side_effect=ValueError("Dataset run requires at least one source recording"), ): with self.assertRaises(HTTPException) as exc: create_project_dataset_run( @@ -128,7 +126,7 @@ def test_create_route_surfaces_multi_speaker_validation_errors(self) -> None: ) self.assertEqual(exc.exception.status_code, 400) - self.assertIn("exactly one source WAV", str(exc.exception.detail)) + self.assertIn("at least one source recording", str(exc.exception.detail)) def test_slicer_rerun_and_results_routes_serialize(self) -> None: run = run_view() diff --git a/backend/tests/test_dataset_runs.py b/backend/tests/test_dataset_runs.py index 5f3ae32..9fd1309 100644 --- a/backend/tests/test_dataset_runs.py +++ b/backend/tests/test_dataset_runs.py @@ -235,12 +235,17 @@ def test_create_run_rejects_multi_speaker_multi_source_until_cross_file_identity ) session.commit() - with self.assertRaisesRegex(ValueError, "exactly one source WAV"): - create_dataset_run( - self.repository, - "project-1", - DatasetRunCreateRequest(single_speaker=False, source_recording_ids=["recording-1", "recording-2"]), - ) + # Option C: multi-file diarization is supported (worker concatenates the + # per-source analysis variants, diarizes once, remaps regions per file). + run = create_dataset_run( + self.repository, + "project-1", + DatasetRunCreateRequest(single_speaker=False, source_recording_ids=["recording-1", "recording-2"]), + ) + self.assertFalse(run.input_summary["single_speaker"]) + self.assertEqual( + run.input_summary["source_recording_ids"], ["recording-1", "recording-2"] + ) def test_refresh_indexes_rejected_manifest_and_completed_status(self) -> None: run = create_dataset_run(self.repository, "project-1", DatasetRunCreateRequest()) diff --git a/backend/tests/test_repository_media.py b/backend/tests/test_repository_media.py index 8214994..6651d2c 100644 --- a/backend/tests/test_repository_media.py +++ b/backend/tests/test_repository_media.py @@ -278,7 +278,6 @@ def test_legacy_seed_imports_all_projects_without_source_collisions(self) -> Non "id": "project-a", "name": "Project A", "status": "ready", - "export_status": "idle", "created_at": "2026-03-18T15:16:30Z", "updated_at": "2026-03-18T15:16:30Z", }, @@ -286,7 +285,6 @@ def test_legacy_seed_imports_all_projects_without_source_collisions(self) -> Non "id": "project-b", "name": "Project B", "status": "ready", - "export_status": "idle", "created_at": "2026-03-18T15:16:30Z", "updated_at": "2026-03-18T15:16:30Z", }, @@ -336,7 +334,6 @@ def test_seed_from_legacy_json_uses_real_audio_for_one_to_one_source_recordings( "id": "project-a", "name": "Project A", "status": "ready", - "export_status": "idle", "created_at": "2026-03-18T15:16:30Z", "updated_at": "2026-03-18T15:16:30Z", } @@ -387,7 +384,6 @@ def test_migrate_legacy_seed_source_recordings_backfills_real_audio(self) -> Non "id": "project-a", "name": "Project A", "status": "ready", - "export_status": "idle", "created_at": "2026-03-18T15:16:30Z", "updated_at": "2026-03-18T15:16:30Z", } diff --git a/frontend/bun.lock b/frontend/bun.lock index 4df5d3c..b94b34e 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -3,144 +3,617 @@ "configVersion": 1, "workspaces": { "": { - "name": "speechcraft-frontend", + "name": "speechcraft-web", "dependencies": { - "@wavesurfer/react": "^1.0.12", - "lucide-react": "^0.577.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "@radix-ui/react-accordion": "^1.2.11", + "@radix-ui/react-alert-dialog": "^1.1.14", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.15", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", + "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-navigation-menu": "^1.2.13", + "@radix-ui/react-popover": "^1.1.14", + "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-radio-group": "^1.3.7", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.5", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.5", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toast": "^1.2.14", + "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-query": "^5.95.2", + "@tanstack/react-virtual": "^3.13.23", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "1.1.1", + "date-fns": "^4.1.0", + "embla-carousel-react": "^8.6.0", + "framer-motion": "^12.34.0", + "input-otp": "^1.4.2", + "lucide-react": "^0.562.0", + "next": "16.2.1", + "next-themes": "^0.4.6", + "nuqs": "^2.8.9", + "react": "^19.2.0", + "react-day-picker": "9.8.0", + "react-dom": "^19.2.0", + "react-dropzone": "^15.0.0", + "react-hotkeys-hook": "^5.2.4", + "react-icons": "^5.5.0", + "react-number-format": "^5.4.4", + "recharts": "^3.6.0", + "tailwind-merge": "3.4.0", + "vaul": "^1.1.2", "wavesurfer.js": "^7.12.1", }, "devDependencies": { - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^6.0.1", - "typescript": "^5.6.3", - "vite": "^8.0.1", + "@types/node": "^22.10.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "autoprefixer": "^10.4.20", + "bun-types": "1.3.0", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.13", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.7.0", }, }, }, "packages": { - "@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], - "@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], + "@date-fns/tz": ["@date-fns/tz@1.2.0", "", {}, "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], - "@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="], + "@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.10", "", { "os": "android", "cpu": "arm64" }, "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg=="], + "@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=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w=="], + "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm" }, "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA=="], + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg=="], + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g=="], + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w=="], + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg=="], + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw=="], + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA=="], + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.10", "", { "os": "none", "cpu": "arm64" }, "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q=="], + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.10", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA=="], + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ=="], + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.10", "", { "os": "win32", "cpu": "x64" }, "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w=="], + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="], + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], - "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], - "@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="], + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], - "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], - "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], - "@wavesurfer/react": ["@wavesurfer/react@1.0.12", "", { "peerDependencies": { "react": "^18.2.0 || ^19.0.0", "wavesurfer.js": ">=7.7.14" } }, "sha512-BNHpz2ryKNVvJdxB47pCPUsNCsjb2pZRysg82M5djIiw0vsiSJwdlt5jaAfDo3vd5IWrcoK9OiPQKO9ZEVNpDQ=="], + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@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=="], + + "@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/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@next/env": ["@next/env@16.2.1", "", {}, "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg=="], + + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q=="], + + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA=="], + + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw=="], + + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ=="], + + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg=="], + + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg=="], + + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA=="], + + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@radix-ui/number": ["@radix-ui/number@1.1.2", "", {}, "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig=="], + + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.5", "", {}, "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg=="], + + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-collapsible": "1.1.16", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-BpZJNmetujnGgUI6OX0jEhEmlA46WPqgub8Rv09Kyquwd0cc1ndMKpiPYCjmBU6KSSRPAMtgLpEoZSG/tdNIWQ=="], + + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-dialog": "1.1.19", "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FA7n1f6D/DwGE0+AWxiY5LacNbbExQuEgMubeG06idEaH+mSLuf9dp/qBNqOnvbTQ+4gZ2ue1RATF1Ub91Mg5g=="], + + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A=="], + + "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.2.2", "", { "dependencies": { "@radix-ui/react-context": "1.2.0", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-is-hydrated": "0.1.1", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sST0qh8GzOB7besQ3tMLWLyngnRuSk0gc/Hm+667KYKQFCt6Y6ZXv25WlqM7dIDK54ULCh5+CHmk4LIolzfz+A=="], + + "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JroKHfQBfh+fDuzpPsBC+pESkhuq8ql4hljTguz8MWnS35cISr3d/Jhl9kYrB44FlDtxCArYdDvTx+BSsJ64rQ=="], + + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-opfXRe6nnzyGmCDPx+l1Aqo/RbqWtQal2FnsBqF9hhePp6j0LsRoBaRxcMOlTv+uYTJVtWYZKg9t9wTe+BA/ZA=="], + + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.12", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA=="], + + "@radix-ui/react-context": ["@radix-ui/react-context@1.2.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg=="], + + "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-menu": "2.1.20", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-PS+gKE0z2prJ74Y0sM+brAGK4mYOHIR7TlcV5EJgUQ6E0xMvyswkK2X4yRqyganrzsRL+WCSKAPu0NQITICRWg=="], + + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.12", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg=="], + + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA=="], + + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-effect-event": "0.0.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA=="], + + "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.20", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-menu": "2.1.20", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-slfm+rRaZRuQBvHq60lXvSVUPhid0IPtjSZzIuUlWZMUs01iYZNlGS3mJgRD3ChLQVBAYlKiL/tFyWGX+dz8Xw=="], + + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q=="], + + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.12", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A=="], + + "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-popper": "1.3.3", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-2KTgMLQtKvicznQgbindEI2RZ3QbDIwU5gabjUPwFJsormjGDz+rUvO4NANmYwzEEpTcTONUt33vBHIfTIVSfw=="], + + "@radix-ui/react-icons": ["@radix-ui/react-icons@1.3.2", "", { "peerDependencies": { "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" } }, "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA=="], + + "@radix-ui/react-label": ["@radix-ui/react-label@2.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3PKvDDxOn62k0oV1n4QtNtD2vpu+zYjXR7ojLBPaO6SPvhy53yg0vAmgNeBQeJW5rV3dffoRG+HYfLBZuzw0CQ=="], + + "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.20", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.12", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.3", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-roving-focus": "1.1.15", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-callback-ref": "1.1.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VsUrXxFe9d2ScbZF0fR/oPR1+qjyeLs5p0jzG8h90puMoA9bq4SirYlXbE+USRg9Q2qTeJSFNqjw2nts8jJe4w=="], + + "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-K9HiuxZ6xCwSaHcIuUpxyhy4w5gpwzWjh9dHTSbMN3Ix4qAyVObS9RlU3zMycb0PO3v9Tpk0BXMwWvXOUbVXew=="], + + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.12", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.3", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jkrTdQVxnIB8fpn0NyyxW9CTB5aCXZZelVz5z+Xmii6g5WxMqS3fInNslZ63puP39+Puu4jYohUK31y3dT87gQ=="], + + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.3", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-rect": "1.1.2", "@radix-ui/react-use-size": "1.1.2", "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mS7dGpyjv6b+gsDjLF7e0ia1W4Im1B1hSCy2yuXlHuvnZxHKagfDaobt/KAKt27EpZMit2pss8eJBVyVjEWM+g=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.13", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.7", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.7", "", { "dependencies": { "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ=="], + + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.12", "", { "dependencies": { "@radix-ui/react-context": "1.2.0", "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ZPHyI0JyzoH/rP0tq2uRaIZTj/4s8+kAbqPz+e2N8+ejHvwPJ889dHhqn+vh7PNvNeq+boAoH9yzqeoShzwF2w=="], + + "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-roving-focus": "1.1.15", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-WwZFjWV4s3aC1QtR3k04R+oANHtX2q6fgKlc7MCEiDNlnTxCZ3H8k3mHtEgVlOejystwk1WQgarQhNOQZ2bK1g=="], + + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-is-hydrated": "0.1.1", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-40svmmugfM3mUN7VUDGVE1tQGOhyi8enlGD0CNJEcMM36C1f71PKM21DFgNHUfem0XnA+d8H8oN3Z9ZpJjSslg=="], + + "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.14", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bBODCWZK7JTbQLHs0uIP4f73wIWatakK4OS33UzkR1x897wu0PuO658a3f+6P2GEGyDzGYMuHRatMVoAk9WZTw=="], + + "@radix-ui/react-select": ["@radix-ui/react-select@2.3.3", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.5", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.12", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.3", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.7", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-L5RQTXz6Anxsf9CCv+pTgiAsUpyVj7rJxsGtmhFaEOJ++cVfXucv4qWfsIO0AIB4NAhi3yovWGVMKKS1Xf1Wrg=="], + + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jRhe86+8PF7VZ1u14eOWVOuh2BuAhALg/FT1VcMC4OHedMTRUazDnDlKTt+yxo5cRNKHMfmvZ4sSQtWDeMV4CQ=="], + + "@radix-ui/react-slider": ["@radix-ui/react-slider@1.4.3", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.5", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-CWVVj+XaTom0SKCqw1EUgb0NuiLwS+N3OFG73mVEezKEjgNIvZiu0EevMelSSU+CbX3owbqJweG2gPU31WGC5A=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], + + "@radix-ui/react-switch": ["@radix-ui/react-switch@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1+mlB4/lxJfk5tgJ4g+R5mUCbRpPE1T9+UsEyeLYbGgMtwiMgmuTnfKz4Mw1nHALHjuwyxw4MLd4cSHn6pNSlQ=="], + + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-roving-focus": "1.1.15", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-nRyXnrAVCwjeXcHbvEbLS6ndbTeKHG1RqCP4A8Gw5L4cemDzPXdD8rAmr6wet0v57R69wGvuIIsFjHSVkZiMzQ=="], + + "@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-collection": "1.1.12", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-SxfVZfVOibWKWdkf0Xx1awW2d09fQu4V4PXDY1j5hi4MVf7MWdJZqTBJMa1KWtOr1S6GGtCk02nniZ0Iia+dHw=="], + + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.3", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-visually-hidden": "1.2.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-U3HoftgWnmla78vzQbLvKKb7bUYJxoiiqYFzp1wu/TBMyDqMZSuCl3aRICsD6EfVEwcJD2mumGDGUXLFVqQHKA=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.3", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.3", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA=="], + + "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="], + + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw=="], + + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.2", "", { "dependencies": { "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw=="], + + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w=="], + + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1wNZBggTDK3GRuuQ6nP4k2yi7a6l7I5qbMPbZcRsrGsGVead/f/d5FhEzUvqFs0bcrDLx7n1zKQ3JvLR6whaaw=="], + + "@radix-ui/rect": ["@radix-ui/rect@1.1.2", "", {}, "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA=="], + + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.12.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], + + "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + + "@tanstack/query-core": ["@tanstack/query-core@5.101.2", "", {}, "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.101.2", "", { "dependencies": { "@tanstack/query-core": "5.101.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg=="], + + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.5", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A=="], + + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="], + + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + + "@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], + + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + + "attr-accept": ["attr-accept@2.2.5", "", {}, "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ=="], + + "autoprefixer": ["autoprefixer@10.5.2", "", { "dependencies": { "browserslist": "^4.28.4", "caniuse-lite": "^1.0.30001799", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.43", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ=="], + + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.28.6", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", "electron-to-chromium": "^1.5.389", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw=="], + + "bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="], + + "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001805", "", {}, "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA=="], + + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], + + "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], + + "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], + + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + + "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], + + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.389", "", {}, "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg=="], + + "embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="], + + "embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="], + + "embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-toolkit": ["es-toolkit@1.49.0", "", {}, "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "file-selector": ["file-selector@2.1.2", "", { "dependencies": { "tslib": "^2.7.0" } }, "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], + + "framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], - "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=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + "immer": ["immer@11.1.11", "", {}, "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw=="], - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + "input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="], - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], - "lucide-react": ["lucide-react@0.577.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A=="], + "lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "motion-dom": ["motion-dom@12.42.2", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA=="], + + "motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], + + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + + "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], + + "next": ["next@16.2.1", "", { "dependencies": { "@next/env": "16.2.1", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.1", "@next/swc-darwin-x64": "16.2.1", "@next/swc-linux-arm64-gnu": "16.2.1", "@next/swc-linux-arm64-musl": "16.2.1", "@next/swc-linux-x64-gnu": "16.2.1", "@next/swc-linux-x64-musl": "16.2.1", "@next/swc-win32-arm64-msvc": "16.2.1", "@next/swc-win32-x64-msvc": "16.2.1", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q=="], + + "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], + + "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "nuqs": ["nuqs@2.9.0", "", { "dependencies": { "@standard-schema/spec": "1.0.0" }, "peerDependencies": { "@remix-run/react": ">=2", "@tanstack/react-router": "^1", "next": ">=14.2.0", "react": ">=18.2.0 || ^19.0.0-0", "react-router": "^5 || ^6 || ^7 || ^8", "react-router-dom": "^5 || ^6 || ^7" }, "optionalPeers": ["@remix-run/react", "@tanstack/react-router", "next", "react-router", "react-router-dom"] }, "sha512-1ckQBhVHaQYjLZ3kWhhH40A32lPJ7TNTQMa2T+SUvl5azyVt7swCQfUXt9xOXJV3YidWq8VHIHcMzVAJ0knRsw=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], - "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], - "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + "postcss": ["postcss@8.5.17", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w=="], - "rolldown": ["rolldown@1.0.0-rc.10", "", { "dependencies": { "@oxc-project/types": "=0.120.0", "@rolldown/pluginutils": "1.0.0-rc.10" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-x64": "1.0.0-rc.10", "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA=="], + "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], - "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + "postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="], + + "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], + + "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="], + + "postcss-selector-parser": ["postcss-selector-parser@6.1.4", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + + "react-day-picker": ["react-day-picker@9.8.0", "", { "dependencies": { "@date-fns/tz": "1.2.0", "date-fns": "4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-E0yhhg7R+pdgbl/2toTb0xBhsEAtmAx1l7qjIWYfcxOy8w4rTSVfbtBoSzVVhPwKP/5E9iL38LivzoE3AQDhCQ=="], + + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + + "react-dropzone": ["react-dropzone@15.0.0", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg=="], + + "react-hotkeys-hook": ["react-hotkeys-hook@5.3.3", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-aswgyWUnE25hmhzHTfKDmKzsaSE5DJ4LKaU/o6rQSXkDd/1Bh9TfAFQbHkf6fLy11HvlYkp+cDDarGdhmCDhoQ=="], + + "react-icons": ["react-icons@5.7.0", "", { "peerDependencies": { "react": "*" } }, "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw=="], + + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "react-number-format": ["react-number-format@5.4.5", "", { "peerDependencies": { "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw=="], + + "react-redux": ["react-redux@9.3.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g=="], + + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], + + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + + "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], + + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + + "recharts": ["recharts@3.9.2", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^11.1.8", "react-redux": "8.x.x || 9.x.x", "reselect": "5.2.0", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw=="], + + "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], + + "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + + "reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="], + + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "@babel/core": "*", "babel-plugin-macros": "*", "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" }, "optionalPeers": ["@babel/core", "babel-plugin-macros"] }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + + "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], + + "tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], + + "tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="], + + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], + + "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "vite": ["vite@8.0.1", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.10", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "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=="], + + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="], + + "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], + + "wavesurfer.js": ["wavesurfer.js@7.12.10", "", {}, "sha512-M3AC5biFmvfy7Oxe8FN6I88H0z1c8Vuz81N6Oq2CY4kOumNDb6U1/0qg4pomjP619vJqdWADlgUcpOKvfGifng=="], + + "@reduxjs/toolkit/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], - "wavesurfer.js": ["wavesurfer.js@7.12.4", "", {}, "sha512-b/+XnWfJejNdvNUmtm4M5QzQepHhUbTo+62wYybwdV1B/Sn9vHhgb1xckRm0rGY2ZefJwLkE7lYcKnLfIia4cQ=="], + "react-day-picker/date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], - "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.10", "", {}, "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="], + "tinyglobby/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], } } diff --git a/frontend/index.html b/frontend/index.html deleted file mode 100644 index fe2073e..0000000 --- a/frontend/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - Speechcraft - - -
- - - - diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 0000000..b6ab13b --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,22 @@ +import type { NextConfig } from "next"; + +// Same-origin proxy to speechcraft's local FastAPI backend so the browser +// hits /sc-api/* (no CORS) and audio range requests pass through cleanly. +const speechcraftBackend = + process.env.SPEECHCRAFT_BACKEND_URL || "http://127.0.0.1:8010"; + +const nextConfig: NextConfig = { + reactStrictMode: true, + // Local single-user tool: keep builds unblocked while we iterate. + typescript: { ignoreBuildErrors: true }, + async rewrites() { + return [ + { + source: "/sc-api/:path*", + destination: `${speechcraftBackend}/:path*`, + }, + ]; + }, +}; + +export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json deleted file mode 100644 index 27ec6b4..0000000 --- a/frontend/package-lock.json +++ /dev/null @@ -1,2196 +0,0 @@ -{ - "name": "speechcraft-frontend", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "speechcraft-frontend", - "version": "0.1.0", - "dependencies": { - "@wavesurfer/react": "^1.0.12", - "lucide-react": "^0.577.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "wavesurfer.js": "^7.12.1" - }, - "devDependencies": { - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.2", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^6.0.1", - "jsdom": "^29.0.1", - "typescript": "^5.6.3", - "vite": "^8.0.1", - "vitest": "^4.1.0" - } - }, - "node_modules/@adobe/css-tools": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", - "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@asamuzakjp/css-color": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", - "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@csstools/css-calc": "^3.1.1", - "@csstools/css-color-parser": "^4.0.2", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.2.6" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz", - "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.7" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bramus/specificity": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", - "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "css-tree": "^3.0.0" - }, - "bin": { - "specificity": "bin/cli.js" - } - }, - "node_modules/@csstools/color-helpers": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", - "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@csstools/css-calc": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", - "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", - "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.1.1" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", - "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "peer": true, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", - "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "peerDependencies": { - "css-tree": "^3.2.1" - }, - "peerDependenciesMeta": { - "css-tree": { - "optional": true - } - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", - "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "peer": true, - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@exodus/bytes": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", - "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.120.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz", - "integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz", - "integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz", - "integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz", - "integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz", - "integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz", - "integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz", - "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz", - "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz", - "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.7", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", - "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" - }, - "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" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.3.28", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.3.7", - "dev": true, - "license": "MIT", - "peer": true, - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", - "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.7" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "vite": "^8.0.0" - }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - } - } - }, - "node_modules/@vitest/expect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", - "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.0", - "@vitest/utils": "4.1.0", - "chai": "^6.2.2", - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", - "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.0", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", - "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", - "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.0", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", - "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.0", - "@vitest/utils": "4.1.0", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", - "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", - "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.0", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@wavesurfer/react": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@wavesurfer/react/-/react-1.0.12.tgz", - "integrity": "sha512-BNHpz2ryKNVvJdxB47pCPUsNCsjb2pZRysg82M5djIiw0vsiSJwdlt5jaAfDo3vd5IWrcoK9OiPQKO9ZEVNpDQ==", - "license": "BSD-3-Clause", - "peerDependencies": { - "react": "^18.2.0 || ^19.0.0", - "wavesurfer.js": ">=7.7.14" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" - } - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true, - "license": "MIT" - }, - "node_modules/csstype": { - "version": "3.2.3", - "dev": true, - "license": "MIT" - }, - "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT" - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT" - }, - "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "dev": true, - "license": "MIT" - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "license": "MIT" - }, - "node_modules/jsdom": { - "version": "29.0.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", - "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@asamuzakjp/css-color": "^5.0.1", - "@asamuzakjp/dom-selector": "^7.0.3", - "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.1", - "@exodus/bytes": "^1.15.0", - "css-tree": "^3.2.1", - "data-urls": "^7.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.7", - "parse5": "^8.0.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.1", - "undici": "^7.24.5", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.1", - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.1", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "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" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/lucide-react": { - "version": "0.577.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", - "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "bin": { - "lz-string": "bin/bin.js" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" - }, - "node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/react": { - "version": "18.3.1", - "license": "MIT", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "license": "MIT", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT" - }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/rolldown": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz", - "integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.120.0", - "@rolldown/pluginutils": "1.0.0-rc.10" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.10", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", - "@rolldown/binding-darwin-x64": "1.0.0-rc.10", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" - } - }, - "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz", - "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "node_modules/scheduler": { - "version": "0.23.2", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", - "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", - "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tldts": { - "version": "7.0.27", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", - "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^7.0.27" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.0.27", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", - "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tough-cookie": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", - "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, - "node_modules/typescript": { - "version": "5.9.3", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz", - "integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/vite": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", - "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.10", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vitest": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", - "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.0", - "@vitest/mocker": "4.1.0", - "@vitest/pretty-format": "4.1.0", - "@vitest/runner": "4.1.0", - "@vitest/snapshot": "4.1.0", - "@vitest/spy": "4.1.0", - "@vitest/utils": "4.1.0", - "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.0.3", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "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.0", - "@vitest/browser-preview": "4.1.0", - "@vitest/browser-webdriverio": "4.1.0", - "@vitest/ui": "4.1.0", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/wavesurfer.js": { - "version": "7.12.1", - "resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-7.12.1.tgz", - "integrity": "sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg==", - "license": "BSD-3-Clause", - "peer": true - }, - "node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-mimetype": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-url": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/frontend/package.json b/frontend/package.json index 8face48..193e443 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,30 +1,72 @@ { "name": "speechcraft-frontend", - "private": true, "version": "0.1.0", - "type": "module", + "private": true, "scripts": { - "dev": "vite --host 127.0.0.1", - "build": "tsc -b && vite build", - "preview": "vite preview", - "test": "vitest run" + "dev": "next dev -p 3002", + "build": "next build", + "start": "next start -p 3002", + "typecheck": "tsc --noEmit" }, "dependencies": { - "@wavesurfer/react": "^1.0.12", - "lucide-react": "^0.577.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "@radix-ui/react-accordion": "^1.2.11", + "@radix-ui/react-alert-dialog": "^1.1.14", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.15", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", + "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-navigation-menu": "^1.2.13", + "@radix-ui/react-popover": "^1.1.14", + "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-radio-group": "^1.3.7", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.5", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.5", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toast": "^1.2.14", + "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-query": "^5.95.2", + "@tanstack/react-virtual": "^3.13.23", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "1.1.1", + "date-fns": "^4.1.0", + "embla-carousel-react": "^8.6.0", + "framer-motion": "^12.34.0", + "input-otp": "^1.4.2", + "lucide-react": "^0.562.0", + "next": "16.2.1", + "next-themes": "^0.4.6", + "nuqs": "^2.8.9", + "react": "^19.2.0", + "react-day-picker": "9.8.0", + "react-dom": "^19.2.0", + "react-dropzone": "^15.0.0", + "react-hotkeys-hook": "^5.2.4", + "react-icons": "^5.5.0", + "react-number-format": "^5.4.4", + "recharts": "^3.6.0", + "tailwind-merge": "3.4.0", + "vaul": "^1.1.2", "wavesurfer.js": "^7.12.1" }, "devDependencies": { - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.2", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^6.0.1", - "jsdom": "^29.0.1", - "typescript": "^5.6.3", - "vite": "^8.0.1", - "vitest": "^4.1.0" + "@types/node": "^22.10.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "autoprefixer": "^10.4.20", + "bun-types": "1.3.0", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.13", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.7.0" } } diff --git a/frontend/postcss.config.cjs b/frontend/postcss.config.cjs new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/frontend/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx deleted file mode 100644 index 27ba32f..0000000 --- a/frontend/src/App.tsx +++ /dev/null @@ -1,603 +0,0 @@ -import { useEffect, useMemo, useReducer, useState, type ReactNode } from "react"; -import { Settings2 } from "lucide-react"; -import { ApiError, fetchProjects } from "./api"; -import { - PipelineProvider, - initialPipelineSelection, - pipelineSelectionReducer, - type PipelineSelectionState, - type PipelineStage, -} from "./pipeline/PipelineContext"; -import ExportPage from "./pages/ExportPage"; -import IngestPage from "./pages/IngestPage"; -import LabelPage from "./pages/LabelPage"; -import OverviewPage from "./pages/OverviewPage"; -import ProcessingPage from "./pages/ProcessingPage"; -import QcPage from "./pages/QcPage"; -import ReferencePage from "./pages/ReferencePage"; -import SpeakersPage from "./pages/SpeakersPage"; -import SlicerPage from "./pages/SlicerPage"; -import type { ClipLabItemRef, Project } from "./types"; - -type AppStep = PipelineStage | "reference"; -type ProjectLoadStatus = "loading" | "ready" | "error"; - -type AppRoute = { - step: AppStep; - projectId: string | null; - clipItem: ClipLabItemRef | null; -}; - -type AppLocationState = { - route: AppRoute; - pipelineSelection: PipelineSelectionState; -}; - -type StepDefinition = { - id: AppStep; - label: string; - shortLabel: string; - glyph: string; - tone: string; -}; - -type PageHeaderContent = { - eyebrow: string; - title: string; - description: string; -}; - -const stepDefinitions: StepDefinition[] = [ - { id: "ingest", label: "Ingest", shortLabel: "In", glyph: "I", tone: "Sources first" }, - { id: "overview", label: "Overview", shortLabel: "Ov", glyph: "O", tone: "Raw recordings and prep" }, - { id: "speakers", label: "Speakers", shortLabel: "Sp", glyph: "S", tone: "Detect and choose the target voice" }, - { id: "processing", label: "Processing", shortLabel: "Pr", glyph: "P", tone: "Align the selected speaker" }, - { id: "slicer", label: "Slicer", shortLabel: "Sl", glyph: "C", tone: "Generate candidate review clips" }, - { id: "qc", label: "QC", shortLabel: "QC", glyph: "Q", tone: "Machine triage for one run" }, - { id: "lab", label: "Lab", shortLabel: "La", glyph: "L", tone: "Human review and override" }, - { id: "export", label: "Export", shortLabel: "Ex", glyph: "E", tone: "Emit training-ready data" }, -]; - -function getErrorMessage(error: unknown, fallback: string): string { - if (error instanceof ApiError) { - return error.message; - } - - if (error instanceof Error && error.message.trim()) { - return error.message; - } - - return fallback; -} - -function isAppStep(value: string | null): value is AppStep { - return value === "reference" || stepDefinitions.some((step) => step.id === value); -} - -function readPipelineSelectionFromSearch( - searchParams: URLSearchParams, - step: AppStep, -): PipelineSelectionState { - const processingRunId = searchParams.get("processingRun")?.trim() || null; - const speakersRunId = searchParams.get("speakersRun")?.trim() || null; - const runId = searchParams.get("run")?.trim() || null; - - const selectedSpeakersRunId = step === "speakers" ? speakersRunId : null; - const selectedSlicerDatasetRunId = step === "slicer" ? runId : null; - const selectedQcDatasetRunId = step === "qc" ? runId : null; - const selectedLabDatasetRunId = step === "lab" ? runId : null; - - return { - selectedSpeakersRunId, - selectedProcessingRunId: processingRunId, - selectedSlicerDatasetRunId, - selectedQcDatasetRunId, - selectedLabDatasetRunId, - }; -} - -function readLocationState(): AppLocationState { - const path = window.location.pathname.replace(/^\/+|\/+$/g, ""); - const legacyStepMap: Partial> = { - enhance: "overview", - segment: "slicer", - label: "lab", - train: "export", - deploy: "export", - }; - const maybeStep = path.length > 0 ? legacyStepMap[path] ?? path : "ingest"; - const step = isAppStep(maybeStep) ? maybeStep : "ingest"; - const searchParams = new URLSearchParams(window.location.search); - const projectId = searchParams.get("project")?.trim() ?? null; - const clipId = searchParams.get("clip_id")?.trim() ?? null; - const clipItem = clipId ? ({ id: clipId } satisfies ClipLabItemRef) : null; - - return { - route: { - step, - projectId: projectId && projectId.length > 0 ? projectId : null, - clipItem, - }, - pipelineSelection: readPipelineSelectionFromSearch(searchParams, step), - }; -} - -function writeRouteToLocation( - route: AppRoute, - pipelineSelection: PipelineSelectionState, - replace = false, -) { - const url = new URL(window.location.origin); - url.pathname = route.step === "ingest" ? "/" : `/${route.step}`; - if (route.projectId) { - url.searchParams.set("project", route.projectId); - } else { - url.searchParams.delete("project"); - } - if (route.clipItem) { - url.searchParams.set("clip_id", route.clipItem.id); - } else { - url.searchParams.delete("clip_id"); - } - - if (route.step === "speakers" && pipelineSelection.selectedSpeakersRunId) { - url.searchParams.set("speakersRun", pipelineSelection.selectedSpeakersRunId); - } else { - url.searchParams.delete("speakersRun"); - } - - if (route.step === "processing" && pipelineSelection.selectedProcessingRunId) { - url.searchParams.set("processingRun", pipelineSelection.selectedProcessingRunId); - } else { - url.searchParams.delete("processingRun"); - } - - if (route.step === "slicer" && pipelineSelection.selectedSlicerDatasetRunId) { - url.searchParams.set("run", pipelineSelection.selectedSlicerDatasetRunId); - } else if (route.step === "qc" && pipelineSelection.selectedQcDatasetRunId) { - url.searchParams.set("run", pipelineSelection.selectedQcDatasetRunId); - } else if (route.step === "lab" && pipelineSelection.selectedLabDatasetRunId) { - url.searchParams.set("run", pipelineSelection.selectedLabDatasetRunId); - } else { - url.searchParams.delete("run"); - } - - if (replace) { - window.history.replaceState({}, "", url); - return; - } - - window.history.pushState({}, "", url); -} - -function getStepIndex(step: AppStep): number { - return stepDefinitions.findIndex((entry) => entry.id === step); -} - -function getPageHeaderContent(step: AppStep, activeProject: Project | null): PageHeaderContent { - if (step === "ingest") { - return { - eyebrow: "Step 01", - title: activeProject?.name ?? "No project selected", - description: - "Ingest is the new home base: set up model access, point Speechcraft at source folders, and define where assets will sync next.", - }; - } - - if (step === "overview") { - return { - eyebrow: "Step 02", - title: "Overview", - description: "Inspect imported raw recordings and manage explicit preparation state.", - }; - } - - if (step === "speakers") { - return { - eyebrow: "Step 03", - title: "Speakers", - description: "Run speech detection and diarization, audition samples, and choose the target voice.", - }; - } - - if (step === "slicer") { - return { - eyebrow: "Step 05", - title: "Slicer", - description: "Generate candidate review clips from aligned runs and tune SafeCutPoint assembly.", - }; - } - - if (step === "processing") { - return { - eyebrow: "Step 04", - title: "Processing", - description: "Process the chosen speaker through buffers, ASR, MFA, and alignment QC before slicing.", - }; - } - - if (step === "qc") { - return { - eyebrow: "Step 06", - title: "QC", - description: "Run machine triage for one selected slicer run without claiming human approval.", - }; - } - - if (step === "lab") { - return { - eyebrow: "Step 07", - title: activeProject?.name ?? "Lab", - description: "Manual slice review, transcript repair, and human overrides live here.", - }; - } - - if (step === "reference") { - return { - eyebrow: "Reference", - title: activeProject?.name ?? "Reference Workstation", - description: - "Reference remains available as its existing workstation, outside the current sprint path.", - }; - } - - return { - eyebrow: "Step 08", - title: "Export", - description: "Emit the selected reviewed or machine-triaged dataset for downstream training.", - }; -} - -export default function App() { - const [route, setRoute] = useState(() => readLocationState().route); - const [projectLoadStatus, setProjectLoadStatus] = useState("loading"); - const [projectLoadError, setProjectLoadError] = useState(null); - const [projects, setProjects] = useState([]); - const [pageHeaderActions, setPageHeaderActions] = useState(null); - const [pipelineSelection, dispatchPipelineSelection] = useReducer( - pipelineSelectionReducer, - undefined, - () => readLocationState().pipelineSelection, - ); - - useEffect(() => { - const handlePopState = () => { - const nextLocationState = readLocationState(); - setRoute(nextLocationState.route); - dispatchPipelineSelection({ type: "replace", state: nextLocationState.pipelineSelection }); - }; - - window.addEventListener("popstate", handlePopState); - return () => { - window.removeEventListener("popstate", handlePopState); - }; - }, []); - - async function loadProjects(preferredProjectId?: string | null, routeSnapshot = route) { - setProjectLoadStatus("loading"); - setProjectLoadError(null); - - try { - const nextProjects = await fetchProjects(); - const sortedProjects = [...nextProjects].sort( - (left, right) => new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime(), - ); - const fallbackProjectId = sortedProjects[0]?.id ?? null; - const selectedProjectId = - preferredProjectId && nextProjects.some((project) => project.id === preferredProjectId) - ? preferredProjectId - : routeSnapshot.projectId && nextProjects.some((project) => project.id === routeSnapshot.projectId) - ? routeSnapshot.projectId - : fallbackProjectId; - - setProjects(sortedProjects); - setProjectLoadStatus("ready"); - - if (selectedProjectId !== routeSnapshot.projectId) { - dispatchPipelineSelection({ type: "reset" }); - const nextRoute = { ...routeSnapshot, projectId: selectedProjectId }; - setRoute(nextRoute); - writeRouteToLocation(nextRoute, initialPipelineSelection, true); - } - } catch (error) { - setProjects([]); - setProjectLoadStatus("error"); - dispatchPipelineSelection({ type: "reset" }); - setProjectLoadError( - getErrorMessage(error, "The app could not load the project list from the backend."), - ); - } - } - - useEffect(() => { - void loadProjects(route.projectId); - }, []); - - const activeProject = useMemo( - () => projects.find((project) => project.id === route.projectId) ?? null, - [projects, route.projectId], - ); - const activeStepIndex = getStepIndex(route.step); - const visibleStepIndex = activeStepIndex >= 0 ? activeStepIndex : 0; - const pageHeaderContent = useMemo( - () => getPageHeaderContent(route.step, activeProject), - [route.step, activeProject], - ); - - useEffect(() => { - setPageHeaderActions(null); - }, [route.step]); - - function navigate(nextStep: AppStep, nextProjectId = route.projectId) { - let nextPipelineSelection = pipelineSelection; - if ((nextProjectId ?? null) !== route.projectId) { - nextPipelineSelection = initialPipelineSelection; - dispatchPipelineSelection({ type: "reset" }); - } - - const nextRoute = { step: nextStep, projectId: nextProjectId ?? null, clipItem: route.clipItem }; - setRoute(nextRoute); - writeRouteToLocation(nextRoute, nextPipelineSelection); - } - - function handleProjectChange(nextProjectId: string) { - dispatchPipelineSelection({ type: "reset" }); - const nextRoute = { - step: route.step, - projectId: nextProjectId, - clipItem: route.clipItem, - }; - setRoute(nextRoute); - writeRouteToLocation(nextRoute, initialPipelineSelection); - } - - function selectProcessingRun(runId: string | null) { - const nextPipelineSelection: PipelineSelectionState = { - ...pipelineSelection, - selectedProcessingRunId: runId, - }; - dispatchPipelineSelection({ type: "select-processing-run", runId }); - writeRouteToLocation(route, nextPipelineSelection, true); - } - - function selectSpeakersRun(runId: string | null) { - const nextPipelineSelection: PipelineSelectionState = { - ...pipelineSelection, - selectedSpeakersRunId: runId, - }; - dispatchPipelineSelection({ type: "select-speakers-run", runId }); - writeRouteToLocation(route, nextPipelineSelection, true); - } - - function selectSlicerDatasetRun(runId: string | null) { - const nextPipelineSelection: PipelineSelectionState = { - ...pipelineSelection, - selectedSlicerDatasetRunId: runId, - }; - dispatchPipelineSelection({ type: "select-slicer-dataset-run", runId }); - writeRouteToLocation(route, nextPipelineSelection, true); - } - - function selectQcDatasetRun(runId: string | null) { - const nextPipelineSelection: PipelineSelectionState = { - ...pipelineSelection, - selectedQcDatasetRunId: runId, - }; - dispatchPipelineSelection({ type: "select-qc-dataset-run", runId }); - writeRouteToLocation(route, nextPipelineSelection, true); - } - - function selectLabDatasetRun(runId: string | null) { - const nextPipelineSelection: PipelineSelectionState = { - ...pipelineSelection, - selectedLabDatasetRunId: runId, - }; - dispatchPipelineSelection({ type: "select-lab-dataset-run", runId }); - writeRouteToLocation(route, nextPipelineSelection, true); - } - - function openSlicerWithRun(runId: string) { - const nextPipelineSelection: PipelineSelectionState = { - ...pipelineSelection, - selectedSlicerDatasetRunId: runId, - }; - dispatchPipelineSelection({ type: "select-slicer-dataset-run", runId }); - const nextRoute = { step: "slicer" as const, projectId: route.projectId, clipItem: route.clipItem }; - setRoute(nextRoute); - writeRouteToLocation(nextRoute, nextPipelineSelection); - } - - function openProcessingWithRun(runId: string) { - const nextPipelineSelection: PipelineSelectionState = { - ...pipelineSelection, - selectedProcessingRunId: runId, - }; - dispatchPipelineSelection({ type: "select-processing-run", runId }); - const nextRoute = { step: "processing" as const, projectId: route.projectId, clipItem: route.clipItem }; - setRoute(nextRoute); - writeRouteToLocation(nextRoute, nextPipelineSelection); - } - - function handleImportComplete(projectId: string) { - dispatchPipelineSelection({ type: "reset" }); - const nextRoute = { step: "overview" as const, projectId, clipItem: null }; - setRoute(nextRoute); - writeRouteToLocation(nextRoute, initialPipelineSelection, true); - void loadProjects(projectId, nextRoute); - } - - const pageProps = { - activeProject, - projectLoadStatus, - projectLoadError, - onRetryProjects: () => void loadProjects(route.projectId), - }; - - let pageContent = null; - - if (route.step === "ingest") { - pageContent = ; - } else if (route.step === "overview") { - pageContent = ; - } else if (route.step === "speakers") { - pageContent = ( - navigate("processing")} - onOpenProcessingWithRun={openProcessingWithRun} - /> - ); - } else if (route.step === "processing") { - pageContent = ( - navigate("speakers")} - onOpenSlicerWithRun={openSlicerWithRun} - /> - ); - } else if (route.step === "slicer") { - pageContent = navigate("qc")} />; - } else if (route.step === "qc") { - pageContent = ( - - ); - } else if (route.step === "lab") { - pageContent = ( - { - const nextRoute = { ...route, clipItem }; - setRoute(nextRoute); - writeRouteToLocation(nextRoute, pipelineSelection, true); - }} - onHeaderActionsChange={setPageHeaderActions} - /> - ); - } else if (route.step === "reference") { - pageContent = ; - } else { - pageContent = ; - } - - return ( -
-
-
-
-

Speechcraft

-

Dataset Workstation

-
- -
-
-

{pageHeaderContent.eyebrow}

-

{pageHeaderContent.title}

-
-

{pageHeaderContent.description}

-
- -
-
- {pageHeaderActions ?
{pageHeaderActions}
: null} - - - - -
-
-
-
- -
- {projectLoadError ?

{projectLoadError}

: null} - { - dispatchPipelineSelection({ type: "reset" }); - writeRouteToLocation(route, initialPipelineSelection, true); - }} - > - {pageContent} - -
- - - - {activeProject ? ( -
- {activeProject.name} - updated {new Date(activeProject.updated_at).toLocaleDateString()} - - export {activeProject.export_status ? activeProject.export_status.replace(/_/g, " ") : "n/a"} - - {stepDefinitions[visibleStepIndex]?.shortLabel ?? "In"} active -
- ) : null} -
- ); -} diff --git a/frontend/src/ErrorBoundary.tsx b/frontend/src/ErrorBoundary.tsx deleted file mode 100644 index d2806db..0000000 --- a/frontend/src/ErrorBoundary.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Component, type ReactNode } from "react"; - -type ErrorBoundaryProps = { - children: ReactNode; - fallback: ReactNode; - resetKey?: string; -}; - -type ErrorBoundaryState = { - hasError: boolean; -}; - -export default class ErrorBoundary extends Component< - ErrorBoundaryProps, - ErrorBoundaryState -> { - state: ErrorBoundaryState = { - hasError: false, - }; - - static getDerivedStateFromError(): ErrorBoundaryState { - return { hasError: true }; - } - - override componentDidCatch(error: unknown) { - console.error("Speechcraft UI error boundary caught an exception", error); - } - - override componentDidUpdate(prevProps: ErrorBoundaryProps) { - if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) { - this.setState({ hasError: false }); - } - } - - override render() { - if (this.state.hasError) { - return this.props.fallback; - } - - return this.props.children; - } -} diff --git a/frontend/src/WaveformPane.test.tsx b/frontend/src/WaveformPane.test.tsx deleted file mode 100644 index e8fc5ad..0000000 --- a/frontend/src/WaveformPane.test.tsx +++ /dev/null @@ -1,452 +0,0 @@ -import { act, render, waitFor } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -type DeferredLoad = { - resolve: () => void; - reject: (error: Error) => void; -}; - -const deferredLoads: DeferredLoad[] = []; -const waveSurferLoad = vi.fn(() => - new Promise((resolve, reject) => { - deferredLoads.push({ - resolve: () => resolve(), - reject, - }); - }), -); -const waveSurferDestroy = vi.fn(); -const waveSurferOn = vi.fn(); -const waveSurferOnce = vi.fn(); -const waveSurferSeekTo = vi.fn(); -const waveSurferGetDuration = vi.fn(() => 2); -const waveSurferGetCurrentTime = vi.fn(() => 0); -const waveSurferGetWrapper = vi.fn(() => ({ - parentElement: null, - scrollWidth: 100, -})); - -vi.mock("wavesurfer.js", () => ({ - default: { - create: vi.fn(() => ({ - load: waveSurferLoad, - destroy: waveSurferDestroy, - on: waveSurferOn, - once: waveSurferOnce, - seekTo: waveSurferSeekTo, - getDuration: waveSurferGetDuration, - getCurrentTime: waveSurferGetCurrentTime, - getWrapper: waveSurferGetWrapper, - isPlaying: vi.fn(() => false), - zoom: vi.fn(), - })), - }, -})); - -const enableDragSelection = vi.fn(); - -vi.mock("wavesurfer.js/dist/plugins/regions.esm.js", () => ({ - default: { - create: vi.fn(() => ({ - enableDragSelection, - on: vi.fn(), - getRegions: vi.fn(() => []), - addRegion: vi.fn(), - })), - }, -})); - -import WaveformPane from "./WaveformPane"; - -async function resolveAllPendingLoads() { - const pending = [...deferredLoads]; - await act(async () => { - for (const load of pending) { - load.resolve(); - } - await Promise.resolve(); - }); -} - -describe("WaveformPane", () => { - beforeEach(() => { - deferredLoads.length = 0; - waveSurferLoad.mockClear(); - waveSurferOnce.mockClear(); - enableDragSelection.mockClear(); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("does not load dataset audio before peaks resolve on first mount", async () => { - const revisionKey = "rev-a"; - const peaks = [0.1, 0.2, 0.3]; - - const { rerender } = render( - {}} - onCursorChange={() => {}} - />, - ); - - await act(async () => { - await Promise.resolve(); - }); - expect(waveSurferLoad).not.toHaveBeenCalled(); - - rerender( - {}} - onCursorChange={() => {}} - />, - ); - - await waitFor(() => { - expect(waveSurferLoad).toHaveBeenCalledTimes(1); - }); - await resolveAllPendingLoads(); - expect(waveSurferLoad).toHaveBeenCalledWith( - "http://127.0.0.1:8010/media/a.wav", - [peaks], - 2, - ); - }); - - it("blocks idle dataset mounts until peaks are ready", async () => { - render( - {}} - onCursorChange={() => {}} - />, - ); - - await act(async () => { - await Promise.resolve(); - }); - expect(waveSurferLoad).not.toHaveBeenCalled(); - }); - - it("loads each revision once when switching clips", async () => { - const peaksA = [0.1, 0.2]; - const peaksB = [0.3, 0.4]; - - const { rerender } = render( - {}} - onCursorChange={() => {}} - />, - ); - - rerender( - {}} - onCursorChange={() => {}} - />, - ); - - await waitFor(() => { - expect(waveSurferLoad).toHaveBeenCalledTimes(1); - }); - await resolveAllPendingLoads(); - - rerender( - {}} - onCursorChange={() => {}} - />, - ); - - await act(async () => { - await Promise.resolve(); - }); - expect(waveSurferLoad).toHaveBeenCalledTimes(1); - - rerender( - {}} - onCursorChange={() => {}} - />, - ); - - await waitFor(() => { - expect(waveSurferLoad).toHaveBeenCalledTimes(2); - }); - await resolveAllPendingLoads(); - expect(waveSurferLoad).toHaveBeenLastCalledWith( - "http://127.0.0.1:8010/media/b.wav", - [peaksB], - 3, - ); - }); - - it("falls back to one audio-only load when dataset peaks fail", async () => { - const { rerender } = render( - {}} - onCursorChange={() => {}} - />, - ); - - rerender( - {}} - onCursorChange={() => {}} - />, - ); - - await waitFor(() => { - expect(waveSurferLoad).toHaveBeenCalledTimes(1); - }); - await resolveAllPendingLoads(); - expect(waveSurferLoad).toHaveBeenCalledWith( - "http://127.0.0.1:8010/media/a.wav", - undefined, - 2, - ); - - rerender( - {}} - onCursorChange={() => {}} - />, - ); - - await act(async () => { - await Promise.resolve(); - }); - expect(waveSurferLoad).toHaveBeenCalledTimes(1); - }); - - it("does not reload when only the playhead changes", async () => { - const revisionKey = "rev-a"; - const peaks = [0.5, 0.4]; - - const { rerender } = render( - {}} - onCursorChange={() => {}} - />, - ); - - await waitFor(() => { - expect(waveSurferLoad).toHaveBeenCalledTimes(1); - }); - await resolveAllPendingLoads(); - - rerender( - {}} - onCursorChange={() => {}} - />, - ); - - await act(async () => { - await Promise.resolve(); - }); - - expect(waveSurferLoad).toHaveBeenCalledTimes(1); - }); - - it("ignores stale load completion when an older revision resolves late", async () => { - const peaksA = [0.1, 0.2]; - const peaksB = [0.3, 0.4]; - - const { rerender } = render( - {}} - onCursorChange={() => {}} - />, - ); - - await waitFor(() => { - expect(waveSurferLoad).toHaveBeenCalledTimes(1); - }); - const loadA = deferredLoads[0]; - expect(loadA).toBeDefined(); - - rerender( - {}} - onCursorChange={() => {}} - />, - ); - - await act(async () => { - await Promise.resolve(); - }); - expect(waveSurferLoad).toHaveBeenCalledTimes(1); - - rerender( - {}} - onCursorChange={() => {}} - />, - ); - - await waitFor(() => { - expect(waveSurferLoad).toHaveBeenCalledTimes(2); - }); - const loadB = deferredLoads[1]; - expect(loadB).toBeDefined(); - - await act(async () => { - loadB.resolve(); - await Promise.resolve(); - }); - - await act(async () => { - loadA.resolve(); - await Promise.resolve(); - }); - - rerender( - {}} - onCursorChange={() => {}} - />, - ); - - await act(async () => { - await Promise.resolve(); - }); - - expect(waveSurferLoad).toHaveBeenCalledTimes(2); - expect(waveSurferLoad).toHaveBeenLastCalledWith( - "http://127.0.0.1:8010/media/b.wav", - [peaksB], - 3, - ); - }); -}); diff --git a/frontend/src/WaveformPane.tsx b/frontend/src/WaveformPane.tsx deleted file mode 100644 index 0bf72a2..0000000 --- a/frontend/src/WaveformPane.tsx +++ /dev/null @@ -1,463 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import WaveSurfer from "wavesurfer.js"; -import RegionsPlugin from "wavesurfer.js/dist/plugins/regions.esm.js"; - -type WaveformPaneProps = { - audioUrl: string; - durationSeconds: number; - peaks: number[] | null; - loadRevisionKey?: string | null; - peaksLoadState?: "idle" | "loading" | "ready" | "failed"; - requirePeaksBeforeLoad?: boolean; - desiredCursorSeconds?: number; - selectionStart: number; - selectionEnd: number; - onSelectionChange: (start: number, end: number) => void; - onCursorChange: (time: number) => void; - onHoverTimeChange?: (time: number | null) => void; - onReady?: (instance: WaveSurfer | null) => void; - onPlayingChange?: (isPlaying: boolean) => void; -}; - -export default function WaveformPane({ - audioUrl, - durationSeconds, - peaks, - loadRevisionKey = null, - peaksLoadState = "idle", - requirePeaksBeforeLoad = false, - desiredCursorSeconds = 0, - selectionStart, - selectionEnd, - onSelectionChange, - onCursorChange, - onHoverTimeChange, - onReady, - onPlayingChange, -}: WaveformPaneProps) { - const precision = 10000; - const roundTime = (value: number): number => Math.round(value * precision) / precision; - const containerRef = useRef(null); - const waveSurferRef = useRef(null); - const regionsRef = useRef(null); - const zoomRef = useRef(90); - const isPointerDownRef = useRef(false); - const pointerStartXRef = useRef(null); - const pointerStartTimeRef = useRef(null); - const draggedThisGestureRef = useRef(false); - const selectionChangeRef = useRef(onSelectionChange); - const cursorChangeRef = useRef(onCursorChange); - const hoverTimeChangeRef = useRef(onHoverTimeChange); - const readyRef = useRef(onReady); - const playingChangeRef = useRef(onPlayingChange); - const lastAudioUrlRef = useRef(null); - const lastLoadedRevisionRef = useRef(null); - const loadGenerationRef = useRef(0); - const peaksRef = useRef(peaks); - const desiredCursorRef = useRef(desiredCursorSeconds); - const [audioState, setAudioState] = useState<"loading" | "ready" | "error">("loading"); - const [audioError, setAudioError] = useState(null); - - function isAbortLikeError(error: unknown): boolean { - const message = - error instanceof Error - ? error.message - : typeof error === "string" - ? error - : ""; - const normalized = message.toLowerCase(); - return ( - normalized.includes("abort") || - normalized.includes("aborted") || - normalized.includes("user aborted") || - normalized.includes("cancel") - ); - } - - useEffect(() => { - selectionChangeRef.current = onSelectionChange; - cursorChangeRef.current = onCursorChange; - hoverTimeChangeRef.current = onHoverTimeChange; - readyRef.current = onReady; - playingChangeRef.current = onPlayingChange; - }, [onSelectionChange, onCursorChange, onHoverTimeChange, onReady, onPlayingChange]); - - useEffect(() => { - desiredCursorRef.current = desiredCursorSeconds; - }, [desiredCursorSeconds]); - - useEffect(() => { - peaksRef.current = peaks; - }, [peaks]); - - useEffect(() => { - lastLoadedRevisionRef.current = null; - }, [loadRevisionKey, audioUrl]); - - useEffect(() => { - if (!containerRef.current) { - return; - } - - const regions = RegionsPlugin.create(); - const waveSurfer = WaveSurfer.create({ - container: containerRef.current, - height: 220, - normalize: true, - waveColor: "#78d2cd", - progressColor: "#3c8d9f", - cursorColor: "#f3a545", - cursorWidth: 2, - minPxPerSec: zoomRef.current, - dragToSeek: false, - interact: true, - autoScroll: false, - autoCenter: false, - hideScrollbar: false, - plugins: [regions], - }); - - waveSurferRef.current = waveSurfer; - regionsRef.current = regions; - readyRef.current?.(waveSurfer); - - const timeAtClientX = (clientX: number): number | null => { - const wrapper = waveSurfer.getWrapper(); - const scrollContainer = wrapper.parentElement; - const duration = waveSurfer.getDuration(); - if (!scrollContainer || duration <= 0 || wrapper.scrollWidth <= 0) { - return null; - } - - const viewport = scrollContainer.getBoundingClientRect(); - const localX = Math.max(0, Math.min(clientX - viewport.left, viewport.width)); - const absoluteX = Math.max( - 0, - Math.min(scrollContainer.scrollLeft + localX, wrapper.scrollWidth), - ); - return roundTime((absoluteX / wrapper.scrollWidth) * duration); - }; - - const handleTimeUpdate = (time: number) => { - if (!waveSurfer.isPlaying()) { - return; - } - cursorChangeRef.current(Number(time.toFixed(2))); - - // Audacity-like page scrolling: jump viewport when playhead exits visible window. - const scrollContainer = waveSurfer.getWrapper().parentElement; - const duration = waveSurfer.getDuration(); - if (!scrollContainer || duration <= 0) { - return; - } - - const totalWidth = waveSurfer.getWrapper().scrollWidth; - const viewportWidth = scrollContainer.clientWidth; - if (totalWidth <= viewportWidth) { - return; - } - - const currentPx = (time / duration) * totalWidth; - const viewStart = scrollContainer.scrollLeft; - const viewEnd = viewStart + viewportWidth; - - if (currentPx > viewEnd - 2) { - const nextPageStart = - Math.floor(currentPx / viewportWidth) * viewportWidth; - scrollContainer.scrollLeft = Math.min( - nextPageStart, - Math.max(totalWidth - viewportWidth, 0), - ); - } else if (currentPx < viewStart) { - const prevPageStart = - Math.floor(currentPx / viewportWidth) * viewportWidth; - scrollContainer.scrollLeft = Math.max(prevPageStart, 0); - } - }; - const handleInteraction = () => { - if (draggedThisGestureRef.current) { - return; - } - cursorChangeRef.current(roundTime(waveSurfer.getCurrentTime())); - }; - const handleSeeking = (time: number) => { - if (draggedThisGestureRef.current) { - return; - } - cursorChangeRef.current(roundTime(time)); - }; - const handleClick = () => { - if (draggedThisGestureRef.current) { - return; - } - const time = roundTime(waveSurfer.getCurrentTime()); - cursorChangeRef.current(time); - selectionChangeRef.current(time, time); - }; - - waveSurfer.on("timeupdate", handleTimeUpdate); - waveSurfer.on("interaction", handleInteraction); - waveSurfer.on("seeking", handleSeeking); - waveSurfer.on("click", handleClick); - waveSurfer.on("play", () => playingChangeRef.current?.(true)); - waveSurfer.on("pause", () => playingChangeRef.current?.(false)); - waveSurfer.on("finish", () => playingChangeRef.current?.(false)); - waveSurfer.on("ready", () => { - setAudioState("ready"); - setAudioError(null); - }); - waveSurfer.on("error", (error) => { - if (isAbortLikeError(error)) { - return; - } - lastLoadedRevisionRef.current = null; - const message = - error instanceof Error - ? error.message - : typeof error === "string" && error - ? error - : "Audio failed to load for this clip."; - setAudioState("error"); - setAudioError(message); - playingChangeRef.current?.(false); - }); - - regions.on("region-created", (region: any) => { - for (const candidate of regions.getRegions()) { - if (candidate.id !== region.id) { - candidate.remove(); - } - } - selectionChangeRef.current( - roundTime(region.start), - roundTime(region.end), - ); - }); - - regions.on("region-updated", (region: any) => { - selectionChangeRef.current( - roundTime(region.start), - roundTime(region.end), - ); - }); - - const handlePointerDown = (event: PointerEvent) => { - isPointerDownRef.current = true; - pointerStartXRef.current = event.clientX; - pointerStartTimeRef.current = timeAtClientX(event.clientX); - draggedThisGestureRef.current = false; - }; - - const handlePointerMove = (event: PointerEvent) => { - const hoverTime = timeAtClientX(event.clientX); - if (hoverTime !== null) { - hoverTimeChangeRef.current?.(hoverTime); - } - - if (!isPointerDownRef.current || pointerStartXRef.current === null) { - return; - } - if (Math.abs(event.clientX - pointerStartXRef.current) > 4) { - draggedThisGestureRef.current = true; - } - }; - - const handlePointerUp = (event: PointerEvent) => { - const didDrag = draggedThisGestureRef.current; - const startTime = pointerStartTimeRef.current; - const endTime = timeAtClientX(event.clientX); - - isPointerDownRef.current = false; - pointerStartXRef.current = null; - pointerStartTimeRef.current = null; - - if (didDrag && startTime !== null && endTime !== null) { - selectionChangeRef.current( - Math.min(startTime, endTime), - Math.max(startTime, endTime), - ); - setTimeout(() => { - draggedThisGestureRef.current = false; - }, 120); - return; - } - - draggedThisGestureRef.current = false; - }; - - const handleWheel = (event: WheelEvent) => { - const scrollContainer = waveSurfer.getWrapper().parentElement; - - if (event.ctrlKey) { - event.preventDefault(); - const delta = event.deltaY < 0 ? 36 : -36; - zoomRef.current = Math.max(30, Math.min(1000, zoomRef.current + delta)); - waveSurfer.zoom(zoomRef.current); - return; - } - - if (event.shiftKey && scrollContainer) { - event.preventDefault(); - scrollContainer.scrollLeft += event.deltaY; - } - }; - const handlePointerLeave = () => { - hoverTimeChangeRef.current?.(null); - }; - - containerRef.current.addEventListener("wheel", handleWheel, { passive: false }); - containerRef.current.addEventListener("pointerdown", handlePointerDown); - containerRef.current.addEventListener("pointermove", handlePointerMove); - containerRef.current.addEventListener("pointerup", handlePointerUp as EventListener); - containerRef.current.addEventListener("pointercancel", handlePointerUp as EventListener); - containerRef.current.addEventListener("pointerleave", handlePointerLeave); - - return () => { - containerRef.current?.removeEventListener("wheel", handleWheel); - containerRef.current?.removeEventListener("pointerdown", handlePointerDown); - containerRef.current?.removeEventListener("pointermove", handlePointerMove); - containerRef.current?.removeEventListener("pointerup", handlePointerUp as EventListener); - containerRef.current?.removeEventListener("pointercancel", handlePointerUp as EventListener); - containerRef.current?.removeEventListener("pointerleave", handlePointerLeave); - readyRef.current?.(null); - waveSurfer.destroy(); - waveSurferRef.current = null; - regionsRef.current = null; - }; - }, []); - - useEffect(() => { - const waveSurfer = waveSurferRef.current; - if (!waveSurfer || !audioUrl) { - return; - } - - const revisionKey = loadRevisionKey ?? audioUrl; - const waitForPeaks = - requirePeaksBeforeLoad - && peaksLoadState !== "ready" - && peaksLoadState !== "failed"; - if (peaksLoadState === "loading" || waitForPeaks) { - loadGenerationRef.current += 1; - setAudioState("loading"); - setAudioError(null); - return; - } - - if (lastLoadedRevisionRef.current === revisionKey) { - return; - } - - const isClipChange = lastAudioUrlRef.current !== audioUrl; - lastAudioUrlRef.current = audioUrl; - const targetTime = isClipChange - ? 0 - : Math.max(0, Math.min(desiredCursorRef.current, durationSeconds)); - const seekOnReady = () => { - const duration = waveSurfer.getDuration(); - if (duration > 0) { - waveSurfer.seekTo(Math.max(0, Math.min(targetTime / duration, 1))); - } - cursorChangeRef.current(roundTime(targetTime)); - }; - waveSurfer.once("ready", seekOnReady); - setAudioState("loading"); - setAudioError(null); - - const currentPeaks = peaksRef.current; - const peaksArg = - peaksLoadState === "ready" && currentPeaks && currentPeaks.length > 0 - ? [currentPeaks] - : undefined; - - const generation = ++loadGenerationRef.current; - const revisionKeyAtStart = revisionKey; - - void waveSurfer - .load( - audioUrl, - peaksArg, - durationSeconds > 0 ? durationSeconds : undefined, - ) - .then(() => { - if ( - generation !== loadGenerationRef.current - || revisionKeyAtStart !== (loadRevisionKey ?? audioUrl) - ) { - return; - } - lastLoadedRevisionRef.current = revisionKeyAtStart; - }) - .catch((error) => { - if (generation !== loadGenerationRef.current) { - return; - } - if (!isAbortLikeError(error)) { - lastLoadedRevisionRef.current = null; - } - }); - }, [audioUrl, durationSeconds, loadRevisionKey, peaksLoadState, requirePeaksBeforeLoad]); - - useEffect(() => { - const regions = regionsRef.current; - if (!regions) { - return; - } - - const start = Math.min(selectionStart, selectionEnd); - const end = Math.max(selectionStart, selectionEnd); - const currentRegion = regions.getRegions()[0]; - - if (end <= start + 0.01) { - if (currentRegion) { - currentRegion.remove(); - } - return; - } - - if (!currentRegion) { - regions.addRegion({ - start, - end, - color: "rgba(247, 203, 104, 0.2)", - drag: true, - resize: true, - }); - return; - } - - if ( - Math.abs(currentRegion.start - start) > 0.02 || - Math.abs(currentRegion.end - end) > 0.02 - ) { - currentRegion.setOptions({ start, end }); - } - }, [selectionStart, selectionEnd]); - - return ( -
-
- {audioState !== "ready" ? ( -
- - {audioState === "loading" - ? peaksLoadState === "loading" - ? "Loading waveform..." - : "Loading audio..." - : "Audio unavailable"} - - - {audioState === "loading" - ? peaksLoadState === "loading" - ? "Fetching cached peaks before decoding clip audio." - : "Fetching clip audio and preparing the waveform." - : audioError ?? "The backend could not decode this clip."} - -
- ) : null} -
- ); -} diff --git a/frontend/src/api.ts b/frontend/src/api.ts deleted file mode 100644 index 4f4e13a..0000000 --- a/frontend/src/api.ts +++ /dev/null @@ -1,775 +0,0 @@ -import type { - ClipLabItem, - DatasetClipLabClipRow, - DatasetClipLabAudioOperationRequest, - DatasetClipLabAudioStackRequest, - DatasetClipLabPatchRequest, - DatasetClipLabView, - DatasetWaveformPeaksPayload, - DatasetPreflight, - DatasetExportResults, - DatasetQcFinalizeRequest, - DatasetQcFinalizeResponse, - DatasetQcPayload, - DatasetSpeakerResults, - DatasetRun, - DatasetRunCreateRequest, - DatasetRunLog, - DatasetSpeakerSelection, - DatasetSlicerResults, - ExportPreview, - ExportRun, - ImportBatch, - MediaCleanupResult, - ProjectAlignmentSettings, - PreparationSettings, - ProjectRecordingJobsRun, - ProjectPreparationRun, - ProjectTranscriptionSettings, - ProcessingJob, - ReferenceAssetDetail, - ReferenceAssetSummary, - ReferenceClipCandidate, - ReferenceCandidate, - ReferenceRunRerankResponse, - ReferenceRun, - ReviewStatus, - Slice, - SliceSummary, - SourceRecording, - SourceRecordingQueue, - WaveformPeaks, -} from "./types"; - -export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://127.0.0.1:8010"; - -export class ApiError extends Error { - readonly status: number; - readonly url: string; - - constructor(message: string, status: number, url: string) { - super(message); - this.name = "ApiError"; - this.status = status; - this.url = url; - } -} - -function backendUnreachableMessage(): string { - return `Backend API is unreachable at ${API_BASE}. Restart make dev-backend, then refresh.`; -} - -async function requestJson(input: string, init?: RequestInit): Promise { - let response: Response; - try { - response = await fetch(input, init); - } catch (error) { - if (error instanceof TypeError) { - throw new ApiError(backendUnreachableMessage(), 0, input); - } - throw error; - } - return await parseJson(response); -} - -async function parseJson(response: Response): Promise { - if (!response.ok) { - let message = `Request failed: ${response.status}`; - - try { - const payload = (await response.json()) as { detail?: string; message?: string }; - message = payload.detail ?? payload.message ?? message; - } catch { - try { - const text = await response.text(); - if (text.trim()) { - message = text.trim(); - } - } catch { - // Ignore secondary parse failures and keep the status message. - } - } - - throw new ApiError(message, response.status, response.url); - } - - return (await response.json()) as T; -} - -export function buildVariantAudioUrl(variantId: string): string { - return `${API_BASE}/media/variants/${variantId}.wav`; -} - -export function resolveApiUrl(pathOrUrl: string): string { - if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) { - return pathOrUrl; - } - return `${API_BASE}${pathOrUrl}`; -} - -export function buildSliceAudioUrl(sliceId: string, revision?: string): string { - const url = new URL(`${API_BASE}/media/slices/${sliceId}.wav`); - if (revision) { - url.searchParams.set("rev", revision); - } - return url.toString(); -} - -export function buildReferenceVariantAudioUrl(variantId: string): string { - return `${API_BASE}/media/reference-variants/${variantId}.wav`; -} - -export function buildReferenceCandidateAudioUrl(runId: string, candidateId: string): string { - return `${API_BASE}/media/reference-candidates/${runId}/${candidateId}.wav`; -} - -export async function fetchHealthStrict(): Promise<{ status: string }> { - return await requestJson<{ status: string }>(`${API_BASE}/healthz`); -} - -export async function fetchProjects(): Promise { - return await requestJson(`${API_BASE}/api/projects`); -} - -export async function fetchProject(projectId: string): Promise { - return await requestJson(`${API_BASE}/api/projects/${projectId}`); -} - -export async function createImportBatch(payload: { id: string; name: string }): Promise { - return await requestJson(`${API_BASE}/api/import-batches`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - }); -} - -export async function deleteProject( - projectId: string, -): Promise<{ project_id: string; deleted_file_count: number }> { - return await requestJson<{ project_id: string; deleted_file_count: number }>(`${API_BASE}/api/projects/${projectId}`, { - method: "DELETE", - }); -} - -export function uploadProjectSourceRecording( - projectId: string, - file: File, - onProgress: (progress: number) => void, -): Promise { - const url = `${API_BASE}/api/projects/${projectId}/source-recordings/upload`; - return new Promise((resolve, reject) => { - const request = new XMLHttpRequest(); - request.open("POST", url); - request.upload.onprogress = (event) => { - if (!event.lengthComputable || event.total <= 0) { - return; - } - onProgress(Math.max(0, Math.min(100, Math.round((event.loaded / event.total) * 100)))); - }; - request.onload = () => { - let payload: unknown = null; - try { - payload = request.responseText ? JSON.parse(request.responseText) : null; - } catch { - payload = null; - } - if (request.status >= 200 && request.status < 300 && payload) { - onProgress(100); - resolve(payload as SourceRecording); - return; - } - const message = - payload && typeof payload === "object" && "detail" in payload - ? String((payload as { detail?: unknown }).detail) - : `Upload failed: ${request.status}`; - reject(new ApiError(message, request.status, url)); - }; - request.onerror = () => { - reject(new ApiError("Upload failed before the server responded.", request.status || 0, url)); - }; - const formData = new FormData(); - formData.append("file", file, file.name); - request.send(formData); - }); -} - -export async function fetchProjectSlices(projectId: string): Promise { - const response = await fetch(`${API_BASE}/api/projects/${projectId}/slices`); - return await parseJson(response); -} - -export async function fetchProjectSourceRecordings(projectId: string): Promise { - return await requestJson(`${API_BASE}/api/projects/${projectId}/source-recordings`); -} - -export async function fetchDatasetPreflight(options?: { - artifactRoot?: string; - asrModel?: string; - asrModelPath?: string; - asrCacheDir?: string; - asrDevice?: string; - asrComputeType?: string; -}): Promise { - const url = new URL(`${API_BASE}/api/system/preflight`); - if (options?.artifactRoot) url.searchParams.set("artifact_root", options.artifactRoot); - if (options?.asrModel) url.searchParams.set("asr_model", options.asrModel); - if (options?.asrModelPath) url.searchParams.set("asr_model_path", options.asrModelPath); - if (options?.asrCacheDir) url.searchParams.set("asr_cache_dir", options.asrCacheDir); - if (options?.asrDevice) url.searchParams.set("asr_device", options.asrDevice); - if (options?.asrComputeType) url.searchParams.set("asr_compute_type", options.asrComputeType); - return await requestJson(url.toString()); -} - -export async function fetchProjectDatasetRuns(projectId: string): Promise { - return await requestJson(`${API_BASE}/api/projects/${projectId}/dataset-runs`); -} - -export async function createProjectDatasetRun( - projectId: string, - payload: DatasetRunCreateRequest, -): Promise { - return await requestJson(`${API_BASE}/api/projects/${projectId}/dataset-runs`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); -} - -export async function startDatasetRun(runId: string): Promise { - return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/start`, { method: "POST" }); -} - -export async function refreshDatasetRun(runId: string): Promise { - return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/refresh`, { method: "POST" }); -} - -export async function fetchDatasetRunLog(runId: string): Promise { - return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/log`); -} - -export async function fetchDatasetSpeakerResults(runId: string): Promise { - return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/speakers`); -} - -export async function saveDatasetSpeakerSelection( - runId: string, - targetSpeakerId: string, -): Promise { - return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/speaker-selection`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ target_speaker_id: targetSpeakerId }), - }); -} - -export async function resumeDatasetRunProcessing( - runId: string, - stopAfter: "buffers" | "normalization" | "mfa" | "alignment_qc" = "alignment_qc", -): Promise { - return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/resume-processing`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ stop_after: stopAfter }), - }); -} - -export async function rerunDatasetSlicer(runId: string, config: Record): Promise { - return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/slicer-rerun`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ config }), - }); -} - -export async function fetchDatasetSlicerResults(runId: string): Promise { - return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/slicer-results`); -} - -export async function rerunDatasetNativeExport(runId: string, config: Record = {}): Promise { - return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/export-rerun`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ config }), - }); -} - -export async function fetchDatasetExportResults(runId: string): Promise { - return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/export-results`); -} - -export async function fetchDatasetQc(runId: string): Promise { - return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/qc`); -} - -export async function fetchDatasetClipLab(runId: string): Promise { - return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/clip-lab`); -} - -export async function patchDatasetClipLabClip( - runId: string, - clipId: string, - payload: DatasetClipLabPatchRequest, -): Promise { - return await requestJson( - `${API_BASE}/api/dataset-runs/${runId}/clips/${encodeURIComponent(clipId)}/clip-lab`, - { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }, - ); -} - -export async function appendDatasetAudioOperation( - runId: string, - clipId: string, - payload: DatasetClipLabAudioOperationRequest, -): Promise { - return await requestJson( - `${API_BASE}/api/dataset-runs/${runId}/clips/${encodeURIComponent(clipId)}/audio/operations`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }, - ); -} - -export async function undoDatasetAudioOperation( - runId: string, - clipId: string, - payload: DatasetClipLabAudioStackRequest, -): Promise { - return await requestJson( - `${API_BASE}/api/dataset-runs/${runId}/clips/${encodeURIComponent(clipId)}/audio/undo`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }, - ); -} - -export async function redoDatasetAudioOperation( - runId: string, - clipId: string, - payload: DatasetClipLabAudioStackRequest, -): Promise { - return await requestJson( - `${API_BASE}/api/dataset-runs/${runId}/clips/${encodeURIComponent(clipId)}/audio/redo`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }, - ); -} - -export async function fetchDatasetClipLabWaveformPeaks( - peaksPath: string, -): Promise { - const url = peaksPath.startsWith("http") ? peaksPath : `${API_BASE}${peaksPath}`; - return await requestJson(url); -} - -export async function finalizeDatasetQc( - runId: string, - payload: DatasetQcFinalizeRequest, -): Promise { - return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/qc/finalize`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); -} - -export async function generateDatasetQcScores( - runId: string, - options?: { force?: boolean }, -): Promise { - return await requestJson(`${API_BASE}/api/dataset-runs/${runId}/qc/generate`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ force: options?.force ?? false }), - }); -} - -export function buildCandidateReviewAudioUrl(runId: string, clipId: string): string { - return `${API_BASE}/media/dataset-runs/${runId}/candidate-review/${clipId}.wav`; -} - -export function buildNativeExportAudioUrl(runId: string, clipId: string): string { - return `${API_BASE}/media/dataset-runs/${runId}/native-export/${clipId}.wav`; -} - -export function buildSpeakerSampleAudioUrl(runId: string, sampleId: string): string { - return `${API_BASE}/media/dataset-runs/${runId}/speaker-samples/${sampleId}.wav`; -} - -export async function fetchProjectReferenceAssets( - projectId: string, -): Promise { - const response = await fetch(`${API_BASE}/api/projects/${projectId}/reference-assets`); - return await parseJson(response); -} - -export async function fetchProjectReferenceRuns(projectId: string): Promise { - const response = await fetch(`${API_BASE}/api/projects/${projectId}/reference-runs`); - return await parseJson(response); -} - -export async function createReferenceRun( - projectId: string, - payload: { - recording_ids: string[]; - mode?: "zero_shot" | "finetune" | "both"; - target_durations?: number[] | null; - candidate_count_cap?: number; - }, -): Promise { - const response = await fetch(`${API_BASE}/api/projects/${projectId}/reference-runs`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - }); - return await parseJson(response); -} - -export async function fetchReferenceRun(runId: string): Promise { - const response = await fetch(`${API_BASE}/api/reference-runs/${runId}`); - return await parseJson(response); -} - -export async function fetchReferenceRunCandidates( - runId: string, - options?: { - offset?: number; - limit?: number; - query?: string; - }, -): Promise { - const url = new URL(`${API_BASE}/api/reference-runs/${runId}/candidates`); - if (options?.offset !== undefined) { - url.searchParams.set("offset", String(options.offset)); - } - if (options?.limit !== undefined) { - url.searchParams.set("limit", String(options.limit)); - } - if (options?.query) { - url.searchParams.set("query", options.query); - } - const response = await fetch(url.toString()); - return await parseJson(response); -} - -export async function rerankReferenceRunCandidates( - runId: string, - payload: { - positive_candidate_ids: string[]; - negative_candidate_ids: string[]; - mode?: string | null; - }, -): Promise { - const response = await fetch(`${API_BASE}/api/reference-runs/${runId}/rerank`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - }); - return await parseJson(response); -} - -export async function fetchReferenceAsset(assetId: string): Promise { - const response = await fetch(`${API_BASE}/api/reference-assets/${assetId}`); - return await parseJson(response); -} - -export async function fetchProjectRecordings(projectId: string): Promise { - const response = await fetch(`${API_BASE}/api/projects/${projectId}/recordings`); - return await parseJson(response); -} - -export async function runProjectTranscription( - projectId: string, - settings: ProjectTranscriptionSettings, -): Promise { - const response = await fetch(`${API_BASE}/api/projects/${projectId}/transcription`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(settings), - }); - return await parseJson(response); -} - -export async function runProjectAlignment( - projectId: string, - settings: ProjectAlignmentSettings, -): Promise { - const response = await fetch(`${API_BASE}/api/projects/${projectId}/alignment`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(settings), - }); - return await parseJson(response); -} - -export async function runProjectPreparation( - projectId: string, - settings: PreparationSettings, -): Promise { - const response = await fetch(`${API_BASE}/api/projects/${projectId}/preparation`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(settings), - }); - return await parseJson(response); -} - -export async function fetchProjectPreparationJobs(projectId: string): Promise { - const response = await fetch(`${API_BASE}/api/projects/${projectId}/preparation-jobs`); - return await parseJson(response); -} - -export async function fetchProcessingJob(jobId: string): Promise { - const response = await fetch(`${API_BASE}/api/jobs/${jobId}`); - return await parseJson(response); -} - -export async function fetchSliceDetail(sliceId: string): Promise { - const response = await fetch(`${API_BASE}/api/slices/${sliceId}`); - return await parseJson(response); -} - -export async function fetchClipLabItem(sliceId: string): Promise { - const response = await fetch(`${API_BASE}/api/slices/${sliceId}/clip-lab`); - return await parseJson(response); -} - -export async function fetchProjectExports(projectId: string): Promise { - const response = await fetch(`${API_BASE}/api/projects/${projectId}/exports`); - return await parseJson(response); -} - -export async function fetchExportPreview(projectId: string): Promise { - const response = await fetch(`${API_BASE}/api/projects/${projectId}/export-preview`); - return await parseJson(response); -} - -export async function runProjectExport(projectId: string): Promise { - const response = await fetch(`${API_BASE}/api/projects/${projectId}/export`, { - method: "POST", - }); - return await parseJson(response); -} - -export async function cleanupProjectMedia(projectId: string): Promise { - const response = await fetch(`${API_BASE}/api/projects/${projectId}/media-cleanup`, { - method: "POST", - }); - return await parseJson(response); -} - -export async function updateClipStatus(clipId: string, status: ReviewStatus): Promise { - const response = await fetch(`${API_BASE}/api/clips/${clipId}/status`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ status }), - }); - return await parseJson(response); -} - -export async function updateClipTranscript( - clipId: string, - modifiedText: string, -): Promise { - const response = await fetch(`${API_BASE}/api/clips/${clipId}/transcript`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ modified_text: modifiedText }), - }); - return await parseJson(response); -} - -export async function updateClipTags( - clipId: string, - tags: { name: string; color: string }[], -): Promise { - const response = await fetch(`${API_BASE}/api/clips/${clipId}/tags`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ tags }), - }); - return await parseJson(response); -} - -export async function saveClipState( - clipId: string, - payload: { - modified_text?: string | null; - tags?: { name: string; color: string }[] | null; - status?: ReviewStatus | null; - message?: string | null; - is_milestone?: boolean; - }, -): Promise { - const response = await fetch(`${API_BASE}/api/clips/${clipId}/save`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - }); - return await parseJson(response); -} - -export async function appendClipEdlOperation( - clipId: string, - payload: { - op: string; - range?: { start_seconds: number; end_seconds: number } | null; - duration_seconds?: number | null; - }, -): Promise { - const response = await fetch(`${API_BASE}/api/clips/${clipId}/edl`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - }); - return await parseJson(response); -} - -export async function undoClip(clipId: string): Promise { - const response = await fetch(`${API_BASE}/api/clips/${clipId}/undo`, { - method: "POST", - }); - return await parseJson(response); -} - -export async function redoClip(clipId: string): Promise { - const response = await fetch(`${API_BASE}/api/clips/${clipId}/redo`, { - method: "POST", - }); - return await parseJson(response); -} - -export async function splitClip(clipId: string, splitAtSeconds: number): Promise { - const response = await fetch(`${API_BASE}/api/clips/${clipId}/split`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ split_at_seconds: splitAtSeconds }), - }); - return await parseJson(response); -} - -export async function mergeWithNextClip(clipId: string): Promise { - const response = await fetch(`${API_BASE}/api/clips/${clipId}/merge-next`, { - method: "POST", - }); - return await parseJson(response); -} - -export async function runClipLabModel( - clipId: string, - generatorModel: string, -): Promise { - const response = await fetch(`${API_BASE}/api/clips/${clipId}/variants/run`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ generator_model: generatorModel }), - }); - return await parseJson(response); -} - -export async function setActiveVariant( - clipId: string, - activeVariantId: string, -): Promise { - const response = await fetch(`${API_BASE}/api/clips/${clipId}/active-variant`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ active_variant_id: activeVariantId }), - }); - return await parseJson(response); -} - -export async function fetchClipLabWaveformPeaks( - sliceId: string, - bins = 120, -): Promise { - const response = await fetch(`${API_BASE}/api/slices/${sliceId}/waveform-peaks?bins=${bins}`); - return await parseJson(response); -} - -export async function markDatasetClipAsReferenceCandidate( - projectId: string, - datasetRunId: string, - payload: { clip_id: string; transcript_text: string }, -): Promise { - const response = await fetch( - `${API_BASE}/api/projects/${projectId}/dataset-runs/${datasetRunId}/reference-clip-candidates`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }, - ); - return await parseJson(response); -} - -export async function saveCurrentSliceAsReference(payload: { - slice_id: string; - name?: string | null; - mood_label?: string | null; -}): Promise { - const response = await fetch(`${API_BASE}/api/reference-assets/from-slice`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - }); - return await parseJson(response); -} - -export async function promoteReferenceCandidate(payload: { - run_id: string; - candidate_id: string; - source_start_seconds?: number | null; - source_end_seconds?: number | null; - name?: string | null; - mood_label?: string | null; -}): Promise { - const response = await fetch(`${API_BASE}/api/reference-assets/from-candidate`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - }); - return await parseJson(response); -} diff --git a/frontend/src/app/(login-chain)/layout.tsx b/frontend/src/app/(login-chain)/layout.tsx new file mode 100644 index 0000000..ac586c6 --- /dev/null +++ b/frontend/src/app/(login-chain)/layout.tsx @@ -0,0 +1,47 @@ +import { Icons } from "@midday/ui/icons"; +import Link from "next/link"; +import { LoginVideoBackground } from "@/components/login-video-background"; + +// Shared shell for the login/onboarding-teaser chain (/login through +// /login6). Lives in a layout (not each page) so the video background + +// testimonials persist across navigation between these pages instead of +// unmounting and remounting (which restarted the video and reset the +// testimonial rotation on every "Continue" click). +// Chain steps read ?project=/&run= from the URL (useSearchParams) — render dynamically. +export const dynamic = "force-dynamic"; + +export default function LoginChainLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ {/* Logo - Fixed position matching website header exactly */} +
+ +
+ + {/* Left Side - Video Background (persists across pages in this chain) */} + + + {/* Right Side - Per-page content */} +
+
+
{children}
+
+
+
+ ); +} diff --git a/frontend/src/app/(login-chain)/login/page.tsx b/frontend/src/app/(login-chain)/login/page.tsx new file mode 100644 index 0000000..4977039 --- /dev/null +++ b/frontend/src/app/(login-chain)/login/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import { ChainContinueButton } from "@/components/chain-continue-button"; + +export const metadata: Metadata = { + title: "Speechcraft", +}; + +// Landing step of the setup wizard. No auth — this is a local single-user +// tool, so there is no Supabase user, no waitlist gate. Just the entry point +// into the ingest -> model -> voice -> transcription chain. +export default function Page() { + return ( + <> +
+

+ Train better voice models. +

+

+ The internal tool every voice AI lab built for themselves. +

+
+ +
+ +
+ + ); +} diff --git a/frontend/src/app/(login-chain)/login2/page.tsx b/frontend/src/app/(login-chain)/login2/page.tsx new file mode 100644 index 0000000..f47cedc --- /dev/null +++ b/frontend/src/app/(login-chain)/login2/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { IngestDropzone } from "@/components/ingest-dropzone"; + +export const metadata: Metadata = { + title: "Login | Midday", +}; + +export default function Page() { + return ; +} diff --git a/frontend/src/app/(login-chain)/login3/page.tsx b/frontend/src/app/(login-chain)/login3/page.tsx new file mode 100644 index 0000000..2cfcdba --- /dev/null +++ b/frontend/src/app/(login-chain)/login3/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { ModelDownload } from "@/components/model-download"; + +export const metadata: Metadata = { + title: "Login | Midday", +}; + +export default function Page() { + return ; +} diff --git a/frontend/src/app/(login-chain)/login4/page.tsx b/frontend/src/app/(login-chain)/login4/page.tsx new file mode 100644 index 0000000..1405cf3 --- /dev/null +++ b/frontend/src/app/(login-chain)/login4/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { VoiceSelection } from "@/components/voice-selection"; + +export const metadata: Metadata = { + title: "Login | Midday", +}; + +export default function Page() { + return ; +} diff --git a/frontend/src/app/(login-chain)/login5/page.tsx b/frontend/src/app/(login-chain)/login5/page.tsx new file mode 100644 index 0000000..ac2ed56 --- /dev/null +++ b/frontend/src/app/(login-chain)/login5/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { TranscriptionProgress } from "@/components/transcription-progress"; + +export const metadata: Metadata = { + title: "Login | Midday", +}; + +export default function Page() { + return ; +} diff --git a/frontend/src/app/(login-chain)/login6/page.tsx b/frontend/src/app/(login-chain)/login6/page.tsx new file mode 100644 index 0000000..8b7149f --- /dev/null +++ b/frontend/src/app/(login-chain)/login6/page.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from "next"; +import { LoginContinueButton } from "@/components/login-continue-button"; + +export const metadata: Metadata = { + title: "Login | Midday", +}; + +export default function Page() { + return ( + <> +
+

+ Train better voice models. +

+

+ The internal tool every voice AI lab built for themselves. +

+
+ +
+ +
+ + ); +} diff --git a/frontend/src/app/(workstation)/lab/page.tsx b/frontend/src/app/(workstation)/lab/page.tsx new file mode 100644 index 0000000..3864def --- /dev/null +++ b/frontend/src/app/(workstation)/lab/page.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import { LabWorkstation } from "@/components/lab/lab-workstation"; + +export const metadata: Metadata = { + title: "Clip Lab | Speechcraft", +}; + +// Inherently dynamic: reads URL search params and fetches live data from the +// FastAPI backend. No value in static prerendering a local workstation page. +export const dynamic = "force-dynamic"; + +export default function LabPage() { + return ( + + + + ); +} diff --git a/frontend/src/app/(workstation)/layout.tsx b/frontend/src/app/(workstation)/layout.tsx new file mode 100644 index 0000000..3d2b344 --- /dev/null +++ b/frontend/src/app/(workstation)/layout.tsx @@ -0,0 +1,11 @@ +// Workstation shell. No sidebar and no auth (local single-user tool). The +// logo, project switcher, export, and re-run all live in the Lab top-bar now; +// the content fills the full width. QC and Lab are a feedback loop toggled +// within the content, not peer destinations. +export default function WorkstationLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}; +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000..bce11df --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -0,0 +1,53 @@ +import "@/components/ui/globals.css"; +import { cn } from "@midday/ui/cn"; +import { Toaster } from "@midday/ui/toaster"; +import type { Metadata } from "next"; +import { Hedvig_Letters_Sans, Hedvig_Letters_Serif } from "next/font/google"; +import type { ReactNode } from "react"; +import { Providers } from "./providers"; + +export const metadata: Metadata = { + title: "Speechcraft", + description: "Turn raw speech recordings into training-ready voice datasets.", +}; + +const hedvigSans = Hedvig_Letters_Sans({ + weight: "400", + subsets: ["latin"], + display: "swap", + variable: "--font-hedvig-sans", +}); + +const hedvigSerif = Hedvig_Letters_Serif({ + weight: "400", + subsets: ["latin"], + display: "swap", + variable: "--font-hedvig-serif", +}); + +export const viewport = { + width: "device-width", + initialScale: 1, + themeColor: [ + { media: "(prefers-color-scheme: light)" }, + { media: "(prefers-color-scheme: dark)" }, + ], +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + + {children} + + + + + ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx new file mode 100644 index 0000000..c8e3ea0 --- /dev/null +++ b/frontend/src/app/page.tsx @@ -0,0 +1,16 @@ +import Link from "next/link"; + +// Placeholder root — replaced when the wizard landing (/login) is ported in +// task 3. Exists so the scaffold boots and routes are reachable during setup. +export default function Home() { + return ( +
+

Speechcraft

+

Scaffold online. Routes:

+ +
+ ); +} diff --git a/frontend/src/app/providers.tsx b/frontend/src/app/providers.tsx new file mode 100644 index 0000000..281b42a --- /dev/null +++ b/frontend/src/app/providers.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ThemeProvider } from "next-themes"; +import { NuqsAdapter } from "nuqs/adapters/next/app"; +import { type ReactNode, useState } from "react"; + +// The entire midday backend layer (Supabase, tRPC, i18n, analytics) is gone. +// A single-user local tool needs only: URL state (nuqs), server-cache +// (react-query, for the FastAPI reads), and theming. +export function Providers({ children }: { children: ReactNode }) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { queries: { staleTime: 60_000, retry: 1 } }, + }), + ); + + return ( + + + + {children} + + + + ); +} diff --git a/frontend/src/components/JobActivityPanel.tsx b/frontend/src/components/JobActivityPanel.tsx deleted file mode 100644 index 59ff4a8..0000000 --- a/frontend/src/components/JobActivityPanel.tsx +++ /dev/null @@ -1,129 +0,0 @@ -export type JobActivityState = "idle" | "running" | "completed" | "failed"; - -export type JobActivityLogLine = { - id: string; - timestamp: string; - message: string; -}; - -export type JobActivity = { - id: string; - name: string; - type: "preparation" | "processing" | "slicing" | "qc" | "export" | "mock"; - state: JobActivityState; - startedAt?: string | null; - completedAt?: string | null; - progressLabel?: string | null; - logs: JobActivityLogLine[]; -}; - -type JobActivityPanelProps = { - title?: string; - job: JobActivity | null; - maxLogLines?: number; -}; - -function formatTime(value?: string | null): string { - if (!value) { - return "Not started"; - } - - return new Date(value).toLocaleString([], { - year: "numeric", - month: "short", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - }); -} - -function getStateLabel(state: JobActivityState): string { - if (state === "idle") { - return "Idle"; - } - - if (state === "running") { - return "Running"; - } - - if (state === "completed") { - return "Completed"; - } - - return "Failed"; -} - -export default function JobActivityPanel({ - title = "Job activity", - job, - maxLogLines = 300, -}: JobActivityPanelProps) { - if (!job) { - return ( -
-
-
-

Activity

-

{title}

-
- Idle -
-

No long-running job is active for this page.

-
- ); - } - - const displayedLogs = job.logs.slice(-maxLogLines); - const hiddenLogCount = Math.max(0, job.logs.length - displayedLogs.length); - - return ( -
-
-
-

{job.type}

-

{job.name}

-
- {getStateLabel(job.state)} -
- -
-
-
Started
-
{formatTime(job.startedAt)}
-
-
-
Progress
-
{job.progressLabel ?? getStateLabel(job.state)}
-
-
-
Finished
-
{formatTime(job.completedAt)}
-
-
- -
- {displayedLogs.length > 0 ? ( - <> - {hiddenLogCount > 0 ? ( -

- ... - Showing latest {displayedLogs.length} lines; {hiddenLogCount} earlier lines hidden. -

- ) : null} - {displayedLogs.map((line) => ( -

- {line.timestamp} - {line.message} -

- ))} - - ) : ( -

- --:-- - Waiting for backend log output. -

- )} -
-
- ); -} diff --git a/frontend/src/components/chain-continue-button.tsx b/frontend/src/components/chain-continue-button.tsx new file mode 100644 index 0000000..893c77f --- /dev/null +++ b/frontend/src/components/chain-continue-button.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { SubmitButton } from "@midday/ui/submit-button"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useState } from "react"; +import type { ReactNode } from "react"; +import { demoEnabled, withDemo } from "@/lib/demo"; + +type Props = { + href: string; + label?: ReactNode; + icon?: ReactNode; +}; + +export function ChainContinueButton({ + href, + label = "Continue", + icon, +}: Props) { + const router = useRouter(); + const searchParams = useSearchParams(); + const [isLoading, setLoading] = useState(false); + + const handleContinue = () => { + setLoading(true); + router.push(withDemo(href, demoEnabled(searchParams))); + }; + + return ( + +
+ {icon} + {label} +
+
+ ); +} diff --git a/frontend/src/components/ingest-dropzone.tsx b/frontend/src/components/ingest-dropzone.tsx new file mode 100644 index 0000000..5dedbd5 --- /dev/null +++ b/frontend/src/components/ingest-dropzone.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { cn } from "@midday/ui/cn"; +import { Progress } from "@midday/ui/progress"; +import { SubmitButton } from "@midday/ui/submit-button"; +import { useToast } from "@midday/ui/use-toast"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useRef, useState } from "react"; +import { type FileRejection, useDropzone } from "react-dropzone"; +import { SpeechcraftApiError } from "@/components/lab/speechcraft-write-api"; +import { demoEnabled, withDemo } from "@/lib/demo"; +import { createProject, makeProjectId, uploadRecording } from "./wizard/wizard-api"; + +type Phase = "idle" | "uploading" | "error"; + +export function IngestDropzone() { + const router = useRouter(); + const searchParams = useSearchParams(); + const demo = demoEnabled(searchParams); + const { toast } = useToast(); + const [phase, setPhase] = useState("idle"); + const [progress, setProgress] = useState(0); + const [current, setCurrent] = useState(0); + const [total, setTotal] = useState(0); + const startedRef = useRef(false); + + const ingest = useCallback( + async (files: File[]) => { + if (startedRef.current || files.length === 0) return; + startedRef.current = true; + + // Demo mode: skip the real upload entirely, just advance the flow. + if (demo) { + router.push(withDemo("/login3?project=demo-review", true)); + return; + } + + setPhase("uploading"); + setTotal(files.length); + setCurrent(0); + setProgress(0); + + const name = + files.length === 1 + ? files[0]!.name.replace(/\.[a-z0-9]+$/i, "") + : `${files.length} recordings`; + const projectId = makeProjectId(files[0]!.name); + + try { + await createProject(projectId, name); + for (let i = 0; i < files.length; i++) { + setCurrent(i + 1); + await uploadRecording(projectId, files[i]!, (pct) => { + // Aggregate progress across all files. + setProgress(Math.round(((i + pct / 100) / files.length) * 100)); + }); + } + setProgress(100); + router.push(withDemo(`/login3?project=${encodeURIComponent(projectId)}`, demo)); + } catch (err) { + startedRef.current = false; + setPhase("error"); + toast({ + variant: "error", + duration: 4000, + title: "Upload failed", + description: + err instanceof SpeechcraftApiError ? err.detail : "Could not ingest the recordings.", + }); + } + }, + [router, toast, demo], + ); + + const onDropRejected = ([reject]: FileRejection[]) => { + if (reject?.errors.find(({ code }) => code === "file-invalid-type")) { + toast({ duration: 2500, variant: "error", title: "Only .wav files are supported." }); + } + }; + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop: (files) => void ingest(files), + onDropRejected, + accept: { "audio/wav": [".wav"], "audio/x-wav": [".wav"] }, + disabled: phase === "uploading", + }); + + if (phase === "uploading") { + return ( + <> +
+

Ingesting your dataset

+

+ Uploading recording {current} of {total}… +

+
+
+ +
+ {progress}% + {progress >= 100 ? "Finishing…" : "Streaming to backend"} +
+
+ + ); + } + + return ( +
evt.stopPropagation() })} className="relative"> +
+

Drop your .wav files here.

+
+ + + +
+

Ingest your dataset

+

+ {phase === "error" + ? "Something went wrong. Drag & drop your .wav files to try again." + : "Drag & drop or upload your .wav recordings."} +

+
+ +
+ document.getElementById("upload-files")?.click()} + className="bg-primary px-6 py-4 text-secondary font-medium flex space-x-2 h-[40px] w-full" + isSubmitting={false} + > + Upload + +
+
+ ); +} diff --git a/frontend/src/components/lab/boundary-tables.tsx b/frontend/src/components/lab/boundary-tables.tsx new file mode 100644 index 0000000..7277179 --- /dev/null +++ b/frontend/src/components/lab/boundary-tables.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@midday/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@midday/ui/table"; +import { useMemo, useState } from "react"; +import { + bestRejected, + riskiestKept, + type KeptSort, + type ManualOverride, + type QcClip, + type RejectedSort, +} from "./qc-logic"; + +function formatScore(score: number | null): string { + return score === null ? "—" : score.toFixed(1); +} + +function formatMargin(margin: number): string { + if (!Number.isFinite(margin)) return "—"; + const sign = margin > 0 ? "+" : ""; + return `${sign}${margin.toFixed(1)}`; +} + +function truncate(text: string, max = 64): string { + return text.length > max ? `${text.slice(0, max - 1)}…` : text; +} + +type BoundaryTablesProps = { + clips: QcClip[]; + transcriptThreshold: number; + speakerThreshold: number; + overrides?: Record; + limit?: number; +}; + +export function RiskiestKeptTable({ + clips, + transcriptThreshold, + speakerThreshold, + overrides = {}, + limit = 10, +}: BoundaryTablesProps) { + const [sort, setSort] = useState("risk"); + + const rows = useMemo( + () => + riskiestKept(clips, transcriptThreshold, speakerThreshold, sort, overrides).slice(0, limit), + [clips, transcriptThreshold, speakerThreshold, sort, overrides, limit], + ); + + return ( +
+
+
+

Riskiest kept

+

+ Accepted clips closest to failing — the ones worth a human ear. +

+
+ +
+ + {rows.length === 0 ? ( +

No accepted clips at these thresholds.

+ ) : ( + + + + Clip + Transcript + Speaker + Risk margin + + + + {rows.map(({ clip, transcriptMargin, speakerMargin, riskMargin }) => ( + + + {clip.clipId} + {clip.trainingText && ( + + {truncate(clip.trainingText)} + + )} + + + {formatScore(clip.transcriptMatch)} + ({formatMargin(transcriptMargin)}) + + + {formatScore(clip.speakerCheck)} + ({formatMargin(speakerMargin)}) + + {formatMargin(riskMargin)} + + ))} + +
+ )} +
+ ); +} + +export function BestRejectedTable({ + clips, + transcriptThreshold, + speakerThreshold, + overrides = {}, + limit = 10, +}: BoundaryTablesProps) { + const [sort, setSort] = useState("closest"); + + const rows = useMemo( + () => + bestRejected(clips, transcriptThreshold, speakerThreshold, sort, overrides).slice(0, limit), + [clips, transcriptThreshold, speakerThreshold, sort, overrides, limit], + ); + + return ( +
+
+
+

Best rejected

+

+ Rejected clips closest to passing — recoverable if thresholds relax. +

+
+ +
+ + {rows.length === 0 ? ( +

No rejected clips match this filter.

+ ) : ( + + + + Clip + Transcript + Speaker + Recovery gap + + + + {rows.map(({ clip, transcriptGap, speakerGap, recoveryGap }) => ( + + + {clip.clipId} + {clip.trainingText && ( + + {truncate(clip.trainingText)} + + )} + + + {formatScore(clip.transcriptMatch)} + {transcriptGap > 0 && ( + (-{transcriptGap.toFixed(1)}) + )} + + + {formatScore(clip.speakerCheck)} + {speakerGap > 0 && ( + (-{speakerGap.toFixed(1)}) + )} + + {recoveryGap.toFixed(1)} + + ))} + +
+ )} +
+ ); +} diff --git a/frontend/src/components/lab/clip-lab-panel.tsx b/frontend/src/components/lab/clip-lab-panel.tsx new file mode 100644 index 0000000..2ce336b --- /dev/null +++ b/frontend/src/components/lab/clip-lab-panel.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { Button } from "@midday/ui/button"; +import { cn } from "@midday/ui/cn"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@midday/ui/dropdown-menu"; +import { Icons } from "@midday/ui/icons"; +import { Separator } from "@midday/ui/separator"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type WaveSurfer from "wavesurfer.js"; +import { type ClipEdit, type LabClip, formatClock, formatSeconds } from "./lab-data"; +import { WaveformPane } from "./waveform-pane"; + +const PLAYBACK_RATES = [0.5, 0.75, 1, 1.25, 1.5, 2] as const; + +type ClipLabPanelProps = { + clip: LabClip; + canUndo: boolean; + canRedo: boolean; + onAccept: () => void; + onReject: () => void; + onAppendEdit: (edit: ClipEdit) => void; + onUndo: () => void; + onRedo: () => void; + onMarkReference: () => void; + onRunModel: () => void; +}; + +export function ClipLabPanel({ + clip, + canUndo, + canRedo, + onAccept, + onReject, + onAppendEdit, + onUndo, + onRedo, + onMarkReference, + onRunModel, +}: ClipLabPanelProps) { + const waveSurferRef = useRef(null); + const [playhead, setPlayhead] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + const [rate, setRate] = useState(1); + const [selectionStart, setSelectionStart] = useState(0); + const [selectionEnd, setSelectionEnd] = useState(0); + const [hover, setHover] = useState(null); + + const duration = clip.durationSeconds; + const selLo = Math.min(selectionStart, selectionEnd); + const selHi = Math.max(selectionStart, selectionEnd); + const hasSelection = selHi - selLo > 0.02; + + // Reset transient state on clip change. + useEffect(() => { + setPlayhead(0); + setSelectionStart(0); + setSelectionEnd(0); + setHover(null); + setIsPlaying(false); + }, [clip.id]); + + const togglePlayback = useCallback(() => { + const ws = waveSurferRef.current; + if (!ws) return; + if (ws.isPlaying()) { + ws.pause(); + return; + } + if (selHi - selLo > 0.02) { + void ws.play(selLo, selHi); + } else { + void ws.play(); + } + }, [selLo, selHi]); + + // Space toggles playback. + useEffect(() => { + const handler = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + if ( + target && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable) + ) { + return; + } + if (event.code === "Space" && !event.metaKey && !event.ctrlKey) { + event.preventDefault(); + togglePlayback(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [togglePlayback]); + + const cycleRate = () => { + const idx = PLAYBACK_RATES.indexOf(rate as (typeof PLAYBACK_RATES)[number]); + const next = PLAYBACK_RATES[(idx + 1) % PLAYBACK_RATES.length]!; + setRate(next); + waveSurferRef.current?.setPlaybackRate(next, true); + }; + + const pausePlayback = () => waveSurferRef.current?.pause(); + + const insertSilence = () => { + pausePlayback(); + onAppendEdit({ op: "insert_silence", startSeconds: playhead, durationSeconds: 0.2 }); + }; + const deleteSelection = () => { + pausePlayback(); + onAppendEdit({ op: "delete_range", startSeconds: selLo, endSeconds: selHi }); + setSelectionStart(0); + setSelectionEnd(0); + }; + const splitClip = () => { + pausePlayback(); + onAppendEdit({ op: "split", startSeconds: hasSelection ? selHi : Number((duration / 2).toFixed(2)) }); + }; + const mergeClip = () => { + pausePlayback(); + onAppendEdit({ op: "merge_next" }); + }; + + return ( +
+
+ + Clip Lab + +
+ {formatSeconds(duration)} + {(clip.sampleRateHz / 1000).toFixed(0)} kHz + {clip.variant} +
+
+ +
+ + + + + + + + + Mark as reference candidate + + Run DeepFilterNet + + +
+ +
+ {clip.audioUrl ? ( + { + setSelectionStart(s); + setSelectionEnd(e); + }} + onCursorChange={setPlayhead} + onHoverChange={setHover} + onReady={(ws) => { + waveSurferRef.current = ws; + ws?.setPlaybackRate(rate, true); + }} + onPlayingChange={setIsPlaying} + /> + ) : ( +
+ No audio for this clip. +
+ )} +
+ +
+ + + + + + + + + + + + + + {hasSelection ? ( + + ) : null} + +
+ + {hasSelection + ? `Sel ${formatClock(selLo)}–${formatClock(selHi)}` + : hover !== null + ? `Hover ${formatClock(hover)}` + : "Sel none"} + + + {formatClock(playhead)} / {formatClock(duration)} + +
+
+
+ ); +} diff --git a/frontend/src/components/lab/clip-queue.tsx b/frontend/src/components/lab/clip-queue.tsx new file mode 100644 index 0000000..770cb88 --- /dev/null +++ b/frontend/src/components/lab/clip-queue.tsx @@ -0,0 +1,261 @@ +"use client"; + +import { Badge } from "@midday/ui/badge"; +import { Button } from "@midday/ui/button"; +import { cn } from "@midday/ui/cn"; +import { Icons } from "@midday/ui/icons"; +import { Input } from "@midday/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@midday/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@midday/ui/select"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useRef } from "react"; +import { + type LabClip, + type MachineBucket, + MACHINE_BUCKET_LABELS, + MACHINE_BUCKET_ORDER, + type ReviewStatus, + REVIEW_STATUS_ORDER, + type SortMode, + SORT_OPTIONS, + STATUS_LABELS, + formatSeconds, +} from "./lab-data"; +import { StatusBadge } from "./status-badge"; + +type ClipQueueProps = { + clips: LabClip[]; + activeClipId: string | null; + search: string; + onSearchChange: (value: string) => void; + statuses: ReviewStatus[]; + tags: string[]; + availableTags: string[]; + buckets: MachineBucket[]; + onToggleStatus: (status: ReviewStatus) => void; + onToggleTag: (tag: string) => void; + onToggleBucket: (bucket: MachineBucket) => void; + onClearFilters: () => void; + sortMode: SortMode; + onSortModeChange: (mode: SortMode) => void; + onSelect: (id: string) => void; +}; + +export function ClipQueue({ + clips, + activeClipId, + search, + onSearchChange, + statuses, + tags, + availableTags, + buckets, + onToggleStatus, + onToggleTag, + onToggleBucket, + onClearFilters, + sortMode, + onSortModeChange, + onSelect, +}: ClipQueueProps) { + const scrollRef = useRef(null); + const activeFilterCount = statuses.length + tags.length + buckets.length; + + const virtualizer = useVirtualizer({ + count: clips.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 104, + overscan: 8, + }); + + const filterSummary = + activeFilterCount > 0 + ? [ + ...statuses.map((s) => STATUS_LABELS[s]), + ...buckets.map((b) => MACHINE_BUCKET_LABELS[b]), + ...tags, + ].join(", ") + : "All slices"; + + return ( + + ); +} diff --git a/frontend/src/components/lab/dataset-health-page.tsx b/frontend/src/components/lab/dataset-health-page.tsx new file mode 100644 index 0000000..5a6e983 --- /dev/null +++ b/frontend/src/components/lab/dataset-health-page.tsx @@ -0,0 +1,276 @@ +"use client"; + +import { Button } from "@midday/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@midday/ui/alert-dialog"; +import { useToast } from "@midday/ui/use-toast"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { BestRejectedTable, RiskiestKeptTable } from "./boundary-tables"; +import { combinedSummary, histogram, unscoredCount, type QcClip } from "./qc-logic"; +import { fetchDatasetQc, type DatasetQcClipApi } from "./speechcraft-api"; +import { finalizeDatasetQc, SpeechcraftApiError } from "./speechcraft-write-api"; +import { ThresholdHistogramChart } from "./threshold-histogram-chart"; + +function toQcClip(api: DatasetQcClipApi): QcClip { + return { + clipId: api.clip_id, + durationSec: api.duration_sec, + transcriptMatch: api.transcript_match, + speakerCheck: api.speaker_check, + reasonCodes: [ + ...api.transcript_reason_codes, + ...api.speaker_reason_codes, + ...api.candidate_reason_codes, + ...api.qc_reason_codes, + ], + trainingText: api.training_text, + }; +} + +function formatDuration(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = Math.round(seconds % 60); + return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`; +} + +const DEMO_CLIPS: QcClip[] = Array.from({ length: 140 }, (_, i) => { + const seed = (i * 9301 + 49297) % 233280; + const rand = seed / 233280; + const transcriptMatch = Math.max(0, Math.min(100, 60 + rand * 45 - 10)); + const speakerCheck = Math.max(0, Math.min(100, 55 + ((seed * 3) % 233280) / 233280 * 45)); + return { + clipId: `demo-clip-${i + 1}`, + durationSec: 3 + rand * 9, + transcriptMatch: i % 17 === 0 ? null : Number(transcriptMatch.toFixed(1)), + speakerCheck: i % 23 === 0 ? null : Number(speakerCheck.toFixed(1)), + reasonCodes: [], + trainingText: "The quick brown fox jumps over the lazy dog near the riverbank.", + }; +}); + +export function DatasetHealthPage({ + runId, + demo = false, +}: { + runId: string | null; + demo?: boolean; +}) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [confirmOpen, setConfirmOpen] = useState(false); + const [committing, setCommitting] = useState(false); + + const { data, isLoading, error } = useQuery({ + queryKey: ["sc-dataset-qc", runId], + queryFn: () => fetchDatasetQc(runId!), + enabled: !demo && !!runId, + staleTime: 30_000, + }); + + const clips = useMemo( + () => (demo ? DEMO_CLIPS : (data?.clips ?? []).map(toQcClip)), + [demo, data], + ); + + const defaults = data?.defaults ?? { transcript_match_threshold: 85, speaker_check_threshold: 70 }; + const initialTranscript = data?.finalized_thresholds?.transcript_match_min ?? defaults.transcript_match_threshold; + const initialSpeaker = data?.finalized_thresholds?.speaker_check_min ?? defaults.speaker_check_threshold; + + const [transcriptThreshold, setTranscriptThreshold] = useState(initialTranscript); + const [speakerThreshold, setSpeakerThreshold] = useState(initialSpeaker); + + // Re-sync local thresholds once when the finalized state actually loads + // (demo mode has no async load, so it's already correct on first render). + const syncKey = demo ? "demo" : data ? "loaded" : "pending"; + const [lastSyncKey, setLastSyncKey] = useState(null); + if (syncKey !== "pending" && syncKey !== lastSyncKey) { + setTranscriptThreshold(initialTranscript); + setSpeakerThreshold(initialSpeaker); + setLastSyncKey(syncKey); + } + + const transcriptBins = useMemo(() => histogram(clips, (c) => c.transcriptMatch), [clips]); + const speakerBins = useMemo(() => histogram(clips, (c) => c.speakerCheck), [clips]); + + const transcriptAccepted = useMemo( + () => clips.filter((c) => (c.transcriptMatch ?? -1) >= transcriptThreshold), + [clips, transcriptThreshold], + ); + const speakerAccepted = useMemo( + () => clips.filter((c) => (c.speakerCheck ?? -1) >= speakerThreshold), + [clips, speakerThreshold], + ); + + const summary = useMemo( + () => combinedSummary(clips, transcriptThreshold, speakerThreshold), + [clips, transcriptThreshold, speakerThreshold], + ); + + const ready = demo || (data?.ready ?? false); + const qcNotReady = !demo && data && !data.ready; + + const handleCommit = async () => { + if (!runId) return; + setCommitting(true); + try { + const result = await finalizeDatasetQc(runId, { + transcript_match_min: transcriptThreshold, + speaker_check_min: speakerThreshold, + }); + toast({ + title: "Thresholds committed", + description: `${result.summary.accepted_count} clips accepted (${formatDuration(result.summary.accepted_duration_sec)}). Export is now stale.`, + variant: "success", + duration: 4000, + }); + queryClient.invalidateQueries({ queryKey: ["sc-dataset-qc", runId] }); + queryClient.invalidateQueries({ queryKey: ["sc-cliplab", runId] }); + } catch (err) { + toast({ + title: "Commit failed", + description: err instanceof SpeechcraftApiError ? err.detail : String(err), + variant: "error", + duration: 4500, + }); + } finally { + setCommitting(false); + setConfirmOpen(false); + } + }; + + if (!demo && !runId) { + return ( +
+

No dataset run selected yet.

+
+ ); + } + + if (!demo && isLoading) { + return ( +
+ Loading dataset health… +
+ ); + } + + if (!demo && error) { + return ( +
+
+

Couldn't load QC

+

+ {error instanceof SpeechcraftApiError ? error.detail : String(error)} +

+
+
+ ); + } + + if (qcNotReady) { + return ( +
+
+

QC hasn't run yet

+

+ {data?.missing_artifacts.length + ? `Missing: ${data.missing_artifacts.join(", ")}.` + : "Run transcript and speaker QC for this run to see dataset health."} +

+
+
+ ); + } + + return ( +
+
+
+
+ {summary.acceptedCount} of {clips.length} clips pass both gates at these thresholds ·{" "} + {formatDuration(summary.acceptedDurationSec)} + {data?.finalized && ( + + (committed: {data.finalized_thresholds?.transcript_match_min}/ + {data.finalized_thresholds?.speaker_check_min}) + + )} +
+ +
+ +
+ c.transcriptMatch)} + acceptedCount={transcriptAccepted.length} + acceptedDurationSec={transcriptAccepted.reduce((sum, c) => sum + c.durationSec, 0)} + /> + c.speakerCheck)} + acceptedCount={speakerAccepted.length} + acceptedDurationSec={speakerAccepted.reduce((sum, c) => sum + c.durationSec, 0)} + /> +
+ +
+ + +
+
+ + + + + Commit these thresholds? + + This writes the verdict for every clip at transcript ≥ {transcriptThreshold} and + speaker ≥ {speakerThreshold}, and invalidates any existing export for this run. + Human overrides in Clip Lab still take priority. + + + + Cancel + void handleCommit()}> + {committing ? "Committing…" : "Commit"} + + + + +
+ ); +} diff --git a/frontend/src/components/lab/diagnostics-drawer.tsx b/frontend/src/components/lab/diagnostics-drawer.tsx new file mode 100644 index 0000000..a07a186 --- /dev/null +++ b/frontend/src/components/lab/diagnostics-drawer.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { Badge } from "@midday/ui/badge"; +import { Button } from "@midday/ui/button"; +import { cn } from "@midday/ui/cn"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@midday/ui/collapsible"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@midday/ui/sheet"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { fetchRunLog } from "./speechcraft-api"; + +// Kind-A (process) observability. One run-scoped drawer: run status + the raw +// backend log, shown fully. Auto-surfaces on failure. The optional `stages` +// prop is for the wizard, where each step is a stage with its own status/log +// (current expanded, prior collapsed); the Lab omits it and just shows the +// run log. Backend run-log is a flat tail blob, so per-stage log text only +// exists when a caller supplies it (e.g. per-ProcessingJob in the wizard). + +export type DiagnosticStage = { + key: string; + label: string; + status: "pending" | "running" | "completed" | "failed" | "idle" | string; + logText?: string; +}; + +const STATUS_TONE: Record = { + running: "text-blue-500 border-blue-500/40", + completed: "text-emerald-600 border-emerald-600/40", + failed: "text-red-500 border-red-500/40", + needs_review: "text-amber-500 border-amber-500/40", + pending: "text-muted-foreground", + idle: "text-muted-foreground", +}; + +function LogPane({ text, truncated }: { text: string; truncated?: boolean }) { + return ( +
+ {truncated ? ( +

+ Showing the latest portion of the log (truncated). +

+ ) : null} +
+        {text || "(empty)"}
+      
+
+ ); +} + +function StageRow({ stage }: { stage: DiagnosticStage }) { + const [open, setOpen] = useState( + stage.status === "running" || stage.status === "failed", + ); + return ( + + + + + + {stage.logText ? ( +
+            {stage.logText}
+          
+ ) : ( +

+ No stage-specific log — see the full backend log below. +

+ )} +
+
+ ); +} + +export function DiagnosticsSheet({ + runId, + runStatus, + runStage, + stages, + triggerClassName, +}: { + runId: string | null; + runStatus?: string; + runStage?: string; + stages?: DiagnosticStage[]; + triggerClassName?: string; +}) { + const [open, setOpen] = useState(false); + const [autoOpenedFor, setAutoOpenedFor] = useState(null); + + // Break-glass: surface automatically the first time a run fails. + useEffect(() => { + if (runStatus === "failed" && runId && autoOpenedFor !== runId) { + setOpen(true); + setAutoOpenedFor(runId); + } + }, [runStatus, runId, autoOpenedFor]); + + const { + data: log, + isLoading, + refetch, + isFetching, + } = useQuery({ + queryKey: ["sc-runlog", runId], + queryFn: () => fetchRunLog(runId!), + enabled: open && !!runId, + refetchInterval: open && runStatus === "running" ? 4000 : false, + }); + + return ( + + + + + + + Diagnostics +
+ {runId ?? "—"} + {runStage ? ( + <> + · + {runStage.replace(/_/g, " ")} + + ) : null} + {runStatus ? ( + + {runStatus} + + ) : null} +
+
+ + {stages && stages.length > 0 ? ( +
{stages.map((s) => )}
+ ) : null} + +
+ + Backend log + + +
+ + {!runId ? ( +

No active run.

+ ) : isLoading ? ( +

Loading log…

+ ) : ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/lab/export-dialog.tsx b/frontend/src/components/lab/export-dialog.tsx new file mode 100644 index 0000000..bb38f4c --- /dev/null +++ b/frontend/src/components/lab/export-dialog.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { Button } from "@midday/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@midday/ui/dialog"; +import { Label } from "@midday/ui/label"; +import { RadioGroup, RadioGroupItem } from "@midday/ui/radio-group"; +import { Separator } from "@midday/ui/separator"; +import { Spinner } from "@midday/ui/spinner"; +import { Switch } from "@midday/ui/switch"; +import { useToast } from "@midday/ui/use-toast"; +import { useState } from "react"; + +type ExportDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +const FORMATS = [ + { + value: "ljspeech", + label: "LJSpeech", + hint: "metadata.csv + wavs/ — the common single-speaker TTS layout.", + }, + { + value: "jsonl", + label: "JSONL manifest", + hint: "One JSON line per clip (audio path, text, speaker, duration).", + }, + { + value: "pairs", + label: "WAV + txt pairs", + hint: "A .wav and matching .txt transcript per accepted clip.", + }, +] as const; + +export function ExportDialog({ open, onOpenChange }: ExportDialogProps) { + const { toast, update } = useToast(); + const [format, setFormat] = useState("ljspeech"); + const [includeRejected, setIncludeRejected] = useState(false); + const [normalizeLoudness, setNormalizeLoudness] = useState(true); + const [isExporting, setIsExporting] = useState(false); + + const startExport = () => { + setIsExporting(true); + onOpenChange(false); + const { id } = toast({ + title: "Exporting dataset", + description: `Compiling accepted clips → ${format}.`, + variant: "progress", + progress: 0, + duration: Number.POSITIVE_INFINITY, + }); + const started = Date.now(); + const interval = setInterval(() => { + const pct = Math.min(100, Math.round(((Date.now() - started) / 4000) * 100)); + update(id, { id, progress: pct }); + if (pct >= 100) { + clearInterval(interval); + setIsExporting(false); + update(id, { + id, + title: "Export complete", + description: "Your dataset is ready in the project's exports folder.", + variant: "success", + duration: 3000, + }); + } + }, 120); + }; + + return ( + + +
+ + Export dataset + + Compiles your accepted clips and their final transcripts into a + training-ready dataset. + + + +
+
+ + + {FORMATS.map((f) => ( + + ))} + +
+ + + +
+
+
+

Normalize loudness

+

Even out clip levels on export.

+
+ +
+
+
+

Include rejected clips

+

+ Off by default — only accepted clips ship. +

+
+ +
+
+ + + +
+ + +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/lab/inspector-rail.tsx b/frontend/src/components/lab/inspector-rail.tsx new file mode 100644 index 0000000..28dc208 --- /dev/null +++ b/frontend/src/components/lab/inspector-rail.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { Badge } from "@midday/ui/badge"; +import { Button } from "@midday/ui/button"; +import { cn } from "@midday/ui/cn"; +import { Progress } from "@midday/ui/progress"; +import { Separator } from "@midday/ui/separator"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@midday/ui/tabs"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@midday/ui/tooltip"; +import { + type LabClip, + type ReviewStatus, + MACHINE_BUCKET_LABELS, + REASON_CODE_LABELS, + REVIEW_STATUS_ORDER, + STATUS_LABELS, + formatDurationCompact, + formatSeconds, +} from "./lab-data"; +import { StatusBadge } from "./status-badge"; + +type Stats = { + total: number; + reviewed: number; + counts: Record; +}; + +type InspectorRailProps = { + clip: LabClip; + stats: Stats; + onStatusChange: (status: ReviewStatus) => void; + onSaveReference: () => void; +}; + +function StatRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +export function InspectorRail({ + clip, + stats, + onStatusChange, + onSaveReference, +}: InspectorRailProps) { + const reviewedPercent = stats.total > 0 ? (stats.reviewed / stats.total) * 100 : 0; + + return ( + + + + ); +} diff --git a/frontend/src/components/lab/keyboard-bar.tsx b/frontend/src/components/lab/keyboard-bar.tsx new file mode 100644 index 0000000..a580fc2 --- /dev/null +++ b/frontend/src/components/lab/keyboard-bar.tsx @@ -0,0 +1,33 @@ +// Presentational keyboard legend, mirroring Midday's shortcut convention +// (small bordered keycap pills, e.g. transaction-shortcuts.tsx). The actual +// hotkeys are wired in LabWorkstation via react-hotkeys-hook. + +const SHORTCUTS: { keys: string[]; label: string }[] = [ + { keys: ["Space"], label: "Play / pause" }, + { keys: ["Enter"], label: "Accept & next" }, + { keys: ["⇧", "Enter"], label: "Reject & next" }, + { keys: ["↑", "↓"], label: "Prev / next clip" }, + { keys: ["⌘", "Z"], label: "Undo" }, +]; + +export function KeyboardBar() { + return ( +
+ {SHORTCUTS.map((shortcut) => ( +
+ + {shortcut.keys.map((key) => ( + + {key} + + ))} + + {shortcut.label} +
+ ))} +
+ ); +} diff --git a/frontend/src/components/lab/lab-data.ts b/frontend/src/components/lab/lab-data.ts new file mode 100644 index 0000000..81d8a0b --- /dev/null +++ b/frontend/src/components/lab/lab-data.ts @@ -0,0 +1,325 @@ +// Mock data + types + helpers for the Clip Lab workstation. +// +// This is the no-backend mock phase: clips, projects, peaks, QC scores are +// all synthesized client-side so the full Lab UX can be built and felt +// before the Python/FastAPI backend exists. Shapes intentionally mirror +// speechcraft's real Lab data model (review status, QC buckets, revisions, +// EDL edits, provenance) so wiring the real API later is a swap, not a +// redesign. + +export type ReviewStatus = "unresolved" | "accepted" | "rejected" | "quarantined"; +export type MachineBucket = "auto_kept" | "needs_review" | "auto_rejected"; + +export type ClipRevision = { + id: string; + message: string; + status: ReviewStatus; + transcript: string; + createdAt: string; + milestone: boolean; +}; + +export type ClipEdit = { + op: string; + startSeconds?: number; + endSeconds?: number; + durationSeconds?: number; +}; + +export type LabClip = { + id: string; + order: number; + transcript: string; + originalTranscript: string; + durationSeconds: number; + status: ReviewStatus; + machineBucket: MachineBucket; + qcScore: number; // 0..1 aggregate + transcriptConfidence: number; // 0..100 (0 when unscored — see transcriptMatchRaw) + speakerPurity: number; // 0..100 (0 when unscored — see speakerCheckRaw) + // Raw nullable scores, distinct from the 0-defaulted fields above. null + // means "unscored" (backend returned null) — used wherever "unscored" must + // be told apart from "scored 0", e.g. re-deriving machineBucket when + // thresholds change after the clip was already mapped. + transcriptMatchRaw: number | null; + speakerCheckRaw: number | null; + variant: string; // "source" or a model name + tags: string[]; + reasonCodes: string[]; + peaks: number[]; // mock waveform envelope, 0..1 + sampleRateHz: number; + channels: number; + speaker: string; + language: string; + sourceRecording: string; + originalStartSeconds: number; + originalEndSeconds: number; + revisions: ClipRevision[]; + edits: ClipEdit[]; + // Present when backed by the live speechcraft API (null in mock mode). + audioUrl?: string | null; + waveformPeaksUrl?: string | null; + // Backend optimistic-concurrency + audio-op tokens (live-backed only). + clipVersion?: number; + manifestSha?: string; + effectiveAudioRevisionKey?: string | null; + renderStatus?: "ready" | "pending" | "failed"; + canUndoAudio?: boolean; + canRedoAudio?: boolean; + audioEditOpCount?: number; +}; + +export type LabProject = { + id: string; + name: string; +}; + +export const REVIEW_STATUS_ORDER: ReviewStatus[] = [ + "unresolved", + "accepted", + "rejected", + "quarantined", +]; + +export const STATUS_LABELS: Record = { + unresolved: "Unresolved", + accepted: "Accepted", + rejected: "Rejected", + quarantined: "Quarantined", +}; + +export const MACHINE_BUCKET_LABELS: Record = { + auto_kept: "Auto-kept", + needs_review: "Needs review", + auto_rejected: "Auto-rejected", +}; + +export const MACHINE_BUCKET_ORDER: MachineBucket[] = [ + "auto_kept", + "needs_review", + "auto_rejected", +]; + +export const REASON_CODE_LABELS: Record = { + broken_audio: "Broken audio", + near_silence_unusable_clip: "Near-silent / unusable", + transcript_mismatch: "Transcript mismatch", + severe_clipping_corruption: "Severe clipping / corruption", + overlap_second_speaker: "Second speaker overlap", +}; + +// ── Formatting ────────────────────────────────────────────────────────── + +/** "5.37s" style duration. */ +export function formatSeconds(seconds: number): string { + return `${seconds.toFixed(2)}s`; +} + +/** "03.49" style clock (seconds.centiseconds), matching the reference UI. */ +export function formatClock(seconds: number): string { + const whole = Math.floor(seconds); + const centis = Math.round((seconds - whole) * 100); + return `${whole.toString().padStart(2, "0")}.${centis.toString().padStart(2, "0")}`; +} + +/** Compact "15m 59s" style for aggregate durations. */ +export function formatDurationCompact(totalSeconds: number): string { + const minutes = Math.floor(totalSeconds / 60); + const seconds = Math.round(totalSeconds % 60); + if (minutes > 0) return `${minutes}m ${seconds}s`; + return `${seconds}s`; +} + +/** QC score cards render 0..100 with two decimals (screenshot shows 40.53). */ +export function formatQcScore(value: number): string { + return value.toFixed(2); +} + +// ── Deterministic mock peaks ──────────────────────────────────────────── + +function seededRandom(seed: number): () => number { + let s = seed % 2147483647; + if (s <= 0) s += 2147483646; + return () => { + s = (s * 16807) % 2147483647; + return (s - 1) / 2147483646; + }; +} + +function hashString(input: string): number { + let hash = 0; + for (let i = 0; i < input.length; i++) { + hash = (hash * 31 + input.charCodeAt(i)) & 0x7fffffff; + } + return hash || 1; +} + +/** Speech-like envelope: bursts of energy with gaps, deterministic per seed. */ +export function generatePeaks(seed: string, count = 240): number[] { + const rand = seededRandom(hashString(seed)); + const peaks: number[] = []; + let envelope = 0; + let burstPhase = rand() * Math.PI * 2; + + for (let i = 0; i < count; i++) { + burstPhase += 0.18 + rand() * 0.06; + // Gate creates silence gaps between "words". + const gate = Math.sin(burstPhase) > -0.35 ? 1 : 0.06; + const target = gate * (0.25 + rand() * 0.75); + envelope = envelope * 0.55 + target * 0.45; + peaks.push(Math.min(1, Math.max(0.015, envelope))); + } + return peaks; +} + +// ── Mock projects + clips ─────────────────────────────────────────────── + +export const MOCK_PROJECTS: LabProject[] = [ + { id: "mb-mq3wkz25", name: "mb" }, + { id: "emma-watson-01", name: "Emma Watson" }, + { id: "billie-rae", name: "Hey Billie Rae" }, +]; + +const MOCK_TRANSCRIPTS = [ + "Isn't this must be like stress relieving and Calming what if I just make people", + "thought it was gonna do and wanted it to do and more but um, I wanted this single especially to be kind of like the", + "Everything's like a stepping stone in regards to the singles I put out. You know,", + "have so many lyrical connections Like they all have there's like words that are the same in a lot of them", + "and hearts like hell home with you All the H's and whatever. I was people have said", + "I think the biggest thing for me was just learning how to trust the process a little bit more", + "we spent probably three weeks just on the drum sounds alone which sounds insane now", + "but when you hear it back on a proper system it makes all the difference honestly", + "there's a moment right before the second chorus where everything drops out and it's just the vocal", + "and I remember the engineer looking at me like are you sure you want to do that", + "yeah so that was recorded in a tiny room in the back of my parents house believe it or not", + "the reverb you hear is actually the natural room we didn't add anything to it", + "I wanted it to feel intimate like you're sitting right there in the room with me", + "and honestly some of the takes that made the record were the scratch vocals", + "we tried to re-record them cleaner but they just didn't have the same feeling", + "there's an imperfection to it that I think people connect with more than a perfect take", + "the bridge went through maybe fifteen different versions before we landed on this one", + "at one point it was twice as long and had a whole extra section that we cut", + "sometimes the best thing you can do is take something away rather than add to it", + "I learned that from working with producers who are way more experienced than me", +]; + +const SPEAKERS = ["SPEAKER_00", "SPEAKER_01", "SPEAKER_00", "SPEAKER_02"]; +const REASON_POOL = [ + "transcript_mismatch", + "overlap_second_speaker", + "near_silence_unusable_clip", +]; + +export function createMockClips(count = 42): LabClip[] { + const rand = seededRandom(20260712); + const clips: LabClip[] = []; + + for (let i = 0; i < count; i++) { + const id = `candidate_review_clip_${i.toString().padStart(6, "0")}`; + const transcript = MOCK_TRANSCRIPTS[i % MOCK_TRANSCRIPTS.length]!; + const duration = Number((3 + rand() * 9).toFixed(2)); + const qcScore = Number(rand().toFixed(3)); + + // Machine bucket derived from score, roughly mirroring backend thresholds. + const machineBucket: MachineBucket = + qcScore >= 0.72 ? "auto_kept" : qcScore < 0.35 ? "auto_rejected" : "needs_review"; + + const hasReason = qcScore < 0.4 && rand() > 0.5; + const reasonCodes = hasReason + ? [REASON_POOL[Math.floor(rand() * REASON_POOL.length)]!] + : []; + + const createdAt = new Date(2026, 5, 7, 5, 30, i).toISOString(); + + clips.push({ + id, + order: i + 1, + transcript, + originalTranscript: transcript, + durationSeconds: duration, + status: "unresolved", + machineBucket, + qcScore, + transcriptConfidence: Number((25 + rand() * 70).toFixed(2)), + speakerPurity: Number((60 + rand() * 39).toFixed(2)), + transcriptMatchRaw: Number((25 + rand() * 70).toFixed(2)), + speakerCheckRaw: Number((60 + rand() * 39).toFixed(2)), + variant: "source", + tags: [], + reasonCodes, + peaks: generatePeaks(id), + sampleRateHz: 16000, + channels: 2, + speaker: SPEAKERS[i % SPEAKERS.length]!, + language: "en", + sourceRecording: `recording_${(i % 3) + 1}`, + originalStartSeconds: Number((i * 6.4).toFixed(2)), + originalEndSeconds: Number((i * 6.4 + duration).toFixed(2)), + revisions: [ + { + id: `${id}-rev-0`, + message: "Dataset candidate clip", + status: "unresolved", + transcript, + createdAt, + milestone: false, + }, + ], + edits: [], + }); + } + + return clips; +} + +// ── Sorting + filtering ───────────────────────────────────────────────── + +export type SortMode = "source" | "qc_desc" | "qc_asc" | "status"; + +export const SORT_OPTIONS: { value: SortMode; label: string }[] = [ + { value: "source", label: "Source Order" }, + { value: "qc_desc", label: "QC Score (high → low)" }, + { value: "qc_asc", label: "QC Score (low → high)" }, + { value: "status", label: "Review Status" }, +]; + +const STATUS_PRIORITY: Record = { + unresolved: 0, + quarantined: 1, + rejected: 2, + accepted: 3, +}; + +export function sortClips(clips: LabClip[], mode: SortMode): LabClip[] { + const copy = [...clips]; + switch (mode) { + case "qc_desc": + return copy.sort((a, b) => b.qcScore - a.qcScore); + case "qc_asc": + return copy.sort((a, b) => a.qcScore - b.qcScore); + case "status": + return copy.sort( + (a, b) => + STATUS_PRIORITY[a.status] - STATUS_PRIORITY[b.status] || a.order - b.order, + ); + default: + return copy.sort((a, b) => a.order - b.order); + } +} + +export function filterClips( + clips: LabClip[], + search: string, + statuses: ReviewStatus[], + tags: string[], + buckets: MachineBucket[] = [], +): LabClip[] { + const query = search.trim().toLowerCase(); + return clips.filter((clip) => { + if (query && !clip.transcript.toLowerCase().includes(query)) return false; + if (statuses.length > 0 && !statuses.includes(clip.status)) return false; + if (tags.length > 0 && !tags.some((tag) => clip.tags.includes(tag))) return false; + if (buckets.length > 0 && !buckets.includes(clip.machineBucket)) return false; + return true; + }); +} diff --git a/frontend/src/components/lab/lab-workstation.tsx b/frontend/src/components/lab/lab-workstation.tsx new file mode 100644 index 0000000..84f344f --- /dev/null +++ b/frontend/src/components/lab/lab-workstation.tsx @@ -0,0 +1,716 @@ +"use client"; + +import { cn } from "@midday/ui/cn"; +import { Button } from "@midday/ui/button"; +import { Icons } from "@midday/ui/icons"; +import { useToast } from "@midday/ui/use-toast"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { ClipLabPanel } from "./clip-lab-panel"; +import { ClipQueue } from "./clip-queue"; +import { DatasetHealthPage } from "./dataset-health-page"; +import { DiagnosticsSheet } from "./diagnostics-drawer"; +import { ExportDialog } from "./export-dialog"; +import { InspectorRail } from "./inspector-rail"; +import { KeyboardBar } from "./keyboard-bar"; +import { ProjectPicker } from "./project-picker"; +import { LabRerunDialog } from "./rerun-dialog"; +import { TranscriptPanel } from "./transcript-panel"; +import { + type ClipEdit, + type LabClip, + type MachineBucket, + type ReviewStatus, + type SortMode, + createMockClips, + filterClips, + sortClips, +} from "./lab-data"; +import { demoEnabled } from "@/lib/demo"; +import { + effectiveQcThresholds, + fetchClipLabView, + fetchDatasetQc, + fetchDatasetRuns, + fetchProjects, + mapApiClip, + pickReviewableRun, +} from "./speechcraft-api"; +import { + type DatasetAudioEditOperation, + type DatasetClipLabClipView, + SpeechcraftApiError, + appendAudioOperation, + markReferenceClipCandidate, + patchClipLab, + redoAudioOperation, + undoAudioOperation, +} from "./speechcraft-write-api"; + +const PRESET_TAGS = [ + "mispronunciation", + "background noise", + "clipping", + "overlap", + "filler word", + "breath", +]; + +type Mode = "qc" | "lab"; + +/** Optimistic-concurrency tokens the backend requires on every clip write. */ +function tokensFor(clip: LabClip) { + return { + expected_manifest_sha256: clip.manifestSha as string, + expected_clip_version: clip.clipVersion as number, + }; +} + +const isLiveBacked = (clip: LabClip) => + clip.manifestSha != null && clip.clipVersion != null; + +/** Fold an authoritative server clip view back into the local working copy. */ +function mergeServerClip(prev: LabClip, s: DatasetClipLabClipView): LabClip { + return { + ...prev, + status: s.review_status, + transcript: s.transcript_override ?? s.transcript ?? prev.transcript, + originalTranscript: s.original_transcript ?? prev.originalTranscript, + tags: s.reviewer_tags ?? prev.tags, + durationSeconds: s.current_duration_sec ?? prev.durationSeconds, + transcriptConfidence: s.transcript_match ?? prev.transcriptConfidence, + speakerPurity: s.speaker_check ?? prev.speakerPurity, + reasonCodes: (s.pipeline_findings ?? []).map((f) => f.code), + variant: s.effective_audio_kind ?? prev.variant, + clipVersion: s.clip_version, + effectiveAudioRevisionKey: s.effective_audio_revision_key, + renderStatus: s.render_status, + canUndoAudio: s.can_undo_audio, + canRedoAudio: s.can_redo_audio, + audioEditOpCount: s.audio_edit_op_count, + }; +} + +export function LabWorkstation() { + const searchParams = useSearchParams(); + const { toast, update } = useToast(); + const queryClient = useQueryClient(); + const demo = demoEnabled(searchParams); + + // ── Real data chain: projects → dataset runs → clip-lab view ── + const { data: projects = [] } = useQuery({ + queryKey: ["sc-projects"], + queryFn: fetchProjects, + staleTime: 60_000, + enabled: !demo, + }); + + const paramProjectId = searchParams.get("project"); + const activeProject = + projects.find((p) => p.id === paramProjectId) ?? projects[0] ?? null; + const projectId = activeProject?.id ?? null; + + const { data: runs = [], isLoading: runsLoading } = useQuery({ + queryKey: ["sc-runs", projectId], + queryFn: () => fetchDatasetRuns(projectId!), + enabled: !demo && !!projectId, + staleTime: 60_000, + }); + + const run = useMemo(() => pickReviewableRun(runs), [runs]); + const runId = run?.id ?? null; + + const { + data: view, + isLoading: viewLoading, + error: viewError, + } = useQuery({ + queryKey: ["sc-cliplab", runId], + queryFn: () => fetchClipLabView(runId!), + enabled: !demo && !!runId, + staleTime: Number.POSITIVE_INFINITY, + }); + + // Committed (or default) QC thresholds — drives machineBucket below so + // "auto_kept" in Clip Lab means whatever Dataset Health has committed, + // not a hardcoded guess. Refetched whenever Dataset Health commits. + const { data: qcPayload } = useQuery({ + queryKey: ["sc-dataset-qc", runId], + queryFn: () => fetchDatasetQc(runId!), + enabled: !demo && !!runId, + staleTime: 30_000, + }); + const qcThresholds = useMemo(() => effectiveQcThresholds(qcPayload), [qcPayload]); + + // ── Local (optimistic) working copy, seeded per run ── + const [mode, setMode] = useState("lab"); + const [exportOpen, setExportOpen] = useState(false); + const [clips, setClips] = useState([]); + const [activeClipId, setActiveClipId] = useState(null); + const [search, setSearch] = useState(""); + const [filterStatuses, setFilterStatuses] = useState([]); + const [filterTags, setFilterTags] = useState([]); + const [sortMode, setSortMode] = useState("source"); + const [filterBuckets, setFilterBuckets] = useState([]); + const seededRunRef = useRef(null); + + useEffect(() => { + if (!demo && view && runId && seededRunRef.current !== runId) { + const mapped = view.clips.map((clip, index) => + mapApiClip(clip, index, runId, view.candidate_manifest_sha256, qcThresholds), + ); + setClips(mapped); + setActiveClipId(mapped[0]?.id ?? null); + setSearch(""); + setFilterStatuses([]); + setFilterTags([]); + setFilterBuckets([]); + seededRunRef.current = runId; + } + // qcThresholds intentionally excluded: this effect only seeds once per + // run. Threshold changes after seeding are handled by the re-derive + // effect below so in-progress edits aren't clobbered. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [view, runId, demo]); + + // Committed thresholds can change after clips are already seeded (Dataset + // Health commits mid-session). Re-derive machineBucket/qcScore in place — + // never touch review status, transcript, or audio edit state. + useEffect(() => { + if (demo) return; + setClips((prev) => + prev.map((clip) => { + const scoresKnown = clip.transcriptMatchRaw != null && clip.speakerCheckRaw != null; + const hardFailed = clip.reasonCodes.length > 0; + const passesGate = + scoresKnown && + (clip.transcriptMatchRaw as number) >= qcThresholds.transcriptMatchMin && + (clip.speakerCheckRaw as number) >= qcThresholds.speakerCheckMin; + const machineBucket: MachineBucket = !scoresKnown + ? "needs_review" + : hardFailed || !passesGate + ? "auto_rejected" + : "auto_kept"; + const qcScore = scoresKnown + ? Math.max( + 0, + Math.min( + 1, + Math.min( + (clip.transcriptMatchRaw as number) / qcThresholds.transcriptMatchMin, + (clip.speakerCheckRaw as number) / qcThresholds.speakerCheckMin, + ), + ), + ) + : 0; + if (clip.machineBucket === machineBucket && clip.qcScore === qcScore) return clip; + return { ...clip, machineBucket, qcScore: Number(qcScore.toFixed(3)) }; + }), + ); + }, [qcThresholds, demo]); + + // Demo / UI-review mode: seed from mock clips, no backend. Writes stay local + // (mock clips have no manifestSha/clipVersion, so applyClipWrite is a no-op + // beyond the optimistic update). + useEffect(() => { + if (demo && seededRunRef.current !== "demo") { + const mock = createMockClips(); + setClips(mock); + setActiveClipId(mock[0]?.id ?? null); + seededRunRef.current = "demo"; + } + }, [demo]); + + const visibleClips = useMemo( + () => sortClips(filterClips(clips, search, filterStatuses, filterTags, filterBuckets), sortMode), + [clips, search, filterStatuses, filterTags, filterBuckets, sortMode], + ); + + const activeClip = useMemo( + () => clips.find((c) => c.id === activeClipId) ?? null, + [clips, activeClipId], + ); + + const availableTags = useMemo(() => { + const set = new Set(); + for (const clip of clips) for (const tag of clip.tags) set.add(tag); + return Array.from(set).sort(); + }, [clips]); + + const allTags = useMemo( + () => Array.from(new Set([...PRESET_TAGS, ...availableTags])), + [availableTags], + ); + + const stats = useMemo(() => { + const counts: Record = { + unresolved: 0, + accepted: 0, + rejected: 0, + quarantined: 0, + }; + for (const clip of clips) counts[clip.status] += 1; + return { total: clips.length, reviewed: clips.length - counts.unresolved, counts }; + }, [clips]); + + const updateClip = useCallback((id: string, fn: (clip: LabClip) => LabClip) => { + setClips((prev) => prev.map((clip) => (clip.id === id ? fn(clip) : clip))); + }, []); + + // Refetch the authoritative clip-lab view and reseed (used after a 409 stale). + const reseedFromServer = useCallback(async () => { + if (!runId) return; + const fresh = await fetchClipLabView(runId); + const mapped = fresh.clips.map((clip, index) => + mapApiClip(clip, index, runId, fresh.candidate_manifest_sha256), + ); + queryClient.setQueryData(["sc-cliplab", runId], fresh); + setClips(mapped); + }, [runId, queryClient]); + + // Optimistic write: apply locally, call backend, reconcile with the returned + // authoritative clip (crucially, its new clip_version). On a stale 409, + // reload the view; on any other failure, revert and surface the error. + const applyClipWrite = useCallback( + async ( + clipId: string, + optimistic: (clip: LabClip) => LabClip, + call: (clip: LabClip) => Promise, + label: string, + ) => { + const snapshot = clips.find((c) => c.id === clipId); + if (!snapshot) return; + updateClip(clipId, optimistic); + if (!isLiveBacked(snapshot)) return; // mock / not-yet-live: local only + try { + const server = await call(snapshot); + updateClip(clipId, (c) => mergeServerClip(c, server)); + } catch (err) { + if (err instanceof SpeechcraftApiError && err.isStale) { + toast({ + title: "Clip changed elsewhere", + description: "Reloaded the latest version — reapply your change.", + variant: "error", + duration: 3500, + }); + await reseedFromServer(); + return; + } + updateClip(clipId, () => snapshot); // revert + toast({ + title: `${label} failed`, + description: + err instanceof SpeechcraftApiError ? err.detail : String(err), + variant: "error", + duration: 4000, + }); + } + }, + [clips, updateClip, toast, reseedFromServer], + ); + + const nextClipIdRef = useRef(null); + nextClipIdRef.current = (() => { + if (!activeClipId) return null; + const idx = visibleClips.findIndex((c) => c.id === activeClipId); + return idx >= 0 ? (visibleClips[idx + 1]?.id ?? null) : null; + })(); + + const decide = useCallback( + (status: ReviewStatus) => { + if (!activeClipId) return; + const nextId = nextClipIdRef.current; + void applyClipWrite( + activeClipId, + (clip) => ({ + ...clip, + status, + revisions: [ + ...clip.revisions, + { + id: `${clip.id}-rev-${clip.revisions.length}`, + message: + status === "accepted" + ? "Accepted milestone" + : status === "rejected" + ? "Rejected milestone" + : `${status} milestone`, + status, + transcript: clip.transcript, + createdAt: new Date().toISOString(), + milestone: true, + }, + ], + }), + (clip) => + patchClipLab(runId!, clip.id, { + ...tokensFor(clip), + review_status: status, + }), + "Status update", + ); + if (nextId) setActiveClipId(nextId); + }, + [activeClipId, applyClipWrite, runId], + ); + + const commitStatus = useCallback( + (status: ReviewStatus) => { + if (!activeClipId) return; + void applyClipWrite( + activeClipId, + (clip) => ({ ...clip, status }), + (clip) => + patchClipLab(runId!, clip.id, { ...tokensFor(clip), review_status: status }), + "Status update", + ); + }, + [activeClipId, applyClipWrite, runId], + ); + + const commitTranscript = useCallback( + (text: string) => { + if (!activeClipId) return; + void applyClipWrite( + activeClipId, + (clip) => ({ ...clip, transcript: text }), + (clip) => + patchClipLab(runId!, clip.id, { + ...tokensFor(clip), + transcript_override: text, + }), + "Transcript save", + ); + }, + [activeClipId, applyClipWrite, runId], + ); + + const commitTags = useCallback( + (tags: string[]) => { + if (!activeClipId) return; + void applyClipWrite( + activeClipId, + (clip) => ({ ...clip, tags }), + (clip) => + patchClipLab(runId!, clip.id, { ...tokensFor(clip), reviewer_tags: tags }), + "Tag update", + ); + }, + [activeClipId, applyClipWrite, runId], + ); + + const navigate = useCallback( + (direction: "prev" | "next") => { + if (!activeClipId) return; + const idx = visibleClips.findIndex((c) => c.id === activeClipId); + if (idx === -1) return; + const target = visibleClips[idx + (direction === "next" ? 1 : -1)]; + if (target) setActiveClipId(target.id); + }, + [activeClipId, visibleClips], + ); + + const appendEdit = useCallback( + (edit: ClipEdit) => { + if (!activeClipId) return; + const clip = clips.find((c) => c.id === activeClipId); + if (!clip) return; + const sr = clip.sampleRateHz || 16000; + let operation: DatasetAudioEditOperation | null = null; + if (edit.op === "delete_range" && edit.startSeconds != null && edit.endSeconds != null) { + operation = { + kind: "delete_range", + start_sample: Math.round(edit.startSeconds * sr), + end_sample: Math.round(edit.endSeconds * sr), + }; + } else if ( + edit.op === "insert_silence" && + edit.startSeconds != null && + edit.durationSeconds != null + ) { + operation = { + kind: "insert_silence", + at_sample: Math.round(edit.startSeconds * sr), + duration_samples: Math.round(edit.durationSeconds * sr), + }; + } + if (!operation) { + // split / merge_next are not backend audio operations. + toast({ + title: "Not supported yet", + description: `"${edit.op}" isn't a backend audio operation.`, + variant: "error", + duration: 3000, + }); + return; + } + const backendOp = operation; + void applyClipWrite( + activeClipId, + (c) => ({ ...c, edits: [...c.edits, edit] }), + (c) => appendAudioOperation(runId!, c.id, { ...tokensFor(c), operation: backendOp }), + "Audio edit", + ); + }, + [activeClipId, clips, applyClipWrite, runId, toast], + ); + + const undoEdit = useCallback(() => { + if (!activeClipId) return; + void applyClipWrite( + activeClipId, + (c) => c, + (c) => undoAudioOperation(runId!, c.id, tokensFor(c)), + "Undo", + ); + }, [activeClipId, applyClipWrite, runId]); + + const redoEdit = useCallback(() => { + if (!activeClipId) return; + void applyClipWrite( + activeClipId, + (c) => c, + (c) => redoAudioOperation(runId!, c.id, tokensFor(c)), + "Redo", + ); + }, [activeClipId, applyClipWrite, runId]); + + const markReference = useCallback(() => { + const clip = clips.find((c) => c.id === activeClipId); + if (!clip || !projectId || !runId) return; + void (async () => { + try { + await markReferenceClipCandidate(projectId, runId, { + clip_id: clip.id, + transcript_text: clip.transcript, + }); + toast({ title: "Marked as reference candidate", variant: "success", duration: 2000 }); + } catch (err) { + toast({ + title: "Mark reference failed", + description: err instanceof SpeechcraftApiError ? err.detail : String(err), + variant: "error", + duration: 4000, + }); + } + })(); + }, [clips, activeClipId, projectId, runId, toast]); + + const runModel = useCallback(() => { + if (!activeClip) return; + const clipId = activeClip.id; + const { id } = toast({ + title: "Running DeepFilterNet", + description: "Enhancing slice audio…", + variant: "progress", + progress: 0, + duration: Number.POSITIVE_INFINITY, + }); + const started = Date.now(); + const interval = setInterval(() => { + const pct = Math.min(100, Math.round(((Date.now() - started) / 3500) * 100)); + update(id, { id, progress: pct }); + if (pct >= 100) { + clearInterval(interval); + updateClip(clipId, (clip) => ({ ...clip, variant: "deepfilternet" })); + update(id, { + id, + title: "Enhancement complete", + description: "Activated the DeepFilterNet variant.", + variant: "success", + duration: 2500, + }); + } + }, 120); + }, [activeClip, toast, update, updateClip]); + + const hotkeyEnabled = mode === "lab" && !!activeClipId; + useHotkeys("enter", (e) => { e.preventDefault(); decide("accepted"); }, { enabled: hotkeyEnabled }, [decide]); + useHotkeys("shift+enter", (e) => { e.preventDefault(); decide("rejected"); }, { enabled: hotkeyEnabled }, [decide]); + useHotkeys("up", (e) => { e.preventDefault(); navigate("prev"); }, { enabled: hotkeyEnabled }, [navigate]); + useHotkeys("down", (e) => { e.preventDefault(); navigate("next"); }, { enabled: hotkeyEnabled }, [navigate]); + useHotkeys("meta+z", (e) => { e.preventDefault(); undoEdit(); }, { enabled: hotkeyEnabled }, [undoEdit]); + useHotkeys("meta+shift+z", (e) => { e.preventDefault(); redoEdit(); }, { enabled: hotkeyEnabled }, [redoEdit]); + + const isLoading = + !demo && + ((!projectId && projects.length === 0) || runsLoading || (viewLoading && clips.length === 0)); + + const undoAvailable = activeClip + ? isLiveBacked(activeClip) + ? !!activeClip.canUndoAudio + : activeClip.edits.length > 0 + : false; + const redoAvailable = activeClip + ? isLiveBacked(activeClip) + ? !!activeClip.canRedoAudio + : false + : false; + + return ( +
+
+
+ + + +
+ + +
+
+

+ {mode === "lab" ? "Clip Lab" : "Dataset Health"} +

+

+ {mode === "lab" + ? "Manual slice review, transcript repair, and human overrides." + : "Machine triage and yield tuning."} +

+
+
+
+ + + + +
+
+ + + + {mode === "qc" ? ( + + ) : viewError ? ( +
+
+

Backend unavailable

+

+ Couldn't reach the speechcraft backend. Make sure it's running on + :8010 (make dev-backend), then reload. +

+
+
+ ) : isLoading ? ( +
+ Loading clips… +
+ ) : clips.length === 0 ? ( +
+ No candidate clips in this project's dataset run yet. +
+ ) : ( +
+ + setFilterStatuses((cur) => + cur.includes(status) ? cur.filter((s) => s !== status) : [...cur, status], + ) + } + onToggleTag={(tag) => + setFilterTags((cur) => + cur.includes(tag) ? cur.filter((t) => t !== tag) : [...cur, tag], + ) + } + onToggleBucket={(bucket) => + setFilterBuckets((cur) => + cur.includes(bucket) ? cur.filter((b) => b !== bucket) : [...cur, bucket], + ) + } + onClearFilters={() => { + setFilterStatuses([]); + setFilterTags([]); + setFilterBuckets([]); + }} + sortMode={sortMode} + onSortModeChange={setSortMode} + onSelect={setActiveClipId} + /> + +
+
+ {activeClip ? ( + <> + decide("accepted")} + onReject={() => decide("rejected")} + onAppendEdit={appendEdit} + onUndo={undoEdit} + onRedo={redoEdit} + onMarkReference={markReference} + onRunModel={runModel} + /> + + + ) : ( +
+ Select a clip to begin review. +
+ )} +
+ +
+ + {activeClip ? ( + + ) : null} +
+ )} +
+ ); +} diff --git a/frontend/src/components/lab/project-picker.tsx b/frontend/src/components/lab/project-picker.tsx new file mode 100644 index 0000000..11c1a5d --- /dev/null +++ b/frontend/src/components/lab/project-picker.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { Avatar, AvatarFallback } from "@midday/ui/avatar"; +import { cn } from "@midday/ui/cn"; +import { Icons } from "@midday/ui/icons"; +import { Popover, PopoverContent, PopoverTrigger } from "@midday/ui/popover"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@midday/ui/tooltip"; +import { useQuery } from "@tanstack/react-query"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { fetchProjects } from "./speechcraft-api"; + +export function ProjectPicker() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const { data: projects = [] } = useQuery({ + queryKey: ["sc-projects"], + queryFn: fetchProjects, + staleTime: 60_000, + }); + + const paramId = searchParams.get("project"); + const activeProject = + projects.find((p) => p.id === paramId) ?? projects[0] ?? null; + + const selectProject = (id: string) => { + const params = new URLSearchParams(searchParams.toString()); + params.set("project", id); + router.replace(`${pathname}?${params.toString()}`); + }; + + const initials = (activeProject?.name ?? "··").slice(0, 2).toUpperCase(); + + return ( + + + + + + + + + +

{activeProject?.name ?? "No project"}

+
+
+ + +

+ Project +

+ {projects.map((project) => { + const isActive = project.id === activeProject?.id; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/frontend/src/components/lab/qc-logic.test.ts b/frontend/src/components/lab/qc-logic.test.ts new file mode 100644 index 0000000..9dc5274 --- /dev/null +++ b/frontend/src/components/lab/qc-logic.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from "bun:test"; +import { + bestRejected, + combinedSummary, + histogram, + riskiestKept, + thresholdImpactCurve, + thresholdStatus, + unscoredCount, + type QcClip, +} from "./qc-logic"; + +function clip( + clipId: string, + transcriptMatch: number | null, + speakerCheck: number | null, + durationSec = 2, +): QcClip { + return { clipId, transcriptMatch, speakerCheck, durationSec, reasonCodes: [], trainingText: clipId }; +} + +// transcript threshold 85, speaker threshold 70 (defaults) +const T = 85; +const S = 70; + +const clips: QcClip[] = [ + clip("a", 95, 90), // clear accept + clip("b", 86, 71), // barely accepted (riskiest kept) + clip("c", 84, 95), // rejected on transcript only (best rejected, transcript_only) + clip("d", 90, 68), // rejected on speaker only + clip("e", 40, 30), // clear reject + clip("f", null, 80), // unscored transcript => rejected +]; + +describe("thresholdStatus (AND-gate, null = rejected)", () => { + test("both gates required", () => { + expect(thresholdStatus(clip("x", 95, 90), T, S)).toBe("accepted"); + expect(thresholdStatus(clip("x", 84, 90), T, S)).toBe("rejected"); + expect(thresholdStatus(clip("x", 95, 69), T, S)).toBe("rejected"); + }); + test("null score is rejected", () => { + expect(thresholdStatus(clip("x", null, 90), T, S)).toBe("rejected"); + expect(thresholdStatus(clip("x", 95, null), T, S)).toBe("rejected"); + }); +}); + +describe("combinedSummary", () => { + test("counts accepted vs rejected", () => { + const s = combinedSummary(clips, T, S); + expect(s.acceptedCount).toBe(2); // a, b + expect(s.rejectedCount).toBe(4); // c, d, e, f + expect(s.acceptedDurationSec).toBe(4); + }); +}); + +describe("riskiestKept", () => { + test("accepted clips ordered by smallest risk margin first", () => { + const kept = riskiestKept(clips, T, S, "risk"); + expect(kept.map((k) => k.clip.clipId)).toEqual(["b", "a"]); // b is closest to failing + }); +}); + +describe("bestRejected", () => { + test("closest to passing first (smallest recovery gap)", () => { + const rejected = bestRejected(clips, T, S, "closest"); + // c (transcript 84, gap 1) and d (speaker 68, gap 2) should lead + expect(rejected[0].clip.clipId).toBe("c"); + }); + test("transcript_only isolates transcript-only failures", () => { + const rejected = bestRejected(clips, T, S, "transcript_only"); + // c (84) fails transcript with passing speaker; f (null transcript, speaker 80) + // is also transcript-only. Ordered by descending transcript score, so c then f. + expect(rejected.map((r) => r.clip.clipId)).toEqual(["c", "f"]); + }); + test("speaker_only isolates speaker-only failures", () => { + const rejected = bestRejected(clips, T, S, "speaker_only"); + expect(rejected.map((r) => r.clip.clipId)).toEqual(["d"]); + }); +}); + +describe("thresholdImpactCurve", () => { + test("accepted count is monotonic non-increasing as threshold rises", () => { + const curve = thresholdImpactCurve(clips, (c) => c.transcriptMatch); + expect(curve[0].acceptedClipCount).toBeGreaterThanOrEqual(curve[100].acceptedClipCount); + // at threshold 0, every scored clip counts (5 of 6; f is null) + expect(curve[0].acceptedClipCount).toBe(5); + }); +}); + +describe("histogram", () => { + test("bins scored clips, drops nulls", () => { + const bins = histogram(clips, (c) => c.transcriptMatch, 10); + const total = bins.reduce((n, b) => n + b.count, 0); + expect(total).toBe(5); // f (null) excluded + expect(unscoredCount(clips, (c) => c.transcriptMatch)).toBe(1); + }); +}); diff --git a/frontend/src/components/lab/qc-logic.ts b/frontend/src/components/lab/qc-logic.ts new file mode 100644 index 0000000..bf4d751 --- /dev/null +++ b/frontend/src/components/lab/qc-logic.ts @@ -0,0 +1,283 @@ +// Pure QC / Dataset-Health logic. Ported from the legacy frontend's +// src/qc/qcLogic.ts and adapted to the live GET /api/dataset-runs/{id}/qc +// clip shape. The two scores (transcript_match, speaker_check) are INDEPENDENT +// necessary gates (AND) — never blended. A null score means "unscored" and is +// treated as rejected, matching the backend (_threshold_status in dataset_qc.py). + +export type QcClip = { + clipId: string; + durationSec: number; + transcriptMatch: number | null; + speakerCheck: number | null; + reasonCodes: string[]; + trainingText: string; +}; + +export type ManualOverride = "force_keep" | "force_reject"; +export type ThresholdStatus = "accepted" | "rejected"; + +export type ClipWithMargins = { + clip: QcClip; + transcriptMargin: number; + speakerMargin: number; + riskMargin: number; + transcriptGap: number; + speakerGap: number; + recoveryGap: number; +}; + +export type CurvePoint = { + threshold: number; + acceptedDurationSec: number; + acceptedClipCount: number; +}; + +export type CombinedSummary = { + acceptedCount: number; + rejectedCount: number; + acceptedDurationSec: number; + rejectedDurationSec: number; +}; + +export type KeptSort = "risk" | "transcript" | "speaker"; +export type RejectedSort = "closest" | "transcript_only" | "speaker_only"; + +const NEG_INF = Number.NEGATIVE_INFINITY; + +function scoreOr(score: number | null | undefined): number { + return typeof score === "number" && Number.isFinite(score) ? score : NEG_INF; +} + +function effectiveOverride( + clip: QcClip, + overrides: Record, +): ManualOverride | null { + return overrides[clip.clipId] ?? null; +} + +export function thresholdStatus( + clip: QcClip, + transcriptThreshold: number, + speakerThreshold: number, +): ThresholdStatus { + if (scoreOr(clip.transcriptMatch) < transcriptThreshold) return "rejected"; + if (scoreOr(clip.speakerCheck) < speakerThreshold) return "rejected"; + return "accepted"; +} + +export function finalStatus( + clip: QcClip, + transcriptThreshold: number, + speakerThreshold: number, + overrides: Record = {}, +): ThresholdStatus { + const override = effectiveOverride(clip, overrides); + if (override === "force_keep") return "accepted"; + if (override === "force_reject") return "rejected"; + return thresholdStatus(clip, transcriptThreshold, speakerThreshold); +} + +export function margins( + clip: QcClip, + transcriptThreshold: number, + speakerThreshold: number, +): ClipWithMargins { + const t = scoreOr(clip.transcriptMatch); + const s = scoreOr(clip.speakerCheck); + const transcriptMargin = t - transcriptThreshold; + const speakerMargin = s - speakerThreshold; + const transcriptGap = Math.max(0, transcriptThreshold - t); + const speakerGap = Math.max(0, speakerThreshold - s); + return { + clip, + transcriptMargin, + speakerMargin, + riskMargin: Math.min(transcriptMargin, speakerMargin), + transcriptGap, + speakerGap, + recoveryGap: Math.max(transcriptGap, speakerGap), + }; +} + +function byClipId(a: QcClip, b: QcClip): number { + return a.clipId.localeCompare(b.clipId); +} + +function withMarginsByStatus( + clips: QcClip[], + transcriptThreshold: number, + speakerThreshold: number, + status: ThresholdStatus, + overrides: Record, +): ClipWithMargins[] { + return clips + .filter((c) => finalStatus(c, transcriptThreshold, speakerThreshold, overrides) === status) + .map((c) => margins(c, transcriptThreshold, speakerThreshold)); +} + +/** Accepted clips ordered so the riskiest kept (closest to failing) come first. */ +export function riskiestKept( + clips: QcClip[], + transcriptThreshold: number, + speakerThreshold: number, + sort: KeptSort = "risk", + overrides: Record = {}, +): ClipWithMargins[] { + const kept = withMarginsByStatus(clips, transcriptThreshold, speakerThreshold, "accepted", overrides); + kept.sort((l, r) => { + if (sort === "transcript") return l.transcriptMargin - r.transcriptMargin || byClipId(l.clip, r.clip); + if (sort === "speaker") return l.speakerMargin - r.speakerMargin || byClipId(l.clip, r.clip); + return l.riskMargin - r.riskMargin || byClipId(l.clip, r.clip); + }); + return kept; +} + +/** Rejected clips ordered so the best rejected (closest to passing) come first. */ +export function bestRejected( + clips: QcClip[], + transcriptThreshold: number, + speakerThreshold: number, + sort: RejectedSort = "closest", + overrides: Record = {}, +): ClipWithMargins[] { + const rejected = withMarginsByStatus(clips, transcriptThreshold, speakerThreshold, "rejected", overrides).filter( + (entry) => { + if (sort === "transcript_only") { + return ( + scoreOr(entry.clip.transcriptMatch) < transcriptThreshold && + scoreOr(entry.clip.speakerCheck) >= speakerThreshold + ); + } + if (sort === "speaker_only") { + return ( + scoreOr(entry.clip.speakerCheck) < speakerThreshold && + scoreOr(entry.clip.transcriptMatch) >= transcriptThreshold + ); + } + return true; + }, + ); + rejected.sort((l, r) => { + if (sort === "transcript_only") { + return scoreOr(r.clip.transcriptMatch) - scoreOr(l.clip.transcriptMatch) || byClipId(l.clip, r.clip); + } + if (sort === "speaker_only") { + return scoreOr(r.clip.speakerCheck) - scoreOr(l.clip.speakerCheck) || byClipId(l.clip, r.clip); + } + return l.recoveryGap - r.recoveryGap || byClipId(l.clip, r.clip); + }); + return rejected; +} + +export function clampScoreToBucket(score: number | null | undefined): number | null { + if (typeof score !== "number" || !Number.isFinite(score)) return null; + const bucket = Math.floor(score); + if (bucket < 0) return 0; + if (bucket > 100) return 100; + return bucket; +} + +/** + * Yield curve: for every integer threshold 0..100, the accepted duration and + * clip count if that single gate were applied alone. This is the "area under + * the graph" the threshold handle sweeps. + */ +export function thresholdImpactCurve( + clips: QcClip[], + getScore: (clip: QcClip) => number | null | undefined, +): CurvePoint[] { + const durationAt = new Float64Array(101); + const countAt = new Uint32Array(101); + for (const clip of clips) { + const bucket = clampScoreToBucket(getScore(clip)); + if (bucket === null) continue; + durationAt[bucket] += clip.durationSec; + countAt[bucket] += 1; + } + const curve: CurvePoint[] = new Array(101); + let acceptedDurationSec = 0; + let acceptedClipCount = 0; + for (let threshold = 100; threshold >= 0; threshold -= 1) { + acceptedDurationSec += durationAt[threshold]; + acceptedClipCount += countAt[threshold]; + curve[threshold] = { + threshold, + acceptedDurationSec: Number(acceptedDurationSec.toFixed(6)), + acceptedClipCount, + }; + } + return curve; +} + +export type HistogramBin = { + /** Inclusive lower edge of the bin, 0..100. */ + start: number; + /** Exclusive upper edge (except the final bin, which is inclusive of 100). */ + end: number; + /** Bin center, used as the chart X value. */ + center: number; + count: number; +}; + +/** Bin scores into `binCount` equal-width buckets across 0..100. Nulls dropped. */ +export function histogram( + clips: QcClip[], + getScore: (clip: QcClip) => number | null | undefined, + binCount = 20, +): HistogramBin[] { + const width = 100 / binCount; + const bins: HistogramBin[] = Array.from({ length: binCount }, (_, i) => ({ + start: i * width, + end: (i + 1) * width, + center: i * width + width / 2, + count: 0, + })); + for (const clip of clips) { + const score = getScore(clip); + if (typeof score !== "number" || !Number.isFinite(score)) continue; + const clamped = Math.max(0, Math.min(100, score)); + let index = Math.floor(clamped / width); + if (index >= binCount) index = binCount - 1; + bins[index].count += 1; + } + return bins; +} + +/** Count of clips whose score is null/unscored for a given field. */ +export function unscoredCount( + clips: QcClip[], + getScore: (clip: QcClip) => number | null | undefined, +): number { + let n = 0; + for (const clip of clips) { + const score = getScore(clip); + if (typeof score !== "number" || !Number.isFinite(score)) n += 1; + } + return n; +} + +export function combinedSummary( + clips: QcClip[], + transcriptThreshold: number, + speakerThreshold: number, + overrides: Record = {}, +): CombinedSummary { + const summary: CombinedSummary = { + acceptedCount: 0, + rejectedCount: 0, + acceptedDurationSec: 0, + rejectedDurationSec: 0, + }; + for (const clip of clips) { + if (finalStatus(clip, transcriptThreshold, speakerThreshold, overrides) === "accepted") { + summary.acceptedCount += 1; + summary.acceptedDurationSec += clip.durationSec; + } else { + summary.rejectedCount += 1; + summary.rejectedDurationSec += clip.durationSec; + } + } + summary.acceptedDurationSec = Number(summary.acceptedDurationSec.toFixed(6)); + summary.rejectedDurationSec = Number(summary.rejectedDurationSec.toFixed(6)); + return summary; +} diff --git a/frontend/src/components/lab/rerun-dialog.tsx b/frontend/src/components/lab/rerun-dialog.tsx new file mode 100644 index 0000000..40f212d --- /dev/null +++ b/frontend/src/components/lab/rerun-dialog.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { Button } from "@midday/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@midday/ui/dialog"; +import { Input } from "@midday/ui/input"; +import { Label } from "@midday/ui/label"; +import { Separator } from "@midday/ui/separator"; +import { Textarea } from "@midday/ui/textarea"; +import { useToast } from "@midday/ui/use-toast"; +import { useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { SpeechcraftApiError } from "./speechcraft-write-api"; +import { + createDatasetRun, + fetchRun, + resolveWhisperModel, + slicerRerun, +} from "@/components/wizard/wizard-api"; + +// The two downstream re-run boundaries from the design: +// - cheap re-slice (adjust clip geometry) via slicer-rerun on the SAME run +// - expensive re-run with externally-known overrides (initial_prompt / +// text normalization) as a NEW run cloned from the current one. +// These live here in the loop — never as wizard knobs — because the insight +// to change them only arises after seeing results. + +const SLICER_FIELDS: { key: string; label: string; unit: string; step: number }[] = [ + { key: "candidate_target_clip_sec", label: "Target clip", unit: "sec", step: 0.5 }, + { key: "candidate_min_clip_sec", label: "Minimum clip", unit: "sec", step: 0.5 }, + { key: "candidate_max_clip_sec", label: "Maximum clip", unit: "sec", step: 0.5 }, + { key: "cutpoint_min_gap_ms", label: "Smallest cuttable gap", unit: "ms", step: 5 }, + { key: "cutpoint_left_word_edge_guard_ms", label: "Left word-edge guard", unit: "ms", step: 5 }, + { key: "cutpoint_right_word_edge_guard_ms", label: "Right word-edge guard", unit: "ms", step: 5 }, +]; + +const SLICER_DEFAULTS: Record = { + candidate_target_clip_sec: 8, + candidate_min_clip_sec: 3, + candidate_max_clip_sec: 15, + cutpoint_min_gap_ms: 80, + cutpoint_left_word_edge_guard_ms: 30, + cutpoint_right_word_edge_guard_ms: 30, +}; + +const NORMALIZATION = ["strict", "loose", "spoken_form"] as const; + +export function LabRerunDialog({ + projectId, + runId, +}: { + projectId: string | null; + runId: string | null; +}) { + const { toast } = useToast(); + const router = useRouter(); + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [slicer, setSlicer] = useState>(SLICER_DEFAULTS); + const [initialPrompt, setInitialPrompt] = useState(""); + const [textNorm, setTextNorm] = useState<(typeof NORMALIZATION)[number]>("loose"); + const [busy, setBusy] = useState(null); + + const reslice = async () => { + if (!runId) return; + setBusy("reslice"); + try { + await slicerRerun(runId, slicer); + toast({ + title: "Re-slicing started", + description: "New candidate clips are being generated — reload when it completes.", + variant: "success", + duration: 3500, + }); + queryClient.invalidateQueries({ queryKey: ["sc-cliplab", runId] }); + setOpen(false); + } catch (err) { + toast({ + title: "Re-slice failed", + description: err instanceof SpeechcraftApiError ? err.detail : String(err), + variant: "error", + duration: 4000, + }); + } finally { + setBusy(null); + } + }; + + const rerunAsr = async () => { + if (!projectId || !runId) return; + setBusy("rerun"); + try { + const current = await fetchRun(runId); + const sourceIds = (current.input_summary.source_recording_ids as string[]) ?? []; + const singleSpeaker = Boolean(current.input_summary.single_speaker); + const resolved = await resolveWhisperModel(); + if (resolved.model === null) throw new SpeechcraftApiError(0, resolved.error); + const fresh = await createDatasetRun(projectId, { + source_recording_ids: sourceIds, + whisper_model_size: resolved.model, + single_speaker: singleSpeaker, + config: { + initial_prompt: initialPrompt.trim() || undefined, + text_normalization_strategy: textNorm, + }, + }); + toast({ title: "New run created", description: "Re-running with your overrides.", variant: "success", duration: 2500 }); + setOpen(false); + router.push( + `/login4?project=${encodeURIComponent(projectId)}&run=${encodeURIComponent(fresh.id)}`, + ); + } catch (err) { + toast({ + title: "Re-run failed", + description: err instanceof SpeechcraftApiError ? err.detail : String(err), + variant: "error", + duration: 4000, + }); + } finally { + setBusy(null); + } + }; + + return ( + + + + + + + Re-run with adjustments + + +
+
+

+ Re-slice (fast — same transcription) +

+

+ Adjust clip geometry and regenerate candidate clips. Existing QC becomes stale. +

+
+ {SLICER_FIELDS.map((f) => ( + + ))} +
+
+ +
+
+ + + +
+

+ Re-transcribe (slow — new run) +

+

+ For systematic transcription issues. Creates a fresh run with your overrides. +

+