diff --git a/.dockerignore b/.dockerignore index 84e1f4f4..89d81539 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,6 +4,7 @@ .ruff_cache .uv-cache .venv +.python-version __pycache__/ *.pyc *.pyo @@ -33,3 +34,7 @@ Output/ CorridorKeyModule/checkpoints/ gvm_core/weights/ VideoMaMaInferenceModule/checkpoints/ + +# Frontend build artifacts (node_modules, not the build output) +web/frontend/node_modules/ +web/frontend/.svelte-kit/ diff --git a/.gitignore b/.gitignore index 880a522f..0cd177e9 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,14 @@ CorridorKey_remote.bat .ipynb_checkpoints/ .DS_Store +# Projects / user content +Projects/ + +# WebUI +web/frontend/node_modules/ +web/frontend/build/ +web/frontend/.svelte-kit/ + # IDE .vscode/ .idea/ diff --git a/README.md b/README.md index 18e6758d..520a0a74 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Naturally, I have not tested everything. If you encounter errors, please conside * **Resolution Independent:** The engine dynamically scales inference to handle 4K plates while predicting using its native 2048x2048 high-fidelity backbone. * **VFX Standard Outputs:** Natively reads and writes 16-bit and 32-bit Linear float EXR files, preserving true color math for integration in Nuke, Fusion, or Resolve. * **Auto-Cleanup:** Includes a morphological cleanup system to automatically prune any tracking markers or tiny background features that slip through CorridorKey's detection. +* **WebUI:** Browser-based interface with drag-and-drop upload, one-click full pipeline, real-time progress, video playback, A/B comparison, and project management. Run via `docker compose --profile web up -d` and open `localhost:3000`. ## Hardware Requirements @@ -104,14 +105,53 @@ Perhaps in the future, I will implement other generators for the AlphaHint! In t Please give feedback and share your results! -### Docker (Linux + NVIDIA GPU) +### WebUI (Browser-based) -If you prefer not to install dependencies locally, you can run CorridorKey in Docker. +CorridorKey includes a full web interface for managing clips, running inference, and previewing results — no terminal required. + +**Quick start with Docker Compose:** +```bash +docker compose --profile web up -d --build # first run builds the image (~5 min) +# Open http://localhost:3000 + +# Subsequent runs (no rebuild needed unless code changes): +docker compose --profile web up -d +``` + +**Quick start without Docker:** +```bash +uv sync --group dev --extra web # install web dependencies +uv sync --group dev --extra web --extra cuda # with CUDA GPU support +uv run uvicorn web.api.app:create_app --factory --port 3000 +# Open http://localhost:3000 +``` + +**WebUI Features:** +- **Upload & organize** — drag-and-drop videos or zipped frame sequences, organize into projects +- **Full pipeline** — one-click processing: extract frames → generate alpha hints (GVM/VideoMaMa) → run inference +- **Real-time progress** — WebSocket-driven progress bars with ETA and fps counter +- **Frame viewer** — scrub through frames, play as video (ffmpeg-stitched MP4), A/B comparison mode +- **Download outputs** — download any pass (FG, Matte, Comp, Processed) as ZIP +- **Job queue** — parallel CPU jobs (extraction) + GPU jobs with configurable VRAM limits +- **Weight management** — download CorridorKey, GVM, and VideoMaMa weights from HuggingFace directly in Settings +- **VRAM monitoring** — system-wide GPU memory usage (via nvidia-smi) +- **Right-click context menus** — rename projects, move clips, batch process, delete +- **Keyboard shortcuts** — press `?` to see all shortcuts + +**Important notes:** +- **Clip storage:** The WebUI manages clips under `Projects/`, while the CLI wizard uses `ClipsForInference/`. These directories are independent — clips created in the WebUI won't appear in the CLI and vice versa. Set `CK_CLIPS_DIR` to point at `ClipsForInference/` if you want both to use the same directory. +- **Mac / MLX:** The WebUI has not been validated on Mac with MLX inference. The server will start and the UI will work, but the VRAM meter will show N/A (nvidia-smi is not available on Mac) and the VRAM concurrency limit for parallel jobs is not enforced on non-CUDA systems. +- Model weights are volume-mounted and persist across Docker rebuilds. +- The web service uses the `web` Docker Compose profile. + +### Docker CLI (Linux + NVIDIA GPU) + +If you prefer the command-line interface in Docker: Prerequisites: - Docker Engine + Docker Compose plugin installed. -- NVIDIA driver installed on the host (Linux), with CUDA compatibility for the PyTorch CUDA 12.6 wheels used by this project. -- NVIDIA Container Toolkit installed and configured for Docker (`nvidia-smi` should work on host, and `docker run --rm --gpus all nvidia/cuda:12.6.3-runtime-ubuntu22.04 nvidia-smi` should succeed). +- NVIDIA driver installed on the host (Linux), with CUDA compatibility for the PyTorch CUDA 12.8 wheels used by this project. +- NVIDIA Container Toolkit installed and configured for Docker (`nvidia-smi` should work on host, and `docker run --rm --gpus all nvidia/cuda:12.8.0-runtime-ubuntu22.04 nvidia-smi` should succeed). 1. Build the image: ```bash diff --git a/backend/job_queue.py b/backend/job_queue.py index baf9a6ba..7f1099b9 100644 --- a/backend/job_queue.py +++ b/backend/job_queue.py @@ -13,6 +13,7 @@ - Jobs have stable IDs assigned at creation time - Deduplication prevents double-submit of same clip+job_type - Job history preserved for UI display (cancelled/completed/failed) + - Multiple jobs can run simultaneously (local + remote nodes) """ from __future__ import annotations @@ -85,7 +86,7 @@ def check_cancelled(self) -> None: class GPUJobQueue: - """Thread-safe GPU job queue with mutual exclusion. + """Thread-safe GPU job queue supporting multiple concurrent running jobs. Usage (CLI mode): queue = GPUJobQueue() @@ -103,15 +104,15 @@ class GPUJobQueue: except Exception as e: queue.fail_job(job, str(e)) - Usage (GUI mode): - The GPU worker QThread calls next_job() / start_job() / complete_job() - in its run loop. The UI submits jobs from the main thread. + Usage (distributed): + Multiple workers (local + remote nodes) can claim and run jobs + simultaneously. All running jobs are tracked and visible in the API. """ def __init__(self): self._queue: deque[GPUJob] = deque() self._lock = threading.Lock() - self._current_job: GPUJob | None = None + self._running_jobs: list[GPUJob] = [] self._history: list[GPUJob] = [] # completed/cancelled/failed jobs for UI display # Callbacks (set by UI or CLI) @@ -143,17 +144,17 @@ def submit(self, job: GPUJob) -> bool: f"(already queued as {existing.id})" ) return False - if ( - self._current_job - and self._current_job.clip_name == job.clip_name - and self._current_job.job_type == job.job_type - and self._current_job.status == JobStatus.RUNNING - ): - logger.warning( - f"Duplicate job rejected: {job.job_type.value} for '{job.clip_name}' " - f"(already running as {self._current_job.id})" - ) - return False + for running in self._running_jobs: + if ( + running.clip_name == job.clip_name + and running.job_type == job.job_type + and running.status == JobStatus.RUNNING + ): + logger.warning( + f"Duplicate job rejected: {job.job_type.value} for '{job.clip_name}' " + f"(already running as {running.id})" + ) + return False job.status = JobStatus.QUEUED self._queue.append(job) @@ -173,15 +174,15 @@ def start_job(self, job: GPUJob) -> None: if job in self._queue: self._queue.remove(job) job.status = JobStatus.RUNNING - self._current_job = job + self._running_jobs.append(job) logger.info(f"Job started [{job.id}]: {job.job_type.value} for '{job.clip_name}'") def complete_job(self, job: GPUJob) -> None: """Mark a job as successfully completed.""" with self._lock: job.status = JobStatus.COMPLETED - if self._current_job is job: - self._current_job = None + if job in self._running_jobs: + self._running_jobs.remove(job) self._history.append(job) logger.info(f"Job completed [{job.id}]: {job.job_type.value} for '{job.clip_name}'") # Emit AFTER lock release (Codex: no deadlock risk) @@ -193,8 +194,8 @@ def fail_job(self, job: GPUJob, error: str) -> None: with self._lock: job.status = JobStatus.FAILED job.error_message = error - if self._current_job is job: - self._current_job = None + if job in self._running_jobs: + self._running_jobs.remove(job) self._history.append(job) logger.error(f"Job failed [{job.id}]: {job.job_type.value} for '{job.clip_name}': {error}") # Emit AFTER lock release @@ -202,16 +203,11 @@ def fail_job(self, job: GPUJob, error: str) -> None: self.on_error(job.clip_name, error) def mark_cancelled(self, job: GPUJob) -> None: - """Mark a running job as cancelled AND clear _current_job. - - This is the cancel-safe path that was missing — calling - job.request_cancel() alone doesn't clear _current_job, which - poisons queue state for subsequent jobs. - """ + """Mark a running job as cancelled AND remove from running list.""" with self._lock: job.status = JobStatus.CANCELLED - if self._current_job is job: - self._current_job = None + if job in self._running_jobs: + self._running_jobs.remove(job) self._history.append(job) logger.info(f"Job cancelled [{job.id}]: {job.job_type.value} for '{job.clip_name}'") @@ -230,17 +226,19 @@ def cancel_job(self, job: GPUJob) -> None: logger.info(f"Job cancel requested [{job.id}]: {job.job_type.value} for '{job.clip_name}'") def cancel_current(self) -> None: - """Cancel the currently running job, if any.""" + """Cancel all currently running jobs.""" with self._lock: - if self._current_job and self._current_job.status == JobStatus.RUNNING: - self._current_job.request_cancel() + for job in self._running_jobs: + if job.status == JobStatus.RUNNING: + job.request_cancel() def cancel_all(self) -> None: - """Cancel current job and clear the queue.""" + """Cancel all running jobs and clear the queue.""" with self._lock: - # Cancel current - if self._current_job and self._current_job.status == JobStatus.RUNNING: - self._current_job.request_cancel() + # Cancel running + for job in self._running_jobs: + if job.status == JobStatus.RUNNING: + job.request_cancel() # Clear queue — preserve in history for job in self._queue: job.status = JobStatus.CANCELLED @@ -249,10 +247,13 @@ def cancel_all(self) -> None: logger.info("All jobs cancelled") def report_progress(self, clip_name: str, current: int, total: int) -> None: - """Report progress for the current job. Called by processing code.""" - if self._current_job: - self._current_job.current_frame = current - self._current_job.total_frames = total + """Report progress for a job by clip name. Called by processing code.""" + with self._lock: + for job in self._running_jobs: + if job.clip_name == clip_name and job.status == JobStatus.RUNNING: + job.current_frame = current + job.total_frames = total + break if self.on_progress: self.on_progress(clip_name, current, total) @@ -263,10 +264,11 @@ def report_warning(self, message: str) -> None: self.on_warning(message) def find_job_by_id(self, job_id: str) -> GPUJob | None: - """Find a job by ID in queue, current, or history.""" + """Find a job by ID in running, queue, or history.""" with self._lock: - if self._current_job and self._current_job.id == job_id: - return self._current_job + for job in self._running_jobs: + if job.id == job_id: + return job for job in self._queue: if job.id == job_id: return job @@ -292,8 +294,15 @@ def has_pending(self) -> bool: @property def current_job(self) -> GPUJob | None: + """Return the first running job (backward compat). Use running_jobs for all.""" + with self._lock: + return self._running_jobs[0] if self._running_jobs else None + + @property + def running_jobs(self) -> list[GPUJob]: + """Return a copy of all currently running jobs.""" with self._lock: - return self._current_job + return list(self._running_jobs) @property def pending_count(self) -> int: @@ -314,11 +323,9 @@ def history_snapshot(self) -> list[GPUJob]: @property def all_jobs_snapshot(self) -> list[GPUJob]: - """Return current + queued + history for full queue panel display.""" + """Return running + queued + history for full queue panel display.""" with self._lock: - result = [] - if self._current_job: - result.append(self._current_job) + result = list(self._running_jobs) result.extend(self._queue) result.extend(self._history) return result diff --git a/docker-compose.yml b/docker-compose.yml index fd21cb34..2f9a4358 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,3 +35,24 @@ services: - ./VideoMaMaInferenceModule/checkpoints:/app/VideoMaMaInferenceModule/checkpoints stdin_open: true tty: true + + corridorkey-web: + profiles: ["web"] + build: + context: . + dockerfile: web/Dockerfile.web + image: corridorkey-web:latest + user: "${UID:-1000}:${GID:-1000}" + gpus: ${CK_GPUS:-all} + ports: + - "3000:3000" + environment: + - OPENCV_IO_ENABLE_OPENEXR=1 + - NVIDIA_VISIBLE_DEVICES=${NVIDIA_VISIBLE_DEVICES:-all} + - NVIDIA_DRIVER_CAPABILITIES=${NVIDIA_DRIVER_CAPABILITIES:-compute,utility,video} + - CK_CLIPS_DIR=/app/Projects + volumes: + - ./Projects:/app/Projects + - ./CorridorKeyModule/checkpoints:/app/CorridorKeyModule/checkpoints + - ./gvm_core/weights:/app/gvm_core/weights + - ./VideoMaMaInferenceModule/checkpoints:/app/VideoMaMaInferenceModule/checkpoints diff --git a/pyproject.toml b/pyproject.toml index f4f76372..b6daae94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,11 @@ cuda = [ mlx = [ "corridorkey-mlx ; python_version >= '3.11'", ] +web = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.34", + "python-multipart>=0.0.9", +] [dependency-groups] dev = ["pytest", "pytest-cov", "ruff", "hypothesis"] diff --git a/uv.lock b/uv.lock index 7e8fe882..2e5dd585 100644 --- a/uv.lock +++ b/uv.lock @@ -52,6 +52,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "anyio" version = "4.12.1" @@ -396,6 +405,11 @@ cuda = [ mlx = [ { name = "corridorkey-mlx", marker = "python_full_version >= '3.11'" }, ] +web = [ + { name = "fastapi" }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, +] [package.dev-dependencies] dev = [ @@ -416,6 +430,7 @@ requires-dist = [ { name = "diffusers" }, { name = "easydict" }, { name = "einops" }, + { name = "fastapi", marker = "extra == 'web'", specifier = ">=0.115" }, { name = "huggingface-hub" }, { name = "imageio" }, { name = "kornia" }, @@ -425,6 +440,7 @@ requires-dist = [ { name = "peft" }, { name = "pillow" }, { name = "pims" }, + { name = "python-multipart", marker = "extra == 'web'", specifier = ">=0.0.9" }, { name = "rich", specifier = ">=13" }, { name = "setuptools" }, { name = "timm", git = "https://github.com/Raiden129/pytorch-image-models-fix?branch=fix%2Fhiera-flash-attention-global-4d" }, @@ -436,8 +452,9 @@ requires-dist = [ { name = "transformers" }, { name = "triton-windows", marker = "sys_platform == 'win32'", specifier = "==3.4.0.post21" }, { name = "typer", specifier = ">=0.12" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.34" }, ] -provides-extras = ["cuda", "mlx"] +provides-extras = ["cuda", "mlx", "web"] [package.metadata.requires-dev] dev = [ @@ -619,6 +636,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "fastapi" +version = "0.135.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" }, +] + [[package]] name = "filelock" version = "3.25.0" @@ -724,6 +757,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/e5/c07e0bcf4ec8db8164e9f6738c048b2e66aabf30e7506f440c4cc6953f60/httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78", size = 204531, upload-time = "2025-10-10T03:54:20.887Z" }, + { url = "https://files.pythonhosted.org/packages/7e/4f/35e3a63f863a659f92ffd92bef131f3e81cf849af26e6435b49bd9f6f751/httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4", size = 109408, upload-time = "2025-10-10T03:54:22.455Z" }, + { url = "https://files.pythonhosted.org/packages/f5/71/b0a9193641d9e2471ac541d3b1b869538a5fb6419d52fd2669fa9c79e4b8/httptools-0.7.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8c751014e13d88d2be5f5f14fc8b89612fcfa92a9cc480f2bc1598357a23a05", size = 440889, upload-time = "2025-10-10T03:54:23.753Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d9/2e34811397b76718750fea44658cb0205b84566e895192115252e008b152/httptools-0.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654968cb6b6c77e37b832a9be3d3ecabb243bbe7a0b8f65fbc5b6b04c8fcabed", size = 440460, upload-time = "2025-10-10T03:54:25.313Z" }, + { url = "https://files.pythonhosted.org/packages/01/3f/a04626ebeacc489866bb4d82362c0657b2262bef381d68310134be7f40bb/httptools-0.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b580968316348b474b020edf3988eecd5d6eec4634ee6561e72ae3a2a0e00a8a", size = 425267, upload-time = "2025-10-10T03:54:26.81Z" }, + { url = "https://files.pythonhosted.org/packages/a5/99/adcd4f66614db627b587627c8ad6f4c55f18881549bab10ecf180562e7b9/httptools-0.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d496e2f5245319da9d764296e86c5bb6fcf0cf7a8806d3d000717a889c8c0b7b", size = 424429, upload-time = "2025-10-10T03:54:28.174Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/ec8fc904a8fd30ba022dfa85f3bbc64c3c7cd75b669e24242c0658e22f3c/httptools-0.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cbf8317bfccf0fed3b5680c559d3459cccf1abe9039bfa159e62e391c7270568", size = 86173, upload-time = "2025-10-10T03:54:29.5Z" }, + { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -1662,6 +1731,111 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -1737,6 +1911,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1996,6 +2188,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-11-corridorkey-cuda' and extra == 'extra-11-corridorkey-mlx')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + [[package]] name = "sympy" version = "1.14.0" @@ -2402,6 +2607,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" @@ -2411,6 +2628,193 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "uvicorn" +version = "0.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'extra-11-corridorkey-cuda' and extra == 'extra-11-corridorkey-mlx')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-11-corridorkey-cuda' and extra == 'extra-11-corridorkey-mlx')" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "(platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32') or (platform_python_implementation == 'PyPy' and extra == 'extra-11-corridorkey-cuda' and extra == 'extra-11-corridorkey-mlx') or (sys_platform == 'cygwin' and extra == 'extra-11-corridorkey-cuda' and extra == 'extra-11-corridorkey-mlx') or (sys_platform == 'win32' and extra == 'extra-11-corridorkey-cuda' and extra == 'extra-11-corridorkey-mlx')" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/1a/206e8cf2dd86fddf939165a57b4df61607a1e0add2785f170a3f616b7d9f/watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", size = 407318, upload-time = "2025-10-14T15:04:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/b3/0f/abaf5262b9c496b5dad4ed3c0e799cbecb1f8ea512ecb6ddd46646a9fca3/watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", size = 394478, upload-time = "2025-10-14T15:04:20.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/9cc0ba88697b34b755371f5ace8d3a4d9a15719c07bdc7bd13d7d8c6a341/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", size = 449894, upload-time = "2025-10-14T15:04:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/d2/9c/eda4615863cd8621e89aed4df680d8c3ec3da6a4cf1da113c17decd87c7f/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", size = 459065, upload-time = "2025-10-14T15:04:22.795Z" }, + { url = "https://files.pythonhosted.org/packages/84/13/f28b3f340157d03cbc8197629bc109d1098764abe1e60874622a0be5c112/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", size = 488377, upload-time = "2025-10-14T15:04:24.138Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/cfa597fa9389e122488f7ffdbd6db505b3b915ca7435ecd7542e855898c2/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", size = 595837, upload-time = "2025-10-14T15:04:25.057Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/68c1ed5652b48d89fc24d6af905d88ee4f82fa8bc491e2666004e307ded1/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", size = 473456, upload-time = "2025-10-14T15:04:26.497Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", size = 455614, upload-time = "2025-10-14T15:04:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/3d782a666512e01eaa6541a72ebac1d3aae191ff4a31274a66b8dd85760c/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", size = 630690, upload-time = "2025-10-14T15:04:28.495Z" }, + { url = "https://files.pythonhosted.org/packages/9b/73/bb5f38590e34687b2a9c47a244aa4dd50c56a825969c92c9c5fc7387cea1/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", size = 622459, upload-time = "2025-10-14T15:04:29.491Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ac/c9bb0ec696e07a20bd58af5399aeadaef195fb2c73d26baf55180fe4a942/watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844", size = 272663, upload-time = "2025-10-14T15:04:30.435Z" }, + { url = "https://files.pythonhosted.org/packages/11/a0/a60c5a7c2ec59fa062d9a9c61d02e3b6abd94d32aac2d8344c4bdd033326/watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e", size = 287453, upload-time = "2025-10-14T15:04:31.53Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4c/a888c91e2e326872fa4705095d64acd8aa2fb9c1f7b9bd0588f33850516c/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", size = 409611, upload-time = "2025-10-14T15:06:05.809Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/5420d1943c8e3ce1a21c0a9330bcf7edafb6aa65d26b21dbb3267c9e8112/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", size = 396889, upload-time = "2025-10-14T15:06:07.035Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e5/0072cef3804ce8d3aaddbfe7788aadff6b3d3f98a286fdbee9fd74ca59a7/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", size = 451616, upload-time = "2025-10-14T15:06:08.072Z" }, + { url = "https://files.pythonhosted.org/packages/83/4e/b87b71cbdfad81ad7e83358b3e447fedd281b880a03d64a760fe0a11fc2e/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", size = 458413, upload-time = "2025-10-14T15:06:09.209Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + [[package]] name = "zensical" version = "0.0.24" diff --git a/web/Dockerfile.web b/web/Dockerfile.web new file mode 100644 index 00000000..895d564c --- /dev/null +++ b/web/Dockerfile.web @@ -0,0 +1,51 @@ +# Stage 1: Build Svelte SPA +FROM node:20-slim AS frontend +WORKDIR /build +COPY web/frontend/package.json web/frontend/package-lock.json* ./ +RUN npm ci +COPY web/frontend/ . +RUN npm run build + +# Stage 2: Python runtime +FROM ghcr.io/astral-sh/uv:0.7-python3.11-bookworm-slim + +WORKDIR /app + +RUN useradd --create-home --uid 1000 appuser + +# Runtime dependencies for OpenCV/video I/O. +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + apt-get update && apt-get install -y --no-install-recommends \ + git \ + ffmpeg \ + libgl1 \ + libglib2.0-0 && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Install Python dependencies (including web extra). +COPY --chown=appuser:appuser pyproject.toml uv.lock ./ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev --extra web --extra cuda --no-install-project + +# Copy project source. +COPY --chown=appuser:appuser . . + +# Install the project itself. +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev --extra web --extra cuda + +# Copy built frontend from stage 1. +COPY --from=frontend /build/build /app/web/frontend/build + +# Ensure Projects dir exists and is owned by appuser +RUN mkdir -p /app/Projects && chown appuser:appuser /app/Projects + +USER appuser + +ENV OPENCV_IO_ENABLE_OPENEXR=1 +ENV CK_CLIPS_DIR=/app/Projects + +EXPOSE 3000 + +CMD ["/app/.venv/bin/uvicorn", "web.api.app:create_app", "--factory", "--host", "0.0.0.0", "--port", "3000"] diff --git a/web/__init__.py b/web/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/web/api/__init__.py b/web/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/web/api/app.py b/web/api/app.py new file mode 100644 index 00000000..1d935018 --- /dev/null +++ b/web/api/app.py @@ -0,0 +1,110 @@ +"""FastAPI application factory with lifespan management.""" + +from __future__ import annotations + +import asyncio +import logging +import os +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.middleware.gzip import GZipMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from backend.project import projects_root + +from .deps import get_queue, get_service +from .routes import clips, jobs, preview, projects, system, upload +from .worker import start_worker +from .ws import manager, websocket_endpoint + +logger = logging.getLogger(__name__) + +# Resolve clips directory from env or default to Projects/ +CLIPS_DIR = os.environ.get("CK_CLIPS_DIR", "") + + +def _resolve_clips_dir() -> str: + if CLIPS_DIR: + return os.path.abspath(CLIPS_DIR) + return projects_root() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Startup: detect device, start worker. Shutdown: stop worker.""" + clips_dir = _resolve_clips_dir() + os.makedirs(clips_dir, exist_ok=True) + + clips.set_clips_dir(clips_dir) + preview.set_clips_dir(clips_dir) + + service = get_service() + device = service.detect_device() + logger.info(f"Device: {device}, Clips dir: {clips_dir}") + + loop = asyncio.get_running_loop() + manager.set_loop(loop) + + queue = get_queue() + worker_thread, stop_event = start_worker(service, queue, clips_dir) + + app.state.clips_dir = clips_dir + app.state.worker_thread = worker_thread + app.state.stop_event = stop_event + + yield + + stop_event.set() + worker_thread.join(timeout=5) + logger.info("Worker thread joined") + + +def create_app() -> FastAPI: + """Application factory — call with uvicorn --factory.""" + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") + + app = FastAPI( + title="CorridorKey WebUI", + version="1.0.0", + lifespan=lifespan, + ) + + # Compress responses > 1KB (speeds up file transfers to nodes) + app.add_middleware(GZipMiddleware, minimum_size=1000) + + # API routes + app.include_router(clips.router) + app.include_router(jobs.router) + app.include_router(system.router) + app.include_router(preview.router) + app.include_router(projects.router) + app.include_router(upload.router) + + # WebSocket + app.websocket("/ws")(websocket_endpoint) + + # Serve built Svelte SPA from web/frontend/build/ + static_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "frontend", "build") + if os.path.isdir(static_dir): + # Mount static assets (JS, CSS, images) — but NOT as catch-all + app.mount("/_app", StaticFiles(directory=os.path.join(static_dir, "_app")), name="spa-assets") + + index_html = os.path.join(static_dir, "index.html") + + # SPA catch-all: any non-API, non-asset GET request serves index.html + @app.get("/{path:path}", include_in_schema=False) + async def spa_fallback(request: Request, path: str): + # Don't intercept API or WebSocket paths + if path.startswith("api/") or path == "ws": + return + # Serve actual static files if they exist (favicon, etc.) + file_path = os.path.join(static_dir, path) + if path and os.path.isfile(file_path): + return FileResponse(file_path) + return FileResponse(index_html) + else: + logger.warning(f"SPA build directory not found at {static_dir} — serving API only") + + return app diff --git a/web/api/deps.py b/web/api/deps.py new file mode 100644 index 00000000..11b4c350 --- /dev/null +++ b/web/api/deps.py @@ -0,0 +1,23 @@ +"""Singleton dependencies for the FastAPI application.""" + +from __future__ import annotations + +from backend.job_queue import GPUJobQueue +from backend.service import CorridorKeyService + +_service: CorridorKeyService | None = None +_queue: GPUJobQueue | None = None + + +def get_service() -> CorridorKeyService: + global _service + if _service is None: + _service = CorridorKeyService() + return _service + + +def get_queue() -> GPUJobQueue: + global _queue + if _queue is None: + _queue = GPUJobQueue() + return _queue diff --git a/web/api/routes/__init__.py b/web/api/routes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/web/api/routes/clips.py b/web/api/routes/clips.py new file mode 100644 index 00000000..8540e12c --- /dev/null +++ b/web/api/routes/clips.py @@ -0,0 +1,194 @@ +"""Clip scanning, detail, and deletion endpoints.""" + +from __future__ import annotations + +import logging +import os +import shutil + +from fastapi import APIRouter, HTTPException + +from ..deps import get_service +from ..schemas import ClipAssetSchema, ClipListResponse, ClipSchema + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/clips", tags=["clips"]) + +# Resolved at startup via app.state.clips_dir +_clips_dir: str = "" + + +def set_clips_dir(path: str) -> None: + global _clips_dir + _clips_dir = path + + +def _clip_to_schema(clip) -> ClipSchema: + def _asset(a) -> ClipAssetSchema | None: + if a is None: + return None + return ClipAssetSchema(path=a.path, asset_type=a.asset_type, frame_count=a.frame_count) + + frame_count = 0 + if clip.input_asset: + frame_count = clip.input_asset.frame_count + + return ClipSchema( + name=clip.name, + root_path=clip.root_path, + state=clip.state.value, + input_asset=_asset(clip.input_asset), + alpha_asset=_asset(clip.alpha_asset), + mask_asset=_asset(clip.mask_asset), + frame_count=frame_count, + completed_frames=clip.completed_frame_count(), + has_outputs=clip.has_outputs, + warnings=clip.warnings, + error_message=clip.error_message, + ) + + +@router.get("", response_model=ClipListResponse) +def list_clips(): + service = get_service() + try: + clips = service.scan_clips(_clips_dir) + except Exception as e: + logger.error(f"Clip scan failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) from e + return ClipListResponse( + clips=[_clip_to_schema(c) for c in clips], + clips_dir=_clips_dir, + ) + + +@router.get("/{name}", response_model=ClipSchema) +def get_clip(name: str): + service = get_service() + try: + clips = service.scan_clips(_clips_dir) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + for clip in clips: + if clip.name == name: + return _clip_to_schema(clip) + raise HTTPException(status_code=404, detail=f"Clip '{name}' not found") + + +@router.delete("/{name}") +def delete_clip(name: str): + """Delete a clip and its entire project directory. + + Removes the clip's root_path. If this was the only clip in a v2 project, + removes the entire project folder too. + """ + service = get_service() + try: + clips = service.scan_clips(_clips_dir) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + clip = next((c for c in clips if c.name == name), None) + if clip is None: + raise HTTPException(status_code=404, detail=f"Clip '{name}' not found") + + clip_root = clip.root_path + if not os.path.isdir(clip_root): + raise HTTPException(status_code=404, detail="Clip directory not found on disk") + + # Safety: ensure the path is inside the clips dir + abs_clips = os.path.abspath(_clips_dir) + abs_clip = os.path.abspath(clip_root) + if not abs_clip.startswith(abs_clips + os.sep): + raise HTTPException(status_code=403, detail="Clip path is outside the projects directory") + + try: + shutil.rmtree(clip_root) + logger.info(f"Deleted clip directory: {clip_root}") + + # If the parent project's clips/ dir is now empty, remove the project too + clips_parent = os.path.dirname(clip_root) # .../clips/ + if os.path.basename(clips_parent) == "clips" and os.path.isdir(clips_parent): + remaining = [d for d in os.listdir(clips_parent) if not d.startswith(".")] + if not remaining: + project_dir = os.path.dirname(clips_parent) + shutil.rmtree(project_dir) + logger.info(f"Deleted empty project directory: {project_dir}") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to delete: {e}") from e + + return {"status": "deleted", "name": name} + + +@router.post("/{name}/move") +def move_clip(name: str, target_project: str): + """Move a clip to a different project. + + The clip directory is physically moved into the target project's clips/ dir. + Updates project.json in both source and target projects. + """ + from backend.project import is_v2_project, read_project_json, write_project_json + + service = get_service() + try: + clips = service.scan_clips(_clips_dir) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + clip = next((c for c in clips if c.name == name), None) + if clip is None: + raise HTTPException(status_code=404, detail=f"Clip '{name}' not found") + + # Find target project + target_dir = os.path.join(_clips_dir, target_project) + if not os.path.isdir(target_dir) or not is_v2_project(target_dir): + raise HTTPException(status_code=404, detail=f"Target project '{target_project}' not found") + + target_clips_dir = os.path.join(target_dir, "clips") + dest = os.path.join(target_clips_dir, os.path.basename(clip.root_path)) + + if os.path.exists(dest): + raise HTTPException(status_code=409, detail=f"A clip named '{name}' already exists in the target project") + + # Safety checks + abs_root = os.path.abspath(_clips_dir) + abs_src = os.path.abspath(clip.root_path) + abs_dst = os.path.abspath(dest) + if not abs_src.startswith(abs_root + os.sep) or not abs_dst.startswith(abs_root + os.sep): + raise HTTPException(status_code=403, detail="Path outside projects directory") + + try: + # Move the clip directory + shutil.move(clip.root_path, dest) + logger.info(f"Moved clip '{name}': {clip.root_path} → {dest}") + + # Update target project.json + target_data = read_project_json(target_dir) or {} + target_clips = target_data.get("clips", []) + clip_basename = os.path.basename(dest) + if clip_basename not in target_clips: + target_clips.append(clip_basename) + target_data["clips"] = target_clips + write_project_json(target_dir, target_data) + + # Clean up source project — use saved path since clip.root_path was moved + source_clips_parent = os.path.dirname(abs_src) # use pre-move absolute path + if os.path.basename(source_clips_parent) == "clips" and os.path.isdir(source_clips_parent): + remaining = [d for d in os.listdir(source_clips_parent) if not d.startswith(".")] + source_project = os.path.dirname(source_clips_parent) + source_data = read_project_json(source_project) or {} + source_clip_list = source_data.get("clips", []) + source_clip_list = [c for c in source_clip_list if c != clip_basename] + source_data["clips"] = source_clip_list + if remaining: + write_project_json(source_project, source_data) + else: + shutil.rmtree(source_project) + logger.info(f"Deleted empty source project: {source_project}") + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to move clip: {e}") from e + + return {"status": "moved", "name": name, "target_project": target_project} diff --git a/web/api/routes/jobs.py b/web/api/routes/jobs.py new file mode 100644 index 00000000..a85adb09 --- /dev/null +++ b/web/api/routes/jobs.py @@ -0,0 +1,207 @@ +"""Job submission, listing, and cancellation endpoints.""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException + +from backend.job_queue import GPUJob, JobType + +from ..deps import get_queue, get_service +from ..schemas import ( + ExtractJobRequest, + GVMJobRequest, + InferenceJobRequest, + JobListResponse, + JobSchema, + PipelineJobRequest, + VideoMaMaJobRequest, +) + +router = APIRouter(prefix="/api/jobs", tags=["jobs"]) + + +def _job_to_schema(job: GPUJob) -> JobSchema: + return JobSchema( + id=job.id, + job_type=job.job_type.value, + clip_name=job.clip_name, + status=job.status.value, + current_frame=job.current_frame, + total_frames=job.total_frames, + error_message=job.error_message, + ) + + +@router.get("", response_model=JobListResponse) +def list_jobs(): + queue = get_queue() + running = queue.running_jobs + return JobListResponse( + current=_job_to_schema(running[0]) if running else None, + running=[_job_to_schema(j) for j in running], + queued=[_job_to_schema(j) for j in queue.queue_snapshot], + history=[_job_to_schema(j) for j in queue.history_snapshot], + ) + + +@router.post("/inference", response_model=list[JobSchema]) +def submit_inference(req: InferenceJobRequest): + queue = get_queue() + submitted = [] + for clip_name in req.clip_names: + job = GPUJob( + job_type=JobType.INFERENCE, + clip_name=clip_name, + params={ + "inference_params": req.params.model_dump(), + "output_config": req.output_config.model_dump(), + "frame_range": list(req.frame_range) if req.frame_range else None, + }, + ) + if queue.submit(job): + submitted.append(_job_to_schema(job)) + if not submitted: + raise HTTPException(status_code=409, detail="All jobs rejected (duplicates)") + return submitted + + +@router.post("/gvm", response_model=list[JobSchema]) +def submit_gvm(req: GVMJobRequest): + queue = get_queue() + submitted = [] + for clip_name in req.clip_names: + job = GPUJob(job_type=JobType.GVM_ALPHA, clip_name=clip_name) + if queue.submit(job): + submitted.append(_job_to_schema(job)) + if not submitted: + raise HTTPException(status_code=409, detail="All jobs rejected (duplicates)") + return submitted + + +@router.post("/videomama", response_model=list[JobSchema]) +def submit_videomama(req: VideoMaMaJobRequest): + queue = get_queue() + submitted = [] + for clip_name in req.clip_names: + job = GPUJob( + job_type=JobType.VIDEOMAMA_ALPHA, + clip_name=clip_name, + params={"chunk_size": req.chunk_size}, + ) + if queue.submit(job): + submitted.append(_job_to_schema(job)) + if not submitted: + raise HTTPException(status_code=409, detail="All jobs rejected (duplicates)") + return submitted + + +@router.post("/pipeline", response_model=list[JobSchema]) +def submit_pipeline(req: PipelineJobRequest): + """Submit the first step of a full pipeline. + + Only queues the NEXT needed step for each clip. When that step + completes, the worker auto-chains the following step (via the + pipeline params stored on the job). This ensures each step finishes + before the next begins. + """ + from ..routes.clips import _clips_dir + + queue = get_queue() + service = get_service() + clips = service.scan_clips(_clips_dir) + clip_map = {c.name: c for c in clips} + + # Pipeline params stored on each job so the worker can chain the next step + pipeline_params = { + "pipeline": True, + "alpha_method": req.alpha_method, + "inference_params": req.params.model_dump(), + "output_config": req.output_config.model_dump(), + } + + submitted = [] + for clip_name in req.clip_names: + clip = clip_map.get(clip_name) + if not clip: + continue + + state = clip.state.value + + if state == "EXTRACTING": + job = GPUJob(job_type=JobType.VIDEO_EXTRACT, clip_name=clip_name, params=pipeline_params) + elif state == "RAW": + if req.alpha_method == "videomama": + job = GPUJob( + job_type=JobType.VIDEOMAMA_ALPHA, + clip_name=clip_name, + params={**pipeline_params, "chunk_size": 50}, + ) + else: + job = GPUJob(job_type=JobType.GVM_ALPHA, clip_name=clip_name, params=pipeline_params) + elif state == "MASKED": + # MASKED clips already have a mask — run VideoMaMa to generate alpha, then inference + job = GPUJob( + job_type=JobType.VIDEOMAMA_ALPHA, + clip_name=clip_name, + params={**pipeline_params, "chunk_size": 50}, + ) + elif state in ("READY", "COMPLETE"): + job = GPUJob(job_type=JobType.INFERENCE, clip_name=clip_name, params=pipeline_params) + else: + continue + + if queue.submit(job): + submitted.append(_job_to_schema(job)) + + if not submitted: + raise HTTPException(status_code=409, detail="No jobs submitted (clips may already be complete or duplicates)") + return submitted + + +@router.post("/extract", response_model=list[JobSchema]) +def submit_extract(req: ExtractJobRequest): + queue = get_queue() + submitted = [] + for clip_name in req.clip_names: + job = GPUJob(job_type=JobType.VIDEO_EXTRACT, clip_name=clip_name) + if queue.submit(job): + submitted.append(_job_to_schema(job)) + if not submitted: + raise HTTPException(status_code=409, detail="All jobs rejected (duplicates)") + return submitted + + +@router.delete("/{job_id}") +def cancel_job(job_id: str): + queue = get_queue() + job = queue.find_job_by_id(job_id) + if job is None: + raise HTTPException(status_code=404, detail=f"Job '{job_id}' not found") + queue.cancel_job(job) + return {"status": "cancelled", "job_id": job_id} + + +@router.get("/{job_id}/log") +def get_job_log(job_id: str): + """Get detailed error/log info for a job.""" + queue = get_queue() + job = queue.find_job_by_id(job_id) + if job is None: + raise HTTPException(status_code=404, detail=f"Job '{job_id}' not found") + return { + "id": job.id, + "job_type": job.job_type.value, + "clip_name": job.clip_name, + "status": job.status.value, + "error_message": job.error_message, + "current_frame": job.current_frame, + "total_frames": job.total_frames, + "params": job.params, + } + + +@router.delete("") +def cancel_all(): + queue = get_queue() + queue.cancel_all() + return {"status": "all_cancelled"} diff --git a/web/api/routes/preview.py b/web/api/routes/preview.py new file mode 100644 index 00000000..7f47f30b --- /dev/null +++ b/web/api/routes/preview.py @@ -0,0 +1,317 @@ +"""Preview endpoint — serves frames as PNG, preview videos as MP4, and downloads as ZIP.""" + +from __future__ import annotations + +import hashlib +import logging +import os +import subprocess +import tempfile +import threading +import zipfile + +import cv2 +import numpy as np +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse, Response + +from backend.frame_io import read_image_frame +from backend.natural_sort import natsorted +from backend.project import is_image_file + +from ..deps import get_service + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/preview", tags=["preview"]) + +_clips_dir: str = "" +# Cache dir for stitched preview videos +_cache_dir: str = "" + + +def set_clips_dir(path: str) -> None: + global _clips_dir, _cache_dir + _clips_dir = path + _cache_dir = os.path.join(path, ".cache", "preview_videos") + os.makedirs(_cache_dir, exist_ok=True) + + +_PASS_MAP = { + "input": "Input", + "frames": "Frames", + "alpha": "AlphaHint", + "fg": "Output/FG", + "matte": "Output/Matte", + "comp": "Output/Comp", + "processed": "Output/Processed", +} + + +def _find_clip_root(clip_name: str) -> str | None: + service = get_service() + clips = service.scan_clips(_clips_dir) + for clip in clips: + if clip.name == clip_name: + return clip.root_path + return None + + +def _resolve_pass_dir(clip_root: str, pass_name: str) -> str: + """Resolve the directory for a pass, handling input/frames fallback.""" + if pass_name == "input": + frames_dir = os.path.join(clip_root, "Frames") + input_dir = os.path.join(clip_root, "Input") + if os.path.isdir(frames_dir) and os.listdir(frames_dir): + return frames_dir + elif os.path.isdir(input_dir): + return input_dir + raise HTTPException(status_code=404, detail="No input frames directory found") + target = os.path.join(clip_root, _PASS_MAP[pass_name]) + if not os.path.isdir(target): + raise HTTPException(status_code=404, detail=f"Directory not found: {_PASS_MAP[pass_name]}") + return target + + +def _frame_to_png_bytes(img: np.ndarray) -> bytes: + if img.dtype == np.float32 or img.dtype == np.float64: + img = (np.clip(img, 0.0, 1.0) * 255.0).astype(np.uint8) + if img.ndim == 3 and img.shape[2] >= 3: + img_bgr = cv2.cvtColor(img[:, :, :3], cv2.COLOR_RGB2BGR) + elif img.ndim == 3 and img.shape[2] == 4: + img_bgr = cv2.cvtColor(img, cv2.COLOR_RGBA2BGRA) + elif img.ndim == 2: + img_bgr = img + else: + img_bgr = img + success, buf = cv2.imencode(".png", img_bgr) + if not success: + raise RuntimeError("PNG encode failed") + return buf.tobytes() + + +# --- Single frame preview --- + + +@router.get("/{clip_name}/{pass_name}/{frame:int}") +def get_preview_frame(clip_name: str, pass_name: str, frame: int): + if pass_name not in _PASS_MAP: + raise HTTPException(status_code=400, detail=f"Unknown pass: {pass_name}. Valid: {list(_PASS_MAP.keys())}") + + clip_root = _find_clip_root(clip_name) + if clip_root is None: + raise HTTPException(status_code=404, detail=f"Clip '{clip_name}' not found") + + target_dir = _resolve_pass_dir(clip_root, pass_name) + files = natsorted([f for f in os.listdir(target_dir) if is_image_file(f)]) + if not files: + raise HTTPException(status_code=404, detail=f"No frames in {pass_name}") + if frame < 0 or frame >= len(files): + raise HTTPException(status_code=404, detail=f"Frame {frame} out of range (0-{len(files) - 1})") + + fpath = os.path.join(target_dir, files[frame]) + + if pass_name == "matte": + os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1" + img = cv2.imread(fpath, cv2.IMREAD_ANYDEPTH | cv2.IMREAD_UNCHANGED) + if img is None: + raise HTTPException(status_code=500, detail=f"Failed to read {fpath}") + if img.ndim == 3: + img = img[:, :, 0] + if img.dtype != np.uint8: + img = (np.clip(img, 0.0, 1.0) * 255.0).astype(np.uint8) + success, buf = cv2.imencode(".png", img) + if not success: + raise HTTPException(status_code=500, detail="PNG encode failed") + return Response(content=buf.tobytes(), media_type="image/png") + + img = read_image_frame(fpath) + if img is None: + raise HTTPException(status_code=500, detail=f"Failed to read {fpath}") + + return Response(content=_frame_to_png_bytes(img), media_type="image/png") + + +# --- Video preview (stitched MP4) --- + +# Lock to prevent concurrent ffmpeg encodes for the same cache key +_encode_locks: dict[str, threading.Lock] = {} +_encode_locks_lock = threading.Lock() + + +def _get_encode_lock(key: str) -> threading.Lock: + with _encode_locks_lock: + if key not in _encode_locks: + _encode_locks[key] = threading.Lock() + return _encode_locks[key] + + +def _ffmpeg_available() -> bool: + try: + subprocess.run(["ffmpeg", "-version"], capture_output=True, timeout=5) + return True + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +def _cache_key(clip_root: str, pass_name: str) -> str: + """Generate a cache key based on directory path and modification time.""" + target_dir = _resolve_pass_dir(clip_root, pass_name) + files = [f for f in os.listdir(target_dir) if is_image_file(f)] + # Hash based on dir path, file count, and newest mtime + newest = max((os.path.getmtime(os.path.join(target_dir, f)) for f in files), default=0) + raw = f"{target_dir}:{len(files)}:{newest}" + return hashlib.md5(raw.encode()).hexdigest()[:12] + + +@router.get("/{clip_name}/{pass_name}/video") +def get_preview_video(clip_name: str, pass_name: str, fps: int = 24): + """Stitch frames into an MP4 for smooth browser playback. Cached.""" + if not _ffmpeg_available(): + raise HTTPException(status_code=503, detail="ffmpeg not available — cannot generate preview video") + + if pass_name not in _PASS_MAP: + raise HTTPException(status_code=400, detail=f"Unknown pass: {pass_name}") + + clip_root = _find_clip_root(clip_name) + if clip_root is None: + raise HTTPException(status_code=404, detail=f"Clip '{clip_name}' not found") + + target_dir = _resolve_pass_dir(clip_root, pass_name) + files = natsorted([f for f in os.listdir(target_dir) if is_image_file(f)]) + if not files: + raise HTTPException(status_code=404, detail=f"No frames in {pass_name}") + + # Check cache + key = _cache_key(clip_root, pass_name) + cache_path = os.path.join(_cache_dir, f"{clip_name}_{pass_name}_{key}.mp4") + + if os.path.isfile(cache_path): + return FileResponse(cache_path, media_type="video/mp4", filename=f"{clip_name}_{pass_name}.mp4") + + # Serialize encodes per cache key to prevent duplicate ffmpeg processes + encode_lock = _get_encode_lock(key) + if not encode_lock.acquire(timeout=0.1): + # Another thread is encoding this exact video — wait for it + encode_lock.acquire() + encode_lock.release() + if os.path.isfile(cache_path): + return FileResponse(cache_path, media_type="video/mp4", filename=f"{clip_name}_{pass_name}.mp4") + raise HTTPException(status_code=500, detail="Concurrent encode failed") + + concat_path = os.path.join(_cache_dir, f"{key}_concat.txt") + try: + with open(concat_path, "w") as f: + for fname in files: + fpath = os.path.join(target_dir, fname) + # For EXR files, we need to convert first — ffmpeg may not handle them well + # Use a glob pattern if filenames are sequential, otherwise concat + f.write(f"file '{fpath}'\n") + f.write(f"duration {1 / fps}\n") + + # Check if files are EXR (ffmpeg needs special handling) + is_exr = files[0].lower().endswith(".exr") + + if is_exr: + # Convert via OpenCV → temp PNGs → ffmpeg + with tempfile.TemporaryDirectory() as tmpdir: + for i, fname in enumerate(files): + fpath = os.path.join(target_dir, fname) + img = read_image_frame(fpath) + if img is not None: + out = (np.clip(img, 0.0, 1.0) * 255.0).astype(np.uint8) + out_bgr = cv2.cvtColor(out, cv2.COLOR_RGB2BGR) + cv2.imwrite(os.path.join(tmpdir, f"{i:06d}.png"), out_bgr) + + cmd = [ + "ffmpeg", + "-y", + "-framerate", + str(fps), + "-i", + os.path.join(tmpdir, "%06d.png"), + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-crf", + "23", + "-movflags", + "+faststart", + cache_path, + ] + result = subprocess.run(cmd, capture_output=True, timeout=300) + if result.returncode != 0: + raise RuntimeError(result.stderr.decode()[-300:]) + else: + # Direct ffmpeg from image files + cmd = [ + "ffmpeg", + "-y", + "-f", + "concat", + "-safe", + "0", + "-i", + concat_path, + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-crf", + "23", + "-movflags", + "+faststart", + cache_path, + ] + result = subprocess.run(cmd, capture_output=True, timeout=300) + if result.returncode != 0: + raise RuntimeError(result.stderr.decode()[-300:]) + + except Exception as e: + logger.error(f"Video stitch failed: {e}") + raise HTTPException(status_code=500, detail=f"Failed to create preview video: {e}") from e + finally: + encode_lock.release() + if os.path.isfile(concat_path): + os.unlink(concat_path) + + return FileResponse(cache_path, media_type="video/mp4", filename=f"{clip_name}_{pass_name}.mp4") + + +# --- Download (ZIP) --- + + +@router.get("/{clip_name}/{pass_name}/download") +def download_pass(clip_name: str, pass_name: str): + """Download all frames for a pass as a ZIP file.""" + if pass_name not in _PASS_MAP: + raise HTTPException(status_code=400, detail=f"Unknown pass: {pass_name}") + + clip_root = _find_clip_root(clip_name) + if clip_root is None: + raise HTTPException(status_code=404, detail=f"Clip '{clip_name}' not found") + + target_dir = _resolve_pass_dir(clip_root, pass_name) + files = natsorted(os.listdir(target_dir)) + files = [f for f in files if not f.startswith(".")] + if not files: + raise HTTPException(status_code=404, detail=f"No files in {pass_name}") + + zip_name = f"{clip_name}_{pass_name}.zip" + + # Build ZIP to a temp file (streaming partial ZIPs produces corrupt files) + zip_path = os.path.join(_cache_dir, f"dl_{zip_name}") + try: + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + for fname in files: + fpath = os.path.join(target_dir, fname) + zf.write(fpath, arcname=os.path.join(pass_name, fname)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to create ZIP: {e}") from e + + return FileResponse( + zip_path, + media_type="application/zip", + filename=zip_name, + background=None, # don't delete after send — cached + ) diff --git a/web/api/routes/projects.py b/web/api/routes/projects.py new file mode 100644 index 00000000..601741ef --- /dev/null +++ b/web/api/routes/projects.py @@ -0,0 +1,159 @@ +"""Project listing, creation, and management endpoints.""" + +from __future__ import annotations + +import logging +import os +import shutil +from datetime import datetime + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from backend.clip_state import scan_project_clips +from backend.project import ( + _dedupe_path, + is_v2_project, + projects_root, + read_project_json, + set_display_name, + write_project_json, +) + +from .clips import _clip_to_schema, _clips_dir + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["projects"]) + + +class ProjectSchema(BaseModel): + name: str + display_name: str + path: str + clip_count: int + created: str | None = None + clips: list[dict] = [] + + +class CreateProjectRequest(BaseModel): + name: str + + +class RenameProjectRequest(BaseModel): + display_name: str + + +def _scan_projects() -> list[ProjectSchema]: + """Scan the projects directory and return project info.""" + root = _clips_dir or projects_root() + if not os.path.isdir(root): + return [] + + projects = [] + for item in sorted(os.listdir(root)): + item_path = os.path.join(root, item) + if not os.path.isdir(item_path) or item.startswith(".") or item.startswith("_"): + continue + + # Only include v2 projects (have clips/ subdir) + if not is_v2_project(item_path): + continue + + data = read_project_json(item_path) or {} + display = data.get("display_name", item) + created = data.get("created") + + # Scan clips inside the project + try: + clips = scan_project_clips(item_path) + except Exception: + clips = [] + + projects.append( + ProjectSchema( + name=item, + display_name=display, + path=item_path, + clip_count=len(clips), + created=created, + clips=[_clip_to_schema(c).__dict__ for c in clips], + ) + ) + + return projects + + +@router.get("", response_model=list[ProjectSchema]) +def list_projects(): + return _scan_projects() + + +@router.post("", response_model=ProjectSchema) +def create_project(req: CreateProjectRequest): + """Create a new empty project.""" + root = _clips_dir or projects_root() + timestamp = datetime.now().strftime("%y%m%d_%H%M%S") + + import re + + name_stem = re.sub(r"[^\w\-]", "_", req.name.strip()) + name_stem = re.sub(r"_+", "_", name_stem).strip("_")[:60] + folder_name = f"{timestamp}_{name_stem}" + + project_dir, _ = _dedupe_path(root, folder_name) + clips_dir = os.path.join(project_dir, "clips") + os.makedirs(clips_dir, exist_ok=True) + + write_project_json( + project_dir, + { + "version": 2, + "created": datetime.now().isoformat(), + "display_name": req.name.strip(), + "clips": [], + }, + ) + + return ProjectSchema( + name=os.path.basename(project_dir), + display_name=req.name.strip(), + path=project_dir, + clip_count=0, + created=datetime.now().isoformat(), + ) + + +@router.patch("/{name}") +def rename_project(name: str, req: RenameProjectRequest): + """Rename a project's display name.""" + root = _clips_dir or projects_root() + project_dir = os.path.join(root, name) + if not os.path.isdir(project_dir): + raise HTTPException(status_code=404, detail=f"Project '{name}' not found") + + set_display_name(project_dir, req.display_name.strip()) + return {"status": "ok", "display_name": req.display_name.strip()} + + +@router.delete("/{name}") +def delete_project(name: str): + """Delete a project and all its clips.""" + root = _clips_dir or projects_root() + project_dir = os.path.join(root, name) + + if not os.path.isdir(project_dir): + raise HTTPException(status_code=404, detail=f"Project '{name}' not found") + + # Safety check + abs_root = os.path.abspath(root) + abs_proj = os.path.abspath(project_dir) + if not abs_proj.startswith(abs_root + os.sep): + raise HTTPException(status_code=403, detail="Project path outside projects directory") + + try: + shutil.rmtree(project_dir) + logger.info(f"Deleted project: {project_dir}") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to delete: {e}") from e + + return {"status": "deleted", "name": name} diff --git a/web/api/routes/system.py b/web/api/routes/system.py new file mode 100644 index 00000000..9e562af8 --- /dev/null +++ b/web/api/routes/system.py @@ -0,0 +1,259 @@ +"""System info endpoints — device, VRAM, model unloading, weight downloads.""" + +from __future__ import annotations + +import logging +import os +import subprocess +import sys +import threading + +from fastapi import APIRouter, HTTPException + +from ..deps import get_service +from ..schemas import DeviceResponse, VRAMResponse + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/system", tags=["system"]) + +# Base dir for weight paths +_BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +# Weight download state +_download_status: dict[str, dict] = {} +_download_lock = threading.Lock() + + +def _weights_info() -> dict: + """Check which weights are installed.""" + ck_dir = os.path.join(_BASE_DIR, "CorridorKeyModule", "checkpoints") + ck_files = [f for f in os.listdir(ck_dir) if f.endswith(".pth")] if os.path.isdir(ck_dir) else [] + + gvm_dir = os.path.join(_BASE_DIR, "gvm_core", "weights") + # GVM weights have vae/, unet/, scheduler/ subdirs — check for unet/config.json + gvm_config = os.path.isfile(os.path.join(gvm_dir, "unet", "config.json")) if os.path.isdir(gvm_dir) else False + + vm_dir = os.path.join(_BASE_DIR, "VideoMaMaInferenceModule", "checkpoints", "VideoMaMa") + vm_exists = os.path.isdir(vm_dir) and len(os.listdir(vm_dir)) > 0 + + return { + "corridorkey": { + "installed": len(ck_files) > 0, + "path": ck_dir, + "detail": ck_files[0] if ck_files else None, + "size_hint": "~300 MB", + }, + "gvm": { + "installed": gvm_config, + "path": gvm_dir, + "detail": "vae + unet + scheduler" if gvm_config else None, + "size_hint": "~10 GB", + }, + "videomama": { + "installed": vm_exists, + "path": vm_dir, + "detail": f"{len(os.listdir(vm_dir))} files" if vm_exists else None, + "size_hint": "~5 GB", + }, + } + + +@router.get("/device", response_model=DeviceResponse) +def get_device(): + service = get_service() + return DeviceResponse(device=service._device) + + +def _nvidia_smi_vram() -> dict | None: + """Query nvidia-smi for system-wide VRAM usage (all processes, not just PyTorch).""" + try: + result = subprocess.run( + ["nvidia-smi", "--query-gpu=memory.total,memory.used,memory.free,name", "--format=csv,nounits,noheader"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode != 0: + return None + line = result.stdout.strip().split("\n")[0] + total_mb, used_mb, free_mb, name = [x.strip() for x in line.split(",")] + total = float(total_mb) / 1024 + used = float(used_mb) / 1024 + free = float(free_mb) / 1024 + return { + "total": total, + "reserved": used, + "allocated": used, + "free": free, + "name": name, + } + except Exception: + return None + + +@router.get("/vram", response_model=VRAMResponse) +def get_vram(): + # Prefer nvidia-smi for system-wide VRAM (includes other processes like Unreal) + smi = _nvidia_smi_vram() + if smi: + return VRAMResponse( + total=smi["total"], + reserved=smi["reserved"], + allocated=smi["allocated"], + free=smi["free"], + name=smi["name"], + available=True, + ) + # Fallback to PyTorch (only sees its own allocations) + service = get_service() + info = service.get_vram_info() + if not info: + return VRAMResponse(available=False) + return VRAMResponse( + total=info.get("total", 0), + reserved=info.get("reserved", 0), + allocated=info.get("allocated", 0), + free=info.get("free", 0), + name=info.get("name", ""), + available=True, + ) + + +@router.get("/vram-limit") +def get_vram_limit_setting(): + from ..worker import get_vram_limit + + return {"vram_limit_gb": get_vram_limit()} + + +@router.post("/vram-limit") +def set_vram_limit_setting(vram_limit_gb: float): + from ..worker import set_vram_limit + + set_vram_limit(vram_limit_gb) + return {"status": "ok", "vram_limit_gb": vram_limit_gb} + + +@router.post("/unload") +def unload_engines(): + service = get_service() + service.unload_engines() + return {"status": "unloaded"} + + +@router.get("/weights") +def get_weights(): + """Check installed weights status.""" + info = _weights_info() + # Merge in download status + with _download_lock: + for key, status in _download_status.items(): + if key in info: + info[key]["download"] = status + return info + + +def _run_download(name: str, cmd: list[str], target_dir: str) -> None: + """Run a weight download in a background thread.""" + try: + with _download_lock: + _download_status[name] = {"status": "downloading", "error": None} + + logger.info(f"Starting weight download: {name} → {target_dir}") + os.makedirs(target_dir, exist_ok=True) + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + cwd=_BASE_DIR, + timeout=3600, # 1 hour max + ) + + if result.returncode != 0: + error = result.stderr.strip()[-500:] if result.stderr else "Unknown error" + logger.error(f"Weight download failed for {name}: {error}") + with _download_lock: + _download_status[name] = {"status": "failed", "error": error} + else: + logger.info(f"Weight download complete: {name}") + with _download_lock: + _download_status[name] = {"status": "complete", "error": None} + + except subprocess.TimeoutExpired: + with _download_lock: + _download_status[name] = {"status": "failed", "error": "Download timed out (1 hour)"} + except Exception as e: + with _download_lock: + _download_status[name] = {"status": "failed", "error": str(e)} + + +def _hf_bin() -> str: + """Find the huggingface-hub CLI binary.""" + import shutil as _shutil + + # Check the venv first (covers Docker where PATH may not include .venv/bin) + venv_hf = os.path.join(_BASE_DIR, ".venv", "bin", "huggingface-cli") + if os.path.isfile(venv_hf): + return venv_hf + # Also check for 'hf' alias + venv_hf2 = os.path.join(_BASE_DIR, ".venv", "bin", "hf") + if os.path.isfile(venv_hf2): + return venv_hf2 + # Try PATH + found = _shutil.which("huggingface-cli") or _shutil.which("hf") + if found: + return found + # Last resort: run via python -m + return "huggingface-cli" + + +@router.post("/weights/download/{name}") +def download_weights(name: str): + """Start downloading weights for a model. Runs in the background.""" + with _download_lock: + if name in _download_status and _download_status[name].get("status") == "downloading": + return {"status": "already_downloading"} + + hf = _hf_bin() + + # Build the download command — use python -m as fallback if hf CLI not found + python_bin = os.path.join(_BASE_DIR, ".venv", "bin", "python") + if not os.path.isfile(python_bin): + python_bin = sys.executable + + def _dl_cmd(repo: str, local_dir: str, extra_args: list[str] | None = None) -> list[str]: + """Build huggingface download command with python -m fallback.""" + try: + # Test if hf CLI works + subprocess.run([hf, "--version"], capture_output=True, timeout=5) + cmd = [hf, "download", repo, "--local-dir", local_dir] + except (FileNotFoundError, subprocess.TimeoutExpired): + cmd = [python_bin, "-m", "huggingface_hub", "download", repo, "--local-dir", local_dir] + if extra_args: + cmd.extend(extra_args) + return cmd + + if name == "corridorkey": + target = os.path.join(_BASE_DIR, "CorridorKeyModule", "checkpoints") + cmd = _dl_cmd("nikopueringer/CorridorKey_v1.0", target, ["CorridorKey_v1.0.pth"]) + thread = threading.Thread(target=_run_download, args=(name, cmd, target), daemon=True) + thread.start() + return {"status": "started", "size_hint": "~300 MB"} + + elif name == "gvm": + target = os.path.join(_BASE_DIR, "gvm_core", "weights") + cmd = _dl_cmd("geyongtao/gvm", target) + thread = threading.Thread(target=_run_download, args=(name, cmd, target), daemon=True) + thread.start() + return {"status": "started", "size_hint": "~10 GB"} + + elif name == "videomama": + target = os.path.join(_BASE_DIR, "VideoMaMaInferenceModule", "checkpoints", "VideoMaMa") + cmd = _dl_cmd("SammyLim/VideoMaMa", target) + thread = threading.Thread(target=_run_download, args=(name, cmd, target), daemon=True) + thread.start() + return {"status": "started", "size_hint": "~5 GB"} + + else: + raise HTTPException(status_code=400, detail=f"Unknown weight set: {name}. Valid: corridorkey, gvm, videomama") diff --git a/web/api/routes/upload.py b/web/api/routes/upload.py new file mode 100644 index 00000000..63c12112 --- /dev/null +++ b/web/api/routes/upload.py @@ -0,0 +1,305 @@ +"""Upload endpoints — video files, image sequences (zip), and alpha hints.""" + +from __future__ import annotations + +import logging +import os +import shutil +import tempfile +import zipfile + +from fastapi import APIRouter, HTTPException, UploadFile + +from backend.job_queue import GPUJob, JobType +from backend.project import ( + create_project, + is_image_file, + is_video_file, + sanitize_stem, +) + +from ..deps import get_queue, get_service +from ..routes import clips as _clips_mod + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/upload", tags=["upload"]) + + +@router.post("/video") +async def upload_video(file: UploadFile, name: str | None = None, auto_extract: bool = True): + """Upload a video file to create a new project/clip. + + The video is saved into a new project via create_project(). + If auto_extract is True (default), a VIDEO_EXTRACT job is queued + to extract frames in the background. + """ + if not file.filename: + raise HTTPException(status_code=400, detail="No filename provided") + + if not is_video_file(file.filename): + raise HTTPException( + status_code=400, + detail=f"Not a supported video format: {file.filename}. " + "Supported: .mp4, .mov, .avi, .mkv, .mxf, .webm, .m4v", + ) + + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = os.path.join(tmpdir, file.filename) + try: + with open(tmp_path, "wb") as f: + while chunk := await file.read(8 * 1024 * 1024): + f.write(chunk) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to save upload: {e}") from e + + try: + project_dir = create_project( + tmp_path, + copy_source=True, + display_name=name, + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to create project: {e}") from e + + # Scan the new clips + service = get_service() + clips = service.scan_clips(_clips_mod._clips_dir) + new_clips = [c for c in clips if c.root_path.startswith(project_dir)] + + # Auto-submit extraction jobs for any clip with a video source + extract_jobs = [] + if auto_extract: + queue = get_queue() + for clip in new_clips: + has_video = clip.input_asset and clip.input_asset.asset_type == "video" + no_frames = not os.path.isdir(os.path.join(clip.root_path, "Frames")) + logger.info( + f"Upload auto-extract check: clip={clip.name} state={clip.state.value} " + f"has_video={has_video} no_frames={no_frames}" + ) + if has_video or clip.state.value == "EXTRACTING": + job = GPUJob(job_type=JobType.VIDEO_EXTRACT, clip_name=clip.name) + if queue.submit(job): + extract_jobs.append(job.id) + logger.info(f"Auto-queued extraction job {job.id} for '{clip.name}'") + + return { + "status": "ok", + "project_dir": project_dir, + "clips": [_clips_mod._clip_to_schema(c) for c in new_clips], + "extract_jobs": extract_jobs, + } + + +@router.post("/frames") +async def upload_frames(file: UploadFile, name: str | None = None): + """Upload a zip of image frames to create a new clip. + + The zip should contain image files (PNG, EXR, JPG, etc.) at the + top level or in a single subdirectory. They'll be placed into + a new project's Frames/ directory. + """ + if not file.filename: + raise HTTPException(status_code=400, detail="No filename provided") + + if not file.filename.lower().endswith(".zip"): + raise HTTPException(status_code=400, detail="Expected a .zip file containing image frames") + + with tempfile.TemporaryDirectory() as tmpdir: + zip_path = os.path.join(tmpdir, file.filename) + try: + with open(zip_path, "wb") as f: + while chunk := await file.read(8 * 1024 * 1024): + f.write(chunk) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to save upload: {e}") from e + + # Extract zip + extract_dir = os.path.join(tmpdir, "extracted") + try: + with zipfile.ZipFile(zip_path, "r") as zf: + zf.extractall(extract_dir) + except zipfile.BadZipFile: + raise HTTPException(status_code=400, detail="Invalid zip file") from None + + # Find image files — may be at root or in a single subdirectory + image_files = [f for f in os.listdir(extract_dir) if is_image_file(f)] + if not image_files: + subdirs = [d for d in os.listdir(extract_dir) if os.path.isdir(os.path.join(extract_dir, d))] + if len(subdirs) == 1: + subdir_path = os.path.join(extract_dir, subdirs[0]) + image_files = [f for f in os.listdir(subdir_path) if is_image_file(f)] + if image_files: + extract_dir = subdir_path + + if not image_files: + raise HTTPException(status_code=400, detail="No image files found in zip") + + # Create project structure manually (create_project expects video) + from datetime import datetime + + from backend.project import _dedupe_path, projects_root, write_clip_json, write_project_json + + clip_name = sanitize_stem(name or file.filename) + timestamp = datetime.now().strftime("%y%m%d_%H%M%S") + folder_name = f"{timestamp}_{clip_name}" + + root = projects_root() + project_dir, _ = _dedupe_path(root, folder_name) + clips_dir = os.path.join(project_dir, "clips") + clip_dir, clip_name = _dedupe_path(clips_dir, clip_name) + frames_dir = os.path.join(clip_dir, "Frames") + os.makedirs(frames_dir, exist_ok=True) + + for fname in sorted(image_files): + src = os.path.join(extract_dir, fname) + dst = os.path.join(frames_dir, fname) + shutil.copy2(src, dst) + + write_clip_json(clip_dir, {"source": {"type": "uploaded_frames", "original_filename": file.filename}}) + write_project_json( + project_dir, + { + "version": 2, + "created": datetime.now().isoformat(), + "display_name": clip_name.replace("_", " "), + "clips": [clip_name], + }, + ) + + service = get_service() + clips = service.scan_clips(_clips_mod._clips_dir) + new_clips = [c for c in clips if c.root_path.startswith(project_dir)] + + return { + "status": "ok", + "project_dir": project_dir, + "clips": [_clips_mod._clip_to_schema(c) for c in new_clips], + "frame_count": len(image_files), + } + + +@router.post("/alpha/{clip_name}") +async def upload_alpha_hint(clip_name: str, file: UploadFile): + """Upload alpha hint frames (zip) for an existing clip. + + Extracts images into the clip's AlphaHint/ directory. + Transitions clip from RAW -> READY. + """ + if not file.filename: + raise HTTPException(status_code=400, detail="No filename provided") + + if not file.filename.lower().endswith(".zip"): + raise HTTPException(status_code=400, detail="Expected a .zip file containing alpha hint frames") + + service = get_service() + clips = service.scan_clips(_clips_mod._clips_dir) + clip = next((c for c in clips if c.name == clip_name), None) + if clip is None: + raise HTTPException(status_code=404, detail=f"Clip '{clip_name}' not found") + + with tempfile.TemporaryDirectory() as tmpdir: + zip_path = os.path.join(tmpdir, file.filename) + try: + with open(zip_path, "wb") as f: + while chunk := await file.read(8 * 1024 * 1024): + f.write(chunk) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to save upload: {e}") from e + + extract_dir = os.path.join(tmpdir, "extracted") + try: + with zipfile.ZipFile(zip_path, "r") as zf: + zf.extractall(extract_dir) + except zipfile.BadZipFile: + raise HTTPException(status_code=400, detail="Invalid zip file") from None + + image_files = [f for f in os.listdir(extract_dir) if is_image_file(f)] + if not image_files: + subdirs = [d for d in os.listdir(extract_dir) if os.path.isdir(os.path.join(extract_dir, d))] + if len(subdirs) == 1: + extract_dir = os.path.join(extract_dir, subdirs[0]) + image_files = [f for f in os.listdir(extract_dir) if is_image_file(f)] + + if not image_files: + raise HTTPException(status_code=400, detail="No image files found in zip") + + alpha_dir = os.path.join(clip.root_path, "AlphaHint") + os.makedirs(alpha_dir, exist_ok=True) + + for fname in sorted(image_files): + src = os.path.join(extract_dir, fname) + dst = os.path.join(alpha_dir, fname) + shutil.copy2(src, dst) + + clips = service.scan_clips(_clips_mod._clips_dir) + updated = next((c for c in clips if c.name == clip_name), None) + + return { + "status": "ok", + "clip": _clips_mod._clip_to_schema(updated) if updated else None, + "alpha_frames": len(image_files), + } + + +@router.post("/mask/{clip_name}") +async def upload_videomama_mask(clip_name: str, file: UploadFile): + """Upload VideoMaMa mask hint frames (zip) for an existing clip. + + Extracts images into the clip's VideoMamaMaskHint/ directory. + Transitions clip to MASKED state. + """ + if not file.filename: + raise HTTPException(status_code=400, detail="No filename provided") + + if not file.filename.lower().endswith(".zip"): + raise HTTPException(status_code=400, detail="Expected a .zip file containing mask frames") + + service = get_service() + clips = service.scan_clips(_clips_mod._clips_dir) + clip = next((c for c in clips if c.name == clip_name), None) + if clip is None: + raise HTTPException(status_code=404, detail=f"Clip '{clip_name}' not found") + + with tempfile.TemporaryDirectory() as tmpdir: + zip_path = os.path.join(tmpdir, file.filename) + try: + with open(zip_path, "wb") as f: + while chunk := await file.read(8 * 1024 * 1024): + f.write(chunk) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to save upload: {e}") from e + + extract_dir = os.path.join(tmpdir, "extracted") + try: + with zipfile.ZipFile(zip_path, "r") as zf: + zf.extractall(extract_dir) + except zipfile.BadZipFile: + raise HTTPException(status_code=400, detail="Invalid zip file") from None + + image_files = [f for f in os.listdir(extract_dir) if is_image_file(f)] + if not image_files: + subdirs = [d for d in os.listdir(extract_dir) if os.path.isdir(os.path.join(extract_dir, d))] + if len(subdirs) == 1: + extract_dir = os.path.join(extract_dir, subdirs[0]) + image_files = [f for f in os.listdir(extract_dir) if is_image_file(f)] + + if not image_files: + raise HTTPException(status_code=400, detail="No image files found in zip") + + mask_dir = os.path.join(clip.root_path, "VideoMamaMaskHint") + os.makedirs(mask_dir, exist_ok=True) + + for fname in sorted(image_files): + src = os.path.join(extract_dir, fname) + dst = os.path.join(mask_dir, fname) + shutil.copy2(src, dst) + + clips = service.scan_clips(_clips_mod._clips_dir) + updated = next((c for c in clips if c.name == clip_name), None) + + return { + "status": "ok", + "clip": _clips_mod._clip_to_schema(updated) if updated else None, + "mask_frames": len(image_files), + } diff --git a/web/api/schemas.py b/web/api/schemas.py new file mode 100644 index 00000000..dc25f383 --- /dev/null +++ b/web/api/schemas.py @@ -0,0 +1,124 @@ +"""Pydantic request/response models for the WebUI API.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + +# --- Clips --- + + +class ClipAssetSchema(BaseModel): + path: str + asset_type: str + frame_count: int + + +class ClipSchema(BaseModel): + name: str + root_path: str + state: str + input_asset: ClipAssetSchema | None = None + alpha_asset: ClipAssetSchema | None = None + mask_asset: ClipAssetSchema | None = None + frame_count: int = 0 + completed_frames: int = 0 + has_outputs: bool = False + warnings: list[str] = [] + error_message: str | None = None + + +class ClipListResponse(BaseModel): + clips: list[ClipSchema] + clips_dir: str + + +# --- Jobs --- + + +class InferenceParamsSchema(BaseModel): + input_is_linear: bool = False + despill_strength: float = Field(1.0, ge=0.0, le=1.0) + auto_despeckle: bool = True + despeckle_size: int = Field(400, ge=1) + refiner_scale: float = Field(1.0, ge=0.0) + + +class OutputConfigSchema(BaseModel): + fg_enabled: bool = True + fg_format: str = "exr" + matte_enabled: bool = True + matte_format: str = "exr" + comp_enabled: bool = True + comp_format: str = "png" + processed_enabled: bool = True + processed_format: str = "exr" + + +class ExtractJobRequest(BaseModel): + clip_names: list[str] + + +class PipelineJobRequest(BaseModel): + """Full pipeline: extract (if needed) → GVM alpha → inference.""" + + clip_names: list[str] + alpha_method: str = "gvm" # "gvm" or "videomama" + params: InferenceParamsSchema = InferenceParamsSchema() + output_config: OutputConfigSchema = OutputConfigSchema() + + +class InferenceJobRequest(BaseModel): + clip_names: list[str] + params: InferenceParamsSchema = InferenceParamsSchema() + output_config: OutputConfigSchema = OutputConfigSchema() + frame_range: tuple[int, int] | None = None + + +class GVMJobRequest(BaseModel): + clip_names: list[str] + + +class VideoMaMaJobRequest(BaseModel): + clip_names: list[str] + chunk_size: int = 50 + + +class JobSchema(BaseModel): + id: str + job_type: str + clip_name: str + status: str + current_frame: int = 0 + total_frames: int = 0 + error_message: str | None = None + + +class JobListResponse(BaseModel): + current: JobSchema | None = None # first running job (backward compat) + running: list[JobSchema] = [] # all running jobs + queued: list[JobSchema] = [] + history: list[JobSchema] = [] + + +# --- System --- + + +class DeviceResponse(BaseModel): + device: str + + +class VRAMResponse(BaseModel): + total: float = 0.0 + reserved: float = 0.0 + allocated: float = 0.0 + free: float = 0.0 + name: str = "" + available: bool = False + + +# --- WebSocket --- + + +class WSMessage(BaseModel): + type: str + data: dict diff --git a/web/api/worker.py b/web/api/worker.py new file mode 100644 index 00000000..7460415f --- /dev/null +++ b/web/api/worker.py @@ -0,0 +1,327 @@ +"""Worker pool — CPU jobs run in parallel, GPU jobs check VRAM before starting.""" + +from __future__ import annotations + +import logging +import os +import threading +from concurrent.futures import ThreadPoolExecutor + +from backend.clip_state import ClipAsset, ClipState +from backend.errors import CorridorKeyError, JobCancelledError +from backend.ffmpeg_tools import extract_frames +from backend.job_queue import GPUJob, GPUJobQueue, JobStatus, JobType +from backend.project import is_video_file +from backend.service import CorridorKeyService, InferenceParams, OutputConfig + +from .ws import manager + +logger = logging.getLogger(__name__) + +# CPU-only job types that don't need VRAM +_CPU_JOB_TYPES = {JobType.VIDEO_EXTRACT, JobType.VIDEO_STITCH} + +# Configurable VRAM limit (GB). Jobs won't start if free VRAM is below this. +# Set to 0 to disable the check (always allow). +_vram_limit_gb: float = 0.0 +_vram_lock = threading.Lock() + + +def set_vram_limit(gb: float) -> None: + global _vram_limit_gb + _vram_limit_gb = max(0.0, gb) + logger.info(f"VRAM limit set to {_vram_limit_gb:.1f} GB") + + +def get_vram_limit() -> float: + return _vram_limit_gb + + +def _get_free_vram_gb() -> float | None: + """Return free VRAM in GB, or None if unavailable.""" + try: + import torch + + if not torch.cuda.is_available(): + return None + total = torch.cuda.get_device_properties(0).total_memory + reserved = torch.cuda.memory_reserved(0) + return (total - reserved) / (1024**3) + except Exception: + return None + + +def _can_start_gpu_job() -> bool: + """Check if there's enough free VRAM to start another GPU job. + + Note: VRAM checking only works on CUDA. On Mac/MLX, _get_free_vram_gb() + returns None and this gate is effectively disabled — multiple GPU jobs + can run simultaneously with no throttle. MLX unified memory checking + is not yet implemented. + """ + if _vram_limit_gb <= 0: + return True # no limit set + free = _get_free_vram_gb() + if free is None: + return True # non-CUDA (e.g. MLX) — can't check, allow it + can = free >= _vram_limit_gb + if not can: + logger.debug(f"VRAM check: {free:.1f} GB free < {_vram_limit_gb:.1f} GB limit, waiting") + return can + + +def _find_clip(service: CorridorKeyService, clips_dir: str, clip_name: str): + """Find a clip by name from the clips directory.""" + clips = service.scan_clips(clips_dir) + for clip in clips: + if clip.name == clip_name: + return clip + return None + + +def _execute_extraction(job: GPUJob, clip, clips_dir: str) -> None: + """Extract frames from a video clip.""" + video_path = None + + if clip.input_asset and clip.input_asset.asset_type == "video" and os.path.isfile(clip.input_asset.path): + video_path = clip.input_asset.path + else: + source_dir = os.path.join(clip.root_path, "Source") + if os.path.isdir(source_dir): + videos = [f for f in os.listdir(source_dir) if is_video_file(f)] + if videos: + video_path = os.path.join(source_dir, videos[0]) + + if not video_path: + raise CorridorKeyError(f"No video file found for clip '{clip.name}'") + frames_dir = os.path.join(clip.root_path, "Frames") + + cancel_event = threading.Event() + + def on_progress(current: int, total: int) -> None: + job.current_frame = current + job.total_frames = total + manager.send_job_progress(job.id, clip.name, current, total) + if job.is_cancelled: + cancel_event.set() + + count = extract_frames( + video_path, + frames_dir, + on_progress=on_progress, + cancel_event=cancel_event, + ) + logger.info(f"Extracted {count} frames for clip '{clip.name}'") + + clip.input_asset = ClipAsset(frames_dir, "sequence") + try: + clip.transition_to(ClipState.RAW) + except Exception: + pass + + manager.send_clip_state_changed(clip.name, "RAW") + + +def _execute_gpu_job(service: CorridorKeyService, job: GPUJob, clips_dir: str) -> None: + """Execute a GPU job (inference, GVM, VideoMaMa).""" + clip = _find_clip(service, clips_dir, job.clip_name) + if clip is None: + raise CorridorKeyError(f"Clip '{job.clip_name}' not found in {clips_dir}") + + def on_progress(clip_name: str, current: int, total: int) -> None: + job.current_frame = current + job.total_frames = total + manager.send_job_progress(job.id, clip_name, current, total) + if current % 10 == 0: + vram = service.get_vram_info() + if vram: + manager.send_vram_update(vram) + + def on_warning(message: str) -> None: + manager.send_job_warning(job.id, message) + + if job.job_type == JobType.INFERENCE: + params = InferenceParams.from_dict(job.params.get("inference_params", {})) + output_config = OutputConfig.from_dict(job.params.get("output_config", {})) + frame_range = job.params.get("frame_range") + service.run_inference( + clip, + params, + job=job, + on_progress=on_progress, + on_warning=on_warning, + output_config=output_config, + frame_range=tuple(frame_range) if frame_range else None, + ) + elif job.job_type == JobType.GVM_ALPHA: + service.run_gvm(clip, job=job, on_progress=on_progress, on_warning=on_warning) + elif job.job_type == JobType.VIDEOMAMA_ALPHA: + chunk_size = job.params.get("chunk_size", 50) + service.run_videomama(clip, job=job, on_progress=on_progress, on_warning=on_warning, chunk_size=chunk_size) + + manager.send_clip_state_changed(job.clip_name, clip.state.value) + + +def _chain_next_pipeline_step(job: GPUJob, queue: GPUJobQueue, clips_dir: str, service: CorridorKeyService) -> None: + """If this was a pipeline job, submit the next step.""" + if not job.params.get("pipeline"): + return + + # Re-scan the clip to get its current state after this step completed + clip = _find_clip(service, clips_dir, job.clip_name) + if clip is None: + return + + state = clip.state.value + params = job.params # carries pipeline config forward + + next_job: GPUJob | None = None + + if state == "RAW": + # Extraction done → need alpha generation + alpha_method = params.get("alpha_method", "gvm") + if alpha_method == "videomama": + next_job = GPUJob( + job_type=JobType.VIDEOMAMA_ALPHA, + clip_name=job.clip_name, + params={**params, "chunk_size": 50}, + ) + else: + next_job = GPUJob(job_type=JobType.GVM_ALPHA, clip_name=job.clip_name, params=params) + elif state == "READY": + # Alpha done → need inference + next_job = GPUJob( + job_type=JobType.INFERENCE, + clip_name=job.clip_name, + params=params, + ) + + if next_job and queue.submit(next_job): + logger.info(f"Pipeline chain: {job.job_type.value} → {next_job.job_type.value} for '{job.clip_name}'") + + +def _run_job(service: CorridorKeyService, job: GPUJob, queue: GPUJobQueue, clips_dir: str) -> None: + """Run a single job (called from thread pool).""" + queue.start_job(job) + manager.send_job_status(job.id, JobStatus.RUNNING.value) + + try: + if job.job_type in _CPU_JOB_TYPES: + clip = _find_clip(service, clips_dir, job.clip_name) + if clip is None: + raise CorridorKeyError(f"Clip '{job.clip_name}' not found") + _execute_extraction(job, clip, clips_dir) + else: + _execute_gpu_job(service, job, clips_dir) + + queue.complete_job(job) + manager.send_job_status(job.id, JobStatus.COMPLETED.value) + + # Auto-chain next pipeline step + _chain_next_pipeline_step(job, queue, clips_dir, service) + except JobCancelledError: + queue.mark_cancelled(job) + manager.send_job_status(job.id, JobStatus.CANCELLED.value) + except Exception as e: + error_msg = str(e) + logger.exception(f"Job {job.id} failed: {error_msg}") + queue.fail_job(job, error_msg) + manager.send_job_status(job.id, JobStatus.FAILED.value, error=error_msg) + + +# Track running GPU jobs +_running_gpu_count = 0 +_running_gpu_lock = threading.Lock() + + +def _detect_local_gpu_count() -> int: + """Detect number of local GPUs for worker concurrency.""" + try: + import torch + + if torch.cuda.is_available(): + return max(1, torch.cuda.device_count()) + except Exception: + pass + return 1 + + +def worker_loop( + service: CorridorKeyService, + queue: GPUJobQueue, + clips_dir: str, + stop_event: threading.Event, + max_gpu_workers: int = 0, # 0 = auto-detect (1 per GPU) + max_cpu_workers: int = 4, +) -> None: + """Main worker loop with parallel execution. + + CPU jobs (extraction) run in a separate thread pool and never block GPU jobs. + GPU jobs are limited to one per physical GPU to prevent model thrashing — + CorridorKey and GVM can't share a single GPU simultaneously. + """ + global _running_gpu_count + + if max_gpu_workers <= 0: + max_gpu_workers = _detect_local_gpu_count() + + gpu_pool = ThreadPoolExecutor(max_workers=max_gpu_workers, thread_name_prefix="gpu-worker") + cpu_pool = ThreadPoolExecutor(max_workers=max_cpu_workers, thread_name_prefix="cpu-worker") + + logger.info(f"Worker pool started (GPU workers: {max_gpu_workers}, CPU workers: {max_cpu_workers})") + + def _on_gpu_done(future, job=None): + global _running_gpu_count + with _running_gpu_lock: + _running_gpu_count -= 1 + + while not stop_event.is_set(): + job = queue.next_job() + if job is None: + stop_event.wait(0.5) + continue + + is_cpu = job.job_type in _CPU_JOB_TYPES + + if is_cpu: + # CPU jobs always start immediately + future = cpu_pool.submit(_run_job, service, job, queue, clips_dir) + else: + # GPU job — check VRAM and concurrency + with _running_gpu_lock: + if _running_gpu_count >= max_gpu_workers: + # Pool full, wait + stop_event.wait(0.5) + continue + + if not _can_start_gpu_job(): + # Not enough VRAM, wait and retry + stop_event.wait(1.0) + continue + + _running_gpu_count += 1 + + future = gpu_pool.submit(_run_job, service, job, queue, clips_dir) + future.add_done_callback(lambda f, j=job: _on_gpu_done(f, j)) + + logger.info("Shutting down worker pools") + gpu_pool.shutdown(wait=True, cancel_futures=True) + cpu_pool.shutdown(wait=True, cancel_futures=True) + logger.info("Worker pools stopped") + + +def start_worker( + service: CorridorKeyService, + queue: GPUJobQueue, + clips_dir: str, +) -> tuple[threading.Thread, threading.Event]: + """Start the worker daemon thread. Returns (thread, stop_event).""" + stop_event = threading.Event() + thread = threading.Thread( + target=worker_loop, + args=(service, queue, clips_dir, stop_event), + daemon=True, + name="worker-dispatcher", + ) + thread.start() + return thread, stop_event diff --git a/web/api/ws.py b/web/api/ws.py new file mode 100644 index 00000000..4ff47aeb --- /dev/null +++ b/web/api/ws.py @@ -0,0 +1,106 @@ +"""WebSocket endpoint and connection manager for real-time updates.""" + +from __future__ import annotations + +import asyncio +import json +import logging +from typing import Any + +from fastapi import WebSocket, WebSocketDisconnect + +logger = logging.getLogger(__name__) + + +class ConnectionManager: + """Manages active WebSocket connections and broadcasts messages.""" + + def __init__(self): + self._connections: list[WebSocket] = [] + self._loop: asyncio.AbstractEventLoop | None = None + + def set_loop(self, loop: asyncio.AbstractEventLoop) -> None: + self._loop = loop + + async def connect(self, ws: WebSocket) -> None: + await ws.accept() + self._connections.append(ws) + logger.info(f"WebSocket connected ({len(self._connections)} total)") + + def disconnect(self, ws: WebSocket) -> None: + if ws in self._connections: + self._connections.remove(ws) + logger.info(f"WebSocket disconnected ({len(self._connections)} total)") + + async def _broadcast(self, message: dict[str, Any]) -> None: + payload = json.dumps(message) + dead: list[WebSocket] = [] + for ws in self._connections: + try: + await ws.send_text(payload) + except Exception: + dead.append(ws) + for ws in dead: + self.disconnect(ws) + + def broadcast_sync(self, message: dict[str, Any]) -> None: + """Thread-safe broadcast from the worker thread.""" + if not self._connections or self._loop is None: + return + try: + asyncio.run_coroutine_threadsafe(self._broadcast(message), self._loop) + except RuntimeError: + pass + + def send_job_progress(self, job_id: str, clip_name: str, current: int, total: int) -> None: + self.broadcast_sync( + { + "type": "job:progress", + "data": {"job_id": job_id, "clip_name": clip_name, "current": current, "total": total}, + } + ) + + def send_job_status(self, job_id: str, status: str, error: str | None = None) -> None: + self.broadcast_sync( + { + "type": "job:status", + "data": {"job_id": job_id, "status": status, "error": error}, + } + ) + + def send_job_warning(self, job_id: str, message: str) -> None: + self.broadcast_sync( + { + "type": "job:warning", + "data": {"job_id": job_id, "message": message}, + } + ) + + def send_clip_state_changed(self, clip_name: str, new_state: str) -> None: + self.broadcast_sync( + { + "type": "clip:state_changed", + "data": {"clip_name": clip_name, "new_state": new_state}, + } + ) + + def send_vram_update(self, vram: dict) -> None: + self.broadcast_sync( + { + "type": "vram:update", + "data": vram, + } + ) + + +manager = ConnectionManager() + + +async def websocket_endpoint(ws: WebSocket) -> None: + await manager.connect(ws) + try: + while True: + # Keep connection alive; we don't expect client messages + await ws.receive_text() + except WebSocketDisconnect: + manager.disconnect(ws) diff --git a/web/frontend/.gitignore b/web/frontend/.gitignore new file mode 100644 index 00000000..3b462cb0 --- /dev/null +++ b/web/frontend/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/web/frontend/.npmrc b/web/frontend/.npmrc new file mode 100644 index 00000000..b6f27f13 --- /dev/null +++ b/web/frontend/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/web/frontend/README.md b/web/frontend/README.md new file mode 100644 index 00000000..95fabbba --- /dev/null +++ b/web/frontend/README.md @@ -0,0 +1,42 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project +npx sv create my-app +``` + +To recreate this project with the same configuration: + +```sh +# recreate this project +npx sv@0.12.7 create --template minimal --types ts --no-install frontend +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/web/frontend/package-lock.json b/web/frontend/package-lock.json new file mode 100644 index 00000000..28940381 --- /dev/null +++ b/web/frontend/package-lock.json @@ -0,0 +1,1541 @@ +{ + "name": "frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.1", + "dependencies": { + "@sveltejs/adapter-static": "^3.0.10" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "svelte": "^5.51.0", + "svelte-check": "^4.4.2", + "typescript": "^5.9.3", + "vite": "^7.3.1" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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==", + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-auto": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz", + "integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz", + "integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.6.4", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", + "license": "MIT", + "dependencies": { + "obug": "^2.1.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "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==", + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz", + "integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==", + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/devalue": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", + "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "@typescript-eslint/types": "^8.2.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==", + "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==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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==", + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "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==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "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==", + "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/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", + "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==", + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.53.11", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.11.tgz", + "integrity": "sha512-GYmqRjRhJYLQBonfdfGAt28gkfWEShrtXKGXcFGneXi502aBE+I1dJcs/YQriByvP6xqXRz/OdBGC6tfvUQHyQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.3", + "esm-env": "^1.2.1", + "esrap": "^2.2.2", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.5.tgz", + "integrity": "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "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/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "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", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.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 + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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/vitefu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", + "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT" + } + } +} diff --git a/web/frontend/package.json b/web/frontend/package.json new file mode 100644 index 00000000..59383fc6 --- /dev/null +++ b/web/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "svelte": "^5.51.0", + "svelte-check": "^4.4.2", + "typescript": "^5.9.3", + "vite": "^7.3.1" + }, + "dependencies": { + "@sveltejs/adapter-static": "^3.0.10" + } +} diff --git a/web/frontend/src/app.css b/web/frontend/src/app.css new file mode 100644 index 00000000..1b76736e --- /dev/null +++ b/web/frontend/src/app.css @@ -0,0 +1,146 @@ +/* CorridorKey WebUI — Corridor Digital Brand */ + +:root { + /* Surface layers — warm blacks */ + --surface-0: #000000; + --surface-1: #0c0b08; + --surface-2: #151411; + --surface-3: #1c1b17; + --surface-4: #2a2923; + --surface-5: #353430; + + /* Corridor Digital signature yellow */ + --accent: #fff203; + --accent-dim: #e6da00; + --accent-muted: rgba(255, 242, 3, 0.10); + --accent-glow: rgba(255, 242, 3, 0.05); + --accent-strong: rgba(255, 242, 3, 0.18); + + /* Secondary: Corridor cyan blue */ + --secondary: #009ADA; + --secondary-hover: #00B5FF; + --secondary-muted: rgba(0, 154, 218, 0.12); + + /* State colors — all readable on dark bg */ + --state-raw: #f0a030; + --state-ready: #3db8ff; + --state-complete: #5dd879; + --state-error: #ff5252; + --state-extracting: #ffab40; + --state-masked: #ce93d8; + --state-running: #fff203; + --state-queued: #90a4ae; + --state-cancelled: #757575; + --state-failed: #ff5252; + + /* Text hierarchy */ + --text-primary: #f0efe8; + --text-secondary: #9d9c93; + --text-tertiary: #605f56; + --text-accent: var(--accent); + + /* Borders */ + --border: rgba(255, 255, 255, 0.07); + --border-subtle: rgba(255, 255, 255, 0.04); + --border-active: rgba(255, 242, 3, 0.35); + + /* Typography */ + --font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; + --font-sans: 'Outfit', -apple-system, BlinkMacSystemFont, sans-serif; + + /* Spacing scale */ + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 14px; + --sp-4: 20px; + --sp-5: 26px; + --sp-6: 32px; + --sp-8: 44px; + --sp-10: 56px; + + /* Radius */ + --radius-sm: 5px; + --radius-md: 8px; + --radius-lg: 12px; + + /* Sidebar width */ + --sidebar-w: 280px; +} + +/* Reset */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + height: 100%; + background: var(--surface-0); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 16px; + line-height: 1.5; + font-weight: 400; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow: hidden; +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 5px; + height: 5px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--surface-5); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-tertiary); +} + +/* Global link reset */ +a { + color: inherit; + text-decoration: none; +} + +/* Utility: monospace readout text */ +.mono { + font-family: var(--font-mono); + font-size: 13px; + letter-spacing: 0.01em; +} + +/* Focus ring */ +:focus-visible { + outline: 1.5px solid var(--accent); + outline-offset: 2px; +} + +/* Selection */ +::selection { + background: rgba(255, 242, 3, 0.2); + color: #fff; +} + +/* Film grain overlay — subtle cinematic texture */ +body::after { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 9999; + opacity: 0.02; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); + background-size: 256px 256px; +} diff --git a/web/frontend/src/app.d.ts b/web/frontend/src/app.d.ts new file mode 100644 index 00000000..da08e6da --- /dev/null +++ b/web/frontend/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/web/frontend/src/app.html b/web/frontend/src/app.html new file mode 100644 index 00000000..1ba150fc --- /dev/null +++ b/web/frontend/src/app.html @@ -0,0 +1,14 @@ + + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/web/frontend/src/components/ClipCard.svelte b/web/frontend/src/components/ClipCard.svelte new file mode 100644 index 00000000..a0c83993 --- /dev/null +++ b/web/frontend/src/components/ClipCard.svelte @@ -0,0 +1,180 @@ + + + +
+ {#if thumbUrl} + {clip.name} preview + {:else} +
+ + + + + +
+ {/if} + {clip.state} +
+
+ +
+ {clip.name} +
+ {clip.frame_count} frames + {#if clip.completed_frames > 0} + · + {clip.completed_frames} done + {/if} +
+ {#if clip.error_message} + {clip.error_message} + {/if} +
+
+ + diff --git a/web/frontend/src/components/ContextMenu.svelte b/web/frontend/src/components/ContextMenu.svelte new file mode 100644 index 00000000..90e66610 --- /dev/null +++ b/web/frontend/src/components/ContextMenu.svelte @@ -0,0 +1,134 @@ + + + + +{#if visible && items.length > 0} +
+ {#each items as item} + {#if item.label === '---'} +
+ {:else} + + {/if} + {/each} +
+{/if} + + diff --git a/web/frontend/src/components/FrameViewer.svelte b/web/frontend/src/components/FrameViewer.svelte new file mode 100644 index 00000000..74f4fc3c --- /dev/null +++ b/web/frontend/src/components/FrameViewer.svelte @@ -0,0 +1,498 @@ + + + + +
+
+ {#if mode === 'compare'} +
+ {#if compareUrl} + Compare — {comparePass} + {/if} + {passLabels[comparePass] ?? comparePass} +
+
+
+ {#if imgUrl} + Frame {currentFrame} — {selectedPass} + {/if} + {passLabels[selectedPass] ?? selectedPass} +
+ {:else if mode === 'video' && videoUrl} + + + {:else if imgUrl && !error} + Frame {currentFrame} — {selectedPass} + {/if} + {#if loading && mode === 'frame'} +
+ {/if} + {#if error && mode === 'frame'} +
Frame unavailable
+ {/if} + {#if !imgUrl && !videoUrl} +
No frames
+ {/if} + {#if mode !== 'video' && frameCount > 0} +
{currentFrame + 1} / {frameCount}
+ {/if} +
+ +
+
+
+ {#each availablePasses as pass} + + {/each} +
+ +
+ {#if frameCount > 1} +
+ + + +
+ {/if} + {#if downloadUrl} + + + + + + {passLabels[selectedPass] ?? selectedPass} + + {/if} +
+
+ + {#if (mode === 'frame' || mode === 'compare') && frameCount > 1} +
+ + + +
+ {/if} + + {#if mode === 'compare'} +
+ COMPARE LEFT +
+ {#each availablePasses as pass} + + {/each} +
+
+ {/if} + + {#if mode === 'video' && frameCount > 1} +
+ FPS + + Change FPS to re-encode preview +
+ {/if} +
+
+ + diff --git a/web/frontend/src/components/InferenceForm.svelte b/web/frontend/src/components/InferenceForm.svelte new file mode 100644 index 00000000..8330929f --- /dev/null +++ b/web/frontend/src/components/InferenceForm.svelte @@ -0,0 +1,308 @@ + + +
+
+

INFERENCE PARAMS

+ + + + + + + +
+ + +
+
+ +
+

OUTPUT PASSES

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + diff --git a/web/frontend/src/components/JobRow.svelte b/web/frontend/src/components/JobRow.svelte new file mode 100644 index 00000000..fb489ced --- /dev/null +++ b/web/frontend/src/components/JobRow.svelte @@ -0,0 +1,219 @@ + + + +
+
+ + {label} +
+ +
+ {job.clip_name} + {#if isRunning} + + {:else} + {job.status.toUpperCase()} + {/if} +
+ +
+ {job.id} + {#if showCancel && (job.status === 'running' || job.status === 'queued')} + + {/if} +
+ + {#if job.error_message} +
+ {job.error_message} + {#if isFailed} + {expanded ? '▲ collapse' : '▼ click for details'} + {/if} +
+ {/if} + {#if expanded && logDetail} +
{logDetail}
+ {/if} +
+ + diff --git a/web/frontend/src/components/KeyboardHelp.svelte b/web/frontend/src/components/KeyboardHelp.svelte new file mode 100644 index 00000000..20428a9d --- /dev/null +++ b/web/frontend/src/components/KeyboardHelp.svelte @@ -0,0 +1,114 @@ + + + + +{#if visible} + +
{ visible = false; }}> + +
e.stopPropagation()}> +
+

Keyboard Shortcuts

+ +
+
+ {#each shortcuts as s} +
+ {s.keys} + {s.desc} +
+ {/each} +
+
+
+{/if} + + diff --git a/web/frontend/src/components/ProgressBar.svelte b/web/frontend/src/components/ProgressBar.svelte new file mode 100644 index 00000000..d40a7667 --- /dev/null +++ b/web/frontend/src/components/ProgressBar.svelte @@ -0,0 +1,130 @@ + + +
+
+
+
+ {#if showLabel && total > 0} +
+ {current}/{total} · {pct.toFixed(0)}% + {#if fps} + {fps} fps + {/if} + {#if eta} + {eta} left + {/if} +
+ {/if} +
+ + diff --git a/web/frontend/src/components/ToastContainer.svelte b/web/frontend/src/components/ToastContainer.svelte new file mode 100644 index 00000000..1ae3df76 --- /dev/null +++ b/web/frontend/src/components/ToastContainer.svelte @@ -0,0 +1,64 @@ + + +{#if $toasts.length > 0} +
+ {#each $toasts as t (t.id)} + + {/each} +
+{/if} + + diff --git a/web/frontend/src/components/VramMeter.svelte b/web/frontend/src/components/VramMeter.svelte new file mode 100644 index 00000000..d6dd2446 --- /dev/null +++ b/web/frontend/src/components/VramMeter.svelte @@ -0,0 +1,106 @@ + + +
+
+ VRAM + {#if info && info.available} + {allocGb}/{totalGb} GB + {:else} + N/A + {/if} +
+
+
80} + class:crit={pct > 95} + style="width: {pct}%" + >
+ +
+
+
+
+ {#if info?.name} + {info.name} + {/if} +
+ + diff --git a/web/frontend/src/lib/api.ts b/web/frontend/src/lib/api.ts new file mode 100644 index 00000000..1429baa9 --- /dev/null +++ b/web/frontend/src/lib/api.ts @@ -0,0 +1,226 @@ +/** Typed fetch wrappers for the CorridorKey API. */ + +const BASE = ''; + +async function request(method: string, path: string, body?: unknown): Promise { + const opts: RequestInit = { + method, + headers: { 'Content-Type': 'application/json' } + }; + if (body !== undefined) { + opts.body = JSON.stringify(body); + } + const res = await fetch(`${BASE}${path}`, opts); + if (!res.ok) { + const detail = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(detail.detail || res.statusText); + } + return res.json(); +} + +// --- Types --- + +export interface ClipAsset { + path: string; + asset_type: string; + frame_count: number; +} + +export interface Clip { + name: string; + root_path: string; + state: string; + input_asset: ClipAsset | null; + alpha_asset: ClipAsset | null; + mask_asset: ClipAsset | null; + frame_count: number; + completed_frames: number; + has_outputs: boolean; + warnings: string[]; + error_message: string | null; +} + +export interface ClipListResponse { + clips: Clip[]; + clips_dir: string; +} + +export interface Job { + id: string; + job_type: string; + clip_name: string; + status: string; + current_frame: number; + total_frames: number; + error_message: string | null; +} + +export interface JobListResponse { + current: Job | null; + running: Job[]; + queued: Job[]; + history: Job[]; +} + +export interface InferenceParams { + input_is_linear: boolean; + despill_strength: number; + auto_despeckle: boolean; + despeckle_size: number; + refiner_scale: number; +} + +export interface OutputConfig { + fg_enabled: boolean; + fg_format: string; + matte_enabled: boolean; + matte_format: string; + comp_enabled: boolean; + comp_format: string; + processed_enabled: boolean; + processed_format: string; +} + +export interface VRAMInfo { + total: number; + reserved: number; + allocated: number; + free: number; + name: string; + available: boolean; +} + +export interface Project { + name: string; + display_name: string; + path: string; + clip_count: number; + created: string | null; + clips: Clip[]; +} + +export interface DeviceInfo { + device: string; +} + +export interface WeightInfo { + installed: boolean; + path: string; + detail: string | null; + size_hint: string; + download?: { status: string; error: string | null }; +} + +// --- API calls --- + +export const api = { + projects: { + list: () => request('GET', '/api/projects'), + create: (name: string) => request('POST', '/api/projects', { name }), + rename: (name: string, display_name: string) => + request('PATCH', `/api/projects/${encodeURIComponent(name)}`, { display_name }), + delete: (name: string) => request('DELETE', `/api/projects/${encodeURIComponent(name)}`) + }, + clips: { + list: () => request('GET', '/api/clips'), + get: (name: string) => request('GET', `/api/clips/${encodeURIComponent(name)}`), + delete: (name: string) => request('DELETE', `/api/clips/${encodeURIComponent(name)}`), + move: (name: string, targetProject: string) => + request('POST', `/api/clips/${encodeURIComponent(name)}/move?target_project=${encodeURIComponent(targetProject)}`) + }, + jobs: { + list: () => request('GET', '/api/jobs'), + submitInference: ( + clip_names: string[], + params?: Partial, + output_config?: Partial, + frame_range?: [number, number] | null + ) => + request('POST', '/api/jobs/inference', { + clip_names, + params: params ?? {}, + output_config: output_config ?? {}, + frame_range: frame_range ?? null + }), + submitPipeline: ( + clip_names: string[], + alpha_method = 'gvm', + params?: Partial, + output_config?: Partial + ) => + request('POST', '/api/jobs/pipeline', { + clip_names, + alpha_method, + params: params ?? {}, + output_config: output_config ?? {} + }), + submitExtract: (clip_names: string[]) => + request('POST', '/api/jobs/extract', { clip_names }), + submitGVM: (clip_names: string[]) => + request('POST', '/api/jobs/gvm', { clip_names }), + submitVideoMaMa: (clip_names: string[], chunk_size = 50) => + request('POST', '/api/jobs/videomama', { clip_names, chunk_size }), + getLog: (jobId: string) => request>('GET', `/api/jobs/${jobId}/log`), + cancel: (jobId: string) => request('DELETE', `/api/jobs/${jobId}`), + cancelAll: () => request('DELETE', '/api/jobs') + }, + system: { + device: () => request('GET', '/api/system/device'), + vram: () => request('GET', '/api/system/vram'), + unload: () => request('POST', '/api/system/unload'), + getVramLimit: () => request<{ vram_limit_gb: number }>('GET', '/api/system/vram-limit'), + setVramLimit: (gb: number) => request('POST', `/api/system/vram-limit?vram_limit_gb=${gb}`), + weights: () => request>('GET', '/api/system/weights'), + downloadWeights: (name: string) => request('POST', `/api/system/weights/download/${name}`) + }, + upload: { + video: async (file: File, name?: string, autoExtract = true): Promise<{ status: string; clips: Clip[]; extract_jobs: string[] }> => { + const form = new FormData(); + form.append('file', file); + const qs = new URLSearchParams(); + if (name) qs.set('name', name); + qs.set('auto_extract', String(autoExtract)); + const res = await fetch(`${BASE}/api/upload/video?${qs}`, { method: 'POST', body: form }); + if (!res.ok) { + const detail = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(detail.detail || res.statusText); + } + return res.json(); + }, + frames: async (file: File, name?: string): Promise<{ status: string; clips: Clip[]; frame_count: number }> => { + const form = new FormData(); + form.append('file', file); + const params = name ? `?name=${encodeURIComponent(name)}` : ''; + const res = await fetch(`${BASE}/api/upload/frames${params}`, { method: 'POST', body: form }); + if (!res.ok) { + const detail = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(detail.detail || res.statusText); + } + return res.json(); + }, + mask: async (clipName: string, file: File): Promise => { + const form = new FormData(); + form.append('file', file); + const res = await fetch(`${BASE}/api/upload/mask/${encodeURIComponent(clipName)}`, { method: 'POST', body: form }); + if (!res.ok) { + const detail = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(detail.detail || res.statusText); + } + return res.json(); + }, + alpha: async (clipName: string, file: File): Promise => { + const form = new FormData(); + form.append('file', file); + const res = await fetch(`${BASE}/api/upload/alpha/${encodeURIComponent(clipName)}`, { method: 'POST', body: form }); + if (!res.ok) { + const detail = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(detail.detail || res.statusText); + } + return res.json(); + } + }, + preview: { + url: (clipName: string, passName: string, frame: number) => + `${BASE}/api/preview/${encodeURIComponent(clipName)}/${passName}/${frame}` + } +}; diff --git a/web/frontend/src/lib/assets/favicon.svg b/web/frontend/src/lib/assets/favicon.svg new file mode 100644 index 00000000..cc5dc66a --- /dev/null +++ b/web/frontend/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/web/frontend/src/lib/index.ts b/web/frontend/src/lib/index.ts new file mode 100644 index 00000000..856f2b6c --- /dev/null +++ b/web/frontend/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/web/frontend/src/lib/stores/clips.ts b/web/frontend/src/lib/stores/clips.ts new file mode 100644 index 00000000..687796e3 --- /dev/null +++ b/web/frontend/src/lib/stores/clips.ts @@ -0,0 +1,22 @@ +import { writable } from 'svelte/store'; +import type { Clip } from '$lib/api'; +import { api } from '$lib/api'; + +export const clips = writable([]); +export const clipsDir = writable(''); +export const clipsLoading = writable(false); +export const clipsError = writable(null); + +export async function refreshClips() { + clipsLoading.set(true); + clipsError.set(null); + try { + const res = await api.clips.list(); + clips.set(res.clips); + clipsDir.set(res.clips_dir); + } catch (e) { + clipsError.set(e instanceof Error ? e.message : String(e)); + } finally { + clipsLoading.set(false); + } +} diff --git a/web/frontend/src/lib/stores/jobs.ts b/web/frontend/src/lib/stores/jobs.ts new file mode 100644 index 00000000..dde48b04 --- /dev/null +++ b/web/frontend/src/lib/stores/jobs.ts @@ -0,0 +1,98 @@ +import { writable, derived, get } from 'svelte/store'; +import type { Job } from '$lib/api'; +import { api } from '$lib/api'; + +/** First running job (backward compat for activity bar). */ +export const currentJob = writable(null); +/** All currently running jobs. */ +export const runningJobs = writable([]); +export const queuedJobs = writable([]); +export const jobHistory = writable([]); + +/** Timestamp when the current job started (for ETA calculation). */ +export const jobStartedAt = writable(null); + +export const activeJobCount = derived( + [runningJobs, queuedJobs], + ([$running, $queued]) => $running.length + $queued.length +); + +let refreshPending = false; + +export async function refreshJobs() { + if (refreshPending) return; + refreshPending = true; + try { + const res = await api.jobs.list(); + const prev = get(currentJob); + currentJob.set(res.current); + runningJobs.set(res.running ?? (res.current ? [res.current] : [])); + queuedJobs.set(res.queued); + jobHistory.set(res.history); + + // Track when a new job starts running + if (res.current && (!prev || prev.id !== res.current.id)) { + jobStartedAt.set(Date.now()); + } else if (!res.current) { + jobStartedAt.set(null); + } + } catch { + // silently fail + } finally { + refreshPending = false; + } +} + +/** + * Update a job from a WebSocket message. + * Returns true if the job was found and updated, false if not. + */ +export function updateJobFromWS(jobId: string, updates: Partial): boolean { + let found = false; + + // Check running jobs + runningJobs.update((jobs) => + jobs.map((j) => { + if (j.id === jobId) { + found = true; + return { ...j, ...updates }; + } + return j; + }) + ); + + // Also update currentJob for backward compat + currentJob.update((j) => { + if (j && j.id === jobId) { + found = true; + return { ...j, ...updates }; + } + return j; + }); + + if (!found) { + queuedJobs.update((jobs) => + jobs.map((j) => { + if (j.id === jobId) { + found = true; + return { ...j, ...updates }; + } + return j; + }) + ); + } + + if (!found) { + jobHistory.update((jobs) => + jobs.map((j) => { + if (j.id === jobId) { + found = true; + return { ...j, ...updates }; + } + return j; + }) + ); + } + + return found; +} diff --git a/web/frontend/src/lib/stores/settings.ts b/web/frontend/src/lib/stores/settings.ts new file mode 100644 index 00000000..4472b3af --- /dev/null +++ b/web/frontend/src/lib/stores/settings.ts @@ -0,0 +1,55 @@ +import { writable } from 'svelte/store'; +import type { InferenceParams, OutputConfig } from '$lib/api'; + +/** + * Create a writable store that persists to localStorage. + * Falls back to the default value if localStorage is unavailable or empty. + */ +function persisted(key: string, defaultValue: T) { + let initial = defaultValue; + if (typeof window !== 'undefined') { + try { + const stored = localStorage.getItem(key); + if (stored !== null) { + initial = JSON.parse(stored); + } + } catch { + // ignore parse errors + } + } + + const store = writable(initial); + + store.subscribe((value) => { + if (typeof window !== 'undefined') { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // ignore quota errors + } + } + }); + + return store; +} + +export const autoExtractFrames = persisted('ck:autoExtractFrames', true); + +export const defaultParams = persisted('ck:defaultParams', { + input_is_linear: false, + despill_strength: 1.0, + auto_despeckle: true, + despeckle_size: 400, + refiner_scale: 1.0 +}); + +export const defaultOutputConfig = persisted('ck:defaultOutputConfig', { + fg_enabled: true, + fg_format: 'exr', + matte_enabled: true, + matte_format: 'exr', + comp_enabled: true, + comp_format: 'png', + processed_enabled: true, + processed_format: 'exr' +}); diff --git a/web/frontend/src/lib/stores/system.ts b/web/frontend/src/lib/stores/system.ts new file mode 100644 index 00000000..fdbb39cf --- /dev/null +++ b/web/frontend/src/lib/stores/system.ts @@ -0,0 +1,27 @@ +import { writable } from 'svelte/store'; +import type { VRAMInfo } from '$lib/api'; +import { api } from '$lib/api'; + +export const device = writable('detecting...'); +export const vram = writable(null); +export const wsConnected = writable(false); + +export async function refreshDevice() { + try { + const res = await api.system.device(); + device.set(res.device); + } catch { + device.set('unknown'); + } +} + +export async function refreshVRAM() { + try { + const res = await api.system.vram(); + if (res.available) { + vram.set(res); + } + } catch { + // ignore + } +} diff --git a/web/frontend/src/lib/stores/toasts.ts b/web/frontend/src/lib/stores/toasts.ts new file mode 100644 index 00000000..64284805 --- /dev/null +++ b/web/frontend/src/lib/stores/toasts.ts @@ -0,0 +1,32 @@ +import { writable } from 'svelte/store'; + +export interface Toast { + id: number; + message: string; + type: 'info' | 'success' | 'error' | 'warning'; + duration: number; +} + +let nextId = 0; + +export const toasts = writable([]); + +export function addToast(message: string, type: Toast['type'] = 'info', duration = 4000) { + const id = nextId++; + toasts.update((t) => [...t, { id, message, type, duration }]); + if (duration > 0) { + setTimeout(() => removeToast(id), duration); + } +} + +export function removeToast(id: number) { + toasts.update((t) => t.filter((toast) => toast.id !== id)); +} + +// Convenience aliases +export const toast = { + info: (msg: string, duration?: number) => addToast(msg, 'info', duration), + success: (msg: string, duration?: number) => addToast(msg, 'success', duration), + error: (msg: string, duration?: number) => addToast(msg, 'error', duration ?? 6000), + warning: (msg: string, duration?: number) => addToast(msg, 'warning', duration), +}; diff --git a/web/frontend/src/lib/ws.ts b/web/frontend/src/lib/ws.ts new file mode 100644 index 00000000..393311fd --- /dev/null +++ b/web/frontend/src/lib/ws.ts @@ -0,0 +1,81 @@ +/** WebSocket client with auto-reconnect. */ + +export interface WSMessage { + type: string; + data: Record; +} + +type MessageHandler = (msg: WSMessage) => void; + +let socket: WebSocket | null = null; +let handlers: MessageHandler[] = []; +let reconnectTimer: ReturnType | null = null; +let intentionallyClosed = false; + +function getWsUrl(): string { + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${proto}//${window.location.host}/ws`; +} + +function doConnect() { + if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) { + return; + } + + socket = new WebSocket(getWsUrl()); + + socket.onopen = () => { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + }; + + socket.onmessage = (event) => { + try { + const msg: WSMessage = JSON.parse(event.data); + for (const handler of handlers) { + handler(msg); + } + } catch { + // ignore malformed messages + } + }; + + socket.onclose = () => { + socket = null; + if (!intentionallyClosed) { + reconnectTimer = setTimeout(doConnect, 2000); + } + }; + + socket.onerror = () => { + socket?.close(); + }; +} + +export function connect() { + intentionallyClosed = false; + doConnect(); +} + +export function disconnect() { + intentionallyClosed = true; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + socket?.close(); + socket = null; +} + +export function onMessage(handler: MessageHandler): () => void { + handlers.push(handler); + return () => { + handlers = handlers.filter((h) => h !== handler); + }; +} + +export function isConnected(): boolean { + return socket?.readyState === WebSocket.OPEN; +} diff --git a/web/frontend/src/routes/+layout.svelte b/web/frontend/src/routes/+layout.svelte new file mode 100644 index 00000000..9f594a5a --- /dev/null +++ b/web/frontend/src/routes/+layout.svelte @@ -0,0 +1,402 @@ + + +
+ + +
+ {#each $runningJobs as rJob (rJob.id)} +
+
+ {rJob.job_type.replace('_', ' ')} + {rJob.clip_name} + {#if rJob.total_frames > 0} + {Math.round((rJob.current_frame / rJob.total_frames) * 100)}% + {/if} +
+
+
+
+
+ {/each} + {@render children()} +
+
+ + + + + diff --git a/web/frontend/src/routes/+page.svelte b/web/frontend/src/routes/+page.svelte new file mode 100644 index 00000000..9204e6b0 --- /dev/null +++ b/web/frontend/src/routes/+page.svelte @@ -0,0 +1,5 @@ + diff --git a/web/frontend/src/routes/clips/+page.svelte b/web/frontend/src/routes/clips/+page.svelte new file mode 100644 index 00000000..c6d20f99 --- /dev/null +++ b/web/frontend/src/routes/clips/+page.svelte @@ -0,0 +1,670 @@ + + + + Clips — CorridorKey + + + +
+ + + {#if showCreateForm} +
+ + + +
+ {/if} + + {#if error || uploadError} +
+ {error || uploadError} +
+ {/if} + + {#if dragOver} +
+
+ + Drop videos or zipped frames +
+
+ {/if} + + {#if projects.length === 0 && !loading} +
+ + + + +

No projects yet

+

Drag & drop video files here, or click Upload to get started.

+
+ {:else} +
+ {#each projects as project (project.name)} + {@const collapsed = collapsedProjects.has(project.name)} +
+ +
toggleProject(project.name)} oncontextmenu={(e) => showProjectContext(e, project)}> + + + + {project.display_name} + {project.clip_count} clip{project.clip_count !== 1 ? 's' : ''} + {#if project.created} + {new Date(project.created).toLocaleDateString()} + {/if} + +
+ {#if !collapsed} +
{ e.preventDefault(); e.currentTarget.classList.add('drop-target'); }} + ondragleave={(e) => { e.currentTarget.classList.remove('drop-target'); }} + ondrop={async (e) => { + e.preventDefault(); + e.currentTarget.classList.remove('drop-target'); + const clipName = e.dataTransfer?.getData('text/clip-name'); + if (clipName) { + try { + await api.clips.move(clipName, project.name); + await loadProjects(); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } + }} + > + {#if project.clips.length === 0} +

Drop clips here or upload new ones

+ {:else} +
+ {#each project.clips as clip (clip.name)} +
{ e.dataTransfer?.setData('text/clip-name', clip.name); }} + oncontextmenu={(e) => showClipContext(e, clip, project)} + > + + {#if projects.length > 1} + + {/if} +
+ {/each} +
+ {/if} +
+ {/if} +
+ {/each} +
+ {/if} +
+ + + + diff --git a/web/frontend/src/routes/clips/[name]/+page.svelte b/web/frontend/src/routes/clips/[name]/+page.svelte new file mode 100644 index 00000000..c35108fb --- /dev/null +++ b/web/frontend/src/routes/clips/[name]/+page.svelte @@ -0,0 +1,539 @@ + + + + {clipName} — CorridorKey + + +
+ + + {#if loading} +
Loading...
+ {:else if error} +
{error}
+ {:else if clip} +
+
+ + +
+
+ FRAMES + {clip.frame_count} +
+ {#if clip.completed_frames > 0} +
+ COMPLETED + {clip.completed_frames} +
+ {/if} + {#if clip.input_asset} +
+ INPUT TYPE + {clip.input_asset.asset_type} +
+ {/if} + {#if clip.alpha_asset} +
+ ALPHA FRAMES + {clip.alpha_asset.frame_count} +
+ {/if} + {#if clip.error_message} +
+ ERROR + {clip.error_message} +
+ {/if} +
+
+ +
+ + +
+ {#if canRunPipeline} + +
OR RUN INDIVIDUAL STEPS
+ {/if} + + {#if canExtract} + + {/if} + {#if canRunInference} + + {/if} + {#if canRunGVM} + + {/if} + {#if canRunVideoMaMa} + + {/if} + {#if canRunGVM || canRunVideoMaMa || canExtract} + + + {/if} + {#if !canRunInference && !canRunGVM && !canRunVideoMaMa && !canExtract && !canRunPipeline} +

Clip is complete.

+ {/if} + +
+
+
+ {/if} +
+ + diff --git a/web/frontend/src/routes/jobs/+page.svelte b/web/frontend/src/routes/jobs/+page.svelte new file mode 100644 index 00000000..179deb0f --- /dev/null +++ b/web/frontend/src/routes/jobs/+page.svelte @@ -0,0 +1,215 @@ + + + + Jobs — CorridorKey + + +
+ + + + {#if $runningJobs.length > 0} +
+

RUNNING {$runningJobs.length}

+
+ {#each $runningJobs as job (job.id)} + + {/each} +
+
+ {/if} + + + {#if $queuedJobs.length > 0} +
+

QUEUED {$queuedJobs.length}

+
+ {#each $queuedJobs as job (job.id)} + + {/each} +
+
+ {/if} + + + {#if $jobHistory.length > 0} +
+

HISTORY

+
+ {#each $jobHistory as job (job.id)} + + {/each} +
+
+ {/if} + + {#if $runningJobs.length === 0 && $queuedJobs.length === 0 && $jobHistory.length === 0} +
+ + + +

No jobs

+

Submit a job from a clip's detail page.

+
+ {/if} +
+ + diff --git a/web/frontend/src/routes/settings/+page.svelte b/web/frontend/src/routes/settings/+page.svelte new file mode 100644 index 00000000..bc89793a --- /dev/null +++ b/web/frontend/src/routes/settings/+page.svelte @@ -0,0 +1,494 @@ + + + + Settings — CorridorKey + + +
+ + +
+
+

MODEL WEIGHTS

+

Download model weights required for inference and alpha generation.

+ + {#if weightsLoading} +

Checking weights...

+ {:else} +
+ {#each Object.entries(weights) as [key, w]} + {@const label = weightLabels[key]} +
+
+
+ + {label?.name ?? key} +
+ {label?.desc ?? ''} + {#if w.installed && w.detail} + {w.detail} + {/if} + {#if w.download?.status === 'failed' && w.download.error} + {w.download.error} + {/if} +
+
+ {#if w.installed} + INSTALLED + {:else if w.download?.status === 'downloading'} + DOWNLOADING... + {:else} + + {/if} +
+
+ {/each} +
+ {/if} +
+ +
+

UPLOAD BEHAVIOR

+ +
+ +
+

DEFAULT PARAMETERS

+

Pre-fill values for the inference form.

+ +
+ +
+

GPU MANAGEMENT

+ +
+
+ Min Free VRAM for Parallel Jobs + GPU jobs won't start if free VRAM is below this. Set to 0 to disable (single job at a time). +
+
+ + {vramLimit === 0 ? 'OFF' : `${vramLimit} GB`} +
+
+ + +
+
+
+ + diff --git a/web/frontend/static/Corridor_Digital_Logo.svg b/web/frontend/static/Corridor_Digital_Logo.svg new file mode 100644 index 00000000..a21556b1 --- /dev/null +++ b/web/frontend/static/Corridor_Digital_Logo.svg @@ -0,0 +1,527 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 +AbWLNLXdcvvvVS1+P/1VpZ5fFxML + + + \ No newline at end of file diff --git a/web/frontend/static/robots.txt b/web/frontend/static/robots.txt new file mode 100644 index 00000000..b6dd6670 --- /dev/null +++ b/web/frontend/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/web/frontend/svelte.config.js b/web/frontend/svelte.config.js new file mode 100644 index 00000000..8f27db49 --- /dev/null +++ b/web/frontend/svelte.config.js @@ -0,0 +1,18 @@ +import adapter from '@sveltejs/adapter-static'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + adapter: adapter({ + pages: 'build', + assets: 'build', + fallback: 'index.html' + }) + }, + vitePlugin: { + dynamicCompileOptions: ({ filename }) => + filename.includes('node_modules') ? undefined : { runes: true } + } +}; + +export default config; diff --git a/web/frontend/tsconfig.json b/web/frontend/tsconfig.json new file mode 100644 index 00000000..2c2ed3c4 --- /dev/null +++ b/web/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/web/frontend/vite.config.ts b/web/frontend/vite.config.ts new file mode 100644 index 00000000..1608bbab --- /dev/null +++ b/web/frontend/vite.config.ts @@ -0,0 +1,15 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()], + server: { + proxy: { + '/api': 'http://localhost:3000', + '/ws': { + target: 'ws://localhost:3000', + ws: true + } + } + } +});