From f59e26efeb09e33d48f1c0172800d8238d2fa913 Mon Sep 17 00:00:00 2001 From: James Nye Date: Fri, 13 Mar 2026 23:30:51 -0700 Subject: [PATCH 1/9] feat: add browser-based WebUI for CorridorKey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-stack web interface served via FastAPI + SvelteKit, accessible at localhost:3000 via `docker compose --profile web up -d --build`. Backend (web/api/): - FastAPI app with lifespan management, SPA fallback routing - Clip scanning, detail, deletion, and move-between-projects endpoints - Job submission with full pipeline chaining (extract → alpha → inference) - Parallel worker pool: CPU jobs (extraction) run alongside GPU jobs, configurable VRAM limit for GPU job concurrency - Video upload with auto frame extraction, zip frame/alpha/mask upload - Preview: single-frame PNG (EXR converted on-the-fly), ffmpeg-stitched MP4 video with caching and encode-lock, ZIP download per pass - Project CRUD (create, rename, delete, list with nested clips) - System endpoints: device detection, system-wide VRAM via nvidia-smi, model weight download from HuggingFace, VRAM limit control - WebSocket for real-time job progress, status, VRAM updates Frontend (web/frontend/): - SvelteKit SPA with Svelte 5 runes, adapter-static build - Corridor Digital branding with cinematic dark theme - Project-grouped clip browser with collapsible sections - Drag-and-drop upload, clip moving, right-click context menus - Frame viewer with playback, A/B comparison, per-pass download - Job queue with real-time progress, ETA, expandable error logs - Settings: weight management, auto-extract, VRAM limit, inference defaults - Toast notifications, keyboard shortcuts (?), global activity bar --- .dockerignore | 5 + .gitignore | 8 + README.md | 47 +- docker-compose.yml | 20 + pyproject.toml | 5 + uv.lock | 406 ++++- web/Dockerfile.web | 48 + web/__init__.py | 0 web/api/__init__.py | 0 web/api/app.py | 106 ++ web/api/deps.py | 23 + web/api/routes/__init__.py | 0 web/api/routes/clips.py | 194 +++ web/api/routes/jobs.py | 206 +++ web/api/routes/preview.py | 317 ++++ web/api/routes/projects.py | 159 ++ web/api/routes/system.py | 259 +++ web/api/routes/upload.py | 305 ++++ web/api/schemas.py | 123 ++ web/api/worker.py | 306 ++++ web/api/ws.py | 106 ++ web/frontend/.gitignore | 23 + web/frontend/.npmrc | 1 + web/frontend/README.md | 42 + web/frontend/package-lock.json | 1541 +++++++++++++++++ web/frontend/package.json | 26 + web/frontend/src/app.css | 146 ++ web/frontend/src/app.d.ts | 13 + web/frontend/src/app.html | 14 + web/frontend/src/components/ClipCard.svelte | 180 ++ .../src/components/ContextMenu.svelte | 134 ++ .../src/components/FrameViewer.svelte | 498 ++++++ .../src/components/InferenceForm.svelte | 308 ++++ web/frontend/src/components/JobRow.svelte | 219 +++ .../src/components/KeyboardHelp.svelte | 114 ++ .../src/components/ProgressBar.svelte | 130 ++ .../src/components/ToastContainer.svelte | 64 + web/frontend/src/components/VramMeter.svelte | 106 ++ web/frontend/src/lib/api.ts | 225 +++ web/frontend/src/lib/assets/favicon.svg | 1 + web/frontend/src/lib/index.ts | 1 + web/frontend/src/lib/stores/clips.ts | 22 + web/frontend/src/lib/stores/jobs.ts | 82 + web/frontend/src/lib/stores/settings.ts | 23 + web/frontend/src/lib/stores/system.ts | 27 + web/frontend/src/lib/stores/toasts.ts | 32 + web/frontend/src/lib/ws.ts | 81 + web/frontend/src/routes/+layout.svelte | 385 ++++ web/frontend/src/routes/+page.svelte | 5 + web/frontend/src/routes/clips/+page.svelte | 658 +++++++ .../src/routes/clips/[name]/+page.svelte | 527 ++++++ web/frontend/src/routes/jobs/+page.svelte | 213 +++ web/frontend/src/routes/settings/+page.svelte | 494 ++++++ web/frontend/static/Corridor_Digital_Logo.svg | 527 ++++++ web/frontend/static/robots.txt | 3 + web/frontend/svelte.config.js | 18 + web/frontend/tsconfig.json | 20 + web/frontend/vite.config.ts | 15 + 58 files changed, 9556 insertions(+), 5 deletions(-) create mode 100644 web/Dockerfile.web create mode 100644 web/__init__.py create mode 100644 web/api/__init__.py create mode 100644 web/api/app.py create mode 100644 web/api/deps.py create mode 100644 web/api/routes/__init__.py create mode 100644 web/api/routes/clips.py create mode 100644 web/api/routes/jobs.py create mode 100644 web/api/routes/preview.py create mode 100644 web/api/routes/projects.py create mode 100644 web/api/routes/system.py create mode 100644 web/api/routes/upload.py create mode 100644 web/api/schemas.py create mode 100644 web/api/worker.py create mode 100644 web/api/ws.py create mode 100644 web/frontend/.gitignore create mode 100644 web/frontend/.npmrc create mode 100644 web/frontend/README.md create mode 100644 web/frontend/package-lock.json create mode 100644 web/frontend/package.json create mode 100644 web/frontend/src/app.css create mode 100644 web/frontend/src/app.d.ts create mode 100644 web/frontend/src/app.html create mode 100644 web/frontend/src/components/ClipCard.svelte create mode 100644 web/frontend/src/components/ContextMenu.svelte create mode 100644 web/frontend/src/components/FrameViewer.svelte create mode 100644 web/frontend/src/components/InferenceForm.svelte create mode 100644 web/frontend/src/components/JobRow.svelte create mode 100644 web/frontend/src/components/KeyboardHelp.svelte create mode 100644 web/frontend/src/components/ProgressBar.svelte create mode 100644 web/frontend/src/components/ToastContainer.svelte create mode 100644 web/frontend/src/components/VramMeter.svelte create mode 100644 web/frontend/src/lib/api.ts create mode 100644 web/frontend/src/lib/assets/favicon.svg create mode 100644 web/frontend/src/lib/index.ts create mode 100644 web/frontend/src/lib/stores/clips.ts create mode 100644 web/frontend/src/lib/stores/jobs.ts create mode 100644 web/frontend/src/lib/stores/settings.ts create mode 100644 web/frontend/src/lib/stores/system.ts create mode 100644 web/frontend/src/lib/stores/toasts.ts create mode 100644 web/frontend/src/lib/ws.ts create mode 100644 web/frontend/src/routes/+layout.svelte create mode 100644 web/frontend/src/routes/+page.svelte create mode 100644 web/frontend/src/routes/clips/+page.svelte create mode 100644 web/frontend/src/routes/clips/[name]/+page.svelte create mode 100644 web/frontend/src/routes/jobs/+page.svelte create mode 100644 web/frontend/src/routes/settings/+page.svelte create mode 100644 web/frontend/static/Corridor_Digital_Logo.svg create mode 100644 web/frontend/static/robots.txt create mode 100644 web/frontend/svelte.config.js create mode 100644 web/frontend/tsconfig.json create mode 100644 web/frontend/vite.config.ts 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..91586bec 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,52 @@ 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 + +**Docker notes:** +- Model weights are volume-mounted and persist across rebuilds +- Set `CK_CLIPS_DIR` environment variable to change the projects directory +- 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/docker-compose.yml b/docker-compose.yml index fd21cb34..bcd64c75 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,3 +35,23 @@ 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 + 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..93359e4e --- /dev/null +++ b/web/Dockerfile.web @@ -0,0 +1,48 @@ +# 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 + +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..b6c1d4b1 --- /dev/null +++ b/web/api/app.py @@ -0,0 +1,106 @@ +"""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.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, + ) + + # 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..9ab782b8 --- /dev/null +++ b/web/api/routes/jobs.py @@ -0,0 +1,206 @@ +"""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() + current = queue.current_job + return JobListResponse( + current=_job_to_schema(current) if current else None, + 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..397afc9f --- /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.clips import _clip_to_schema, _clips_dir + +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_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": [_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_dir) + new_clips = [c for c in clips if c.root_path.startswith(project_dir)] + + return { + "status": "ok", + "project_dir": project_dir, + "clips": [_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_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_dir) + updated = next((c for c in clips if c.name == clip_name), None) + + return { + "status": "ok", + "clip": _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_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_dir) + updated = next((c for c in clips if c.name == clip_name), None) + + return { + "status": "ok", + "clip": _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..ec39a2c7 --- /dev/null +++ b/web/api/schemas.py @@ -0,0 +1,123 @@ +"""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 + 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..8388f514 --- /dev/null +++ b/web/api/worker.py @@ -0,0 +1,306 @@ +"""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.""" + if _vram_limit_gb <= 0: + return True # no limit set + free = _get_free_vram_gb() + if free is None: + return True # 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 worker_loop( + service: CorridorKeyService, + queue: GPUJobQueue, + clips_dir: str, + stop_event: threading.Event, + max_gpu_workers: int = 2, + 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 check VRAM availability before starting. Multiple GPU jobs can run + simultaneously if VRAM limit allows. + """ + global _running_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..3fb9cb0c --- /dev/null +++ b/web/frontend/src/lib/api.ts @@ -0,0 +1,225 @@ +/** 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; + 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..3aafc965 --- /dev/null +++ b/web/frontend/src/lib/stores/jobs.ts @@ -0,0 +1,82 @@ +import { writable, derived, get } from 'svelte/store'; +import type { Job } from '$lib/api'; +import { api } from '$lib/api'; + +export const currentJob = writable(null); +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( + [currentJob, queuedJobs], + ([$current, $queued]) => ($current ? 1 : 0) + $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); + 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; + + 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..1ec4c3f3 --- /dev/null +++ b/web/frontend/src/lib/stores/settings.ts @@ -0,0 +1,23 @@ +import { writable } from 'svelte/store'; +import type { InferenceParams, OutputConfig } from '$lib/api'; + +export const autoExtractFrames = writable(true); + +export const defaultParams = writable({ + input_is_linear: false, + despill_strength: 1.0, + auto_despeckle: true, + despeckle_size: 400, + refiner_scale: 1.0 +}); + +export const defaultOutputConfig = writable({ + 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..fe18f08e --- /dev/null +++ b/web/frontend/src/routes/+layout.svelte @@ -0,0 +1,385 @@ + + +
+ + +
+ {#if $currentJob} +
+
+ {$currentJob.job_type.replace('_', ' ')} + {$currentJob.clip_name} + {#if $currentJob.total_frames > 0} + {Math.round(($currentJob.current_frame / $currentJob.total_frames) * 100)}% + {/if} +
+
+
+
+
+ {/if} + {@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..48e8fb69 --- /dev/null +++ b/web/frontend/src/routes/clips/+page.svelte @@ -0,0 +1,658 @@ + + + + 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..d11a5baa --- /dev/null +++ b/web/frontend/src/routes/clips/[name]/+page.svelte @@ -0,0 +1,527 @@ + + + + {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..b5012649 --- /dev/null +++ b/web/frontend/src/routes/jobs/+page.svelte @@ -0,0 +1,213 @@ + + + + Jobs — CorridorKey + + +
+ + + + {#if $currentJob} +
+

RUNNING

+
+ +
+
+ {/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 !$currentJob && $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 + } + } + } +}); From aa94a5393b9a880e357549fe44f9793ce3974dea Mon Sep 17 00:00:00 2001 From: James Nye Date: Sat, 14 Mar 2026 15:35:07 -0700 Subject: [PATCH 2/9] =?UTF-8?q?docs:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20clip=20storage,=20Mac/MLX=20notes,=20VRAM=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: document that WebUI uses Projects/ while CLI uses ClipsForInference/, and how to unify via CK_CLIPS_DIR - README: note that Mac/MLX WebUI support is not yet validated - worker.py: clarify that VRAM concurrency gate is CUDA-only, MLX unified memory checking not yet implemented --- README.md | 9 +- web/api/worker.py | 618 +++++++++++++++++++++++----------------------- 2 files changed, 317 insertions(+), 310 deletions(-) diff --git a/README.md b/README.md index 91586bec..520a0a74 100644 --- a/README.md +++ b/README.md @@ -138,10 +138,11 @@ uv run uvicorn web.api.app:create_app --factory --port 3000 - **Right-click context menus** — rename projects, move clips, batch process, delete - **Keyboard shortcuts** — press `?` to see all shortcuts -**Docker notes:** -- Model weights are volume-mounted and persist across rebuilds -- Set `CK_CLIPS_DIR` environment variable to change the projects directory -- The web service uses the `web` Docker Compose profile +**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) diff --git a/web/api/worker.py b/web/api/worker.py index 8388f514..55dbb07a 100644 --- a/web/api/worker.py +++ b/web/api/worker.py @@ -1,306 +1,312 @@ -"""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.""" - if _vram_limit_gb <= 0: - return True # no limit set - free = _get_free_vram_gb() - if free is None: - return True # 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 worker_loop( - service: CorridorKeyService, - queue: GPUJobQueue, - clips_dir: str, - stop_event: threading.Event, - max_gpu_workers: int = 2, - 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 check VRAM availability before starting. Multiple GPU jobs can run - simultaneously if VRAM limit allows. - """ - global _running_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 +"""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 worker_loop( + service: CorridorKeyService, + queue: GPUJobQueue, + clips_dir: str, + stop_event: threading.Event, + max_gpu_workers: int = 2, + 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 check VRAM availability before starting. Multiple GPU jobs can run + simultaneously if VRAM limit allows. + """ + global _running_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 From f85a1d01e845c3474ef4d4e02fe70846a9bf1d9d Mon Sep 17 00:00:00 2001 From: James Nye Date: Sun, 15 Mar 2026 19:01:43 -0700 Subject: [PATCH 3/9] fix: limit GPU workers to 1 per physical GPU to prevent model thrashing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit max_gpu_workers defaulted to 2, allowing two GPU jobs (e.g. inference + GVM) to run simultaneously on a single GPU. Since CorridorKey needs ~23GB VRAM and models can't coexist, the second job would force-unload the first mid-processing. Now auto-detects GPU count and sets max_gpu_workers = 1 per GPU. On a single GPU system, GPU jobs run strictly sequentially — the second job queues until the first completes. --- web/api/worker.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/web/api/worker.py b/web/api/worker.py index 55dbb07a..7460415f 100644 --- a/web/api/worker.py +++ b/web/api/worker.py @@ -234,22 +234,37 @@ def _run_job(service: CorridorKeyService, job: GPUJob, queue: GPUJobQueue, clips _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 = 2, + 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 check VRAM availability before starting. Multiple GPU jobs can run - simultaneously if VRAM limit allows. + 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") From 0a5d9168f690df36f2616e68956e3e1496169073 Mon Sep 17 00:00:00 2001 From: James Nye Date: Sun, 15 Mar 2026 19:49:15 -0700 Subject: [PATCH 4/9] fix: support multiple concurrent running jobs in queue and UI The job queue tracked only a single _current_job, so when a second job was claimed (by a remote node or parallel worker), the first disappeared from the UI. Now: - GPUJobQueue uses _running_jobs list instead of _current_job singleton - API returns running: list[Job] alongside current (backward compat) - Frontend runningJobs store tracks all running jobs - Jobs page "RUNNING" section shows all active jobs with count badge - Activity bar shows a progress bar for each running job - Deduplication checks all running jobs, not just one - cancel_all cancels all running jobs - find_job_by_id searches all running jobs --- backend/job_queue.py | 103 ++++++++++++---------- web/api/routes/jobs.py | 5 +- web/api/schemas.py | 3 +- web/frontend/src/lib/api.ts | 1 + web/frontend/src/lib/stores/jobs.ts | 20 ++++- web/frontend/src/routes/+layout.svelte | 16 ++-- web/frontend/src/routes/jobs/+page.svelte | 16 ++-- 7 files changed, 96 insertions(+), 68 deletions(-) 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/web/api/routes/jobs.py b/web/api/routes/jobs.py index 9ab782b8..a85adb09 100644 --- a/web/api/routes/jobs.py +++ b/web/api/routes/jobs.py @@ -35,9 +35,10 @@ def _job_to_schema(job: GPUJob) -> JobSchema: @router.get("", response_model=JobListResponse) def list_jobs(): queue = get_queue() - current = queue.current_job + running = queue.running_jobs return JobListResponse( - current=_job_to_schema(current) if current else None, + 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], ) diff --git a/web/api/schemas.py b/web/api/schemas.py index ec39a2c7..dc25f383 100644 --- a/web/api/schemas.py +++ b/web/api/schemas.py @@ -94,7 +94,8 @@ class JobSchema(BaseModel): class JobListResponse(BaseModel): - current: JobSchema | None = None + current: JobSchema | None = None # first running job (backward compat) + running: list[JobSchema] = [] # all running jobs queued: list[JobSchema] = [] history: list[JobSchema] = [] diff --git a/web/frontend/src/lib/api.ts b/web/frontend/src/lib/api.ts index 3fb9cb0c..1429baa9 100644 --- a/web/frontend/src/lib/api.ts +++ b/web/frontend/src/lib/api.ts @@ -57,6 +57,7 @@ export interface Job { export interface JobListResponse { current: Job | null; + running: Job[]; queued: Job[]; history: Job[]; } diff --git a/web/frontend/src/lib/stores/jobs.ts b/web/frontend/src/lib/stores/jobs.ts index 3aafc965..dde48b04 100644 --- a/web/frontend/src/lib/stores/jobs.ts +++ b/web/frontend/src/lib/stores/jobs.ts @@ -2,7 +2,10 @@ 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([]); @@ -10,8 +13,8 @@ export const jobHistory = writable([]); export const jobStartedAt = writable(null); export const activeJobCount = derived( - [currentJob, queuedJobs], - ([$current, $queued]) => ($current ? 1 : 0) + $queued.length + [runningJobs, queuedJobs], + ([$running, $queued]) => $running.length + $queued.length ); let refreshPending = false; @@ -23,6 +26,7 @@ export async function refreshJobs() { 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); @@ -46,6 +50,18 @@ export async function refreshJobs() { 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; diff --git a/web/frontend/src/routes/+layout.svelte b/web/frontend/src/routes/+layout.svelte index fe18f08e..bf1b2b13 100644 --- a/web/frontend/src/routes/+layout.svelte +++ b/web/frontend/src/routes/+layout.svelte @@ -4,7 +4,7 @@ import { onMount } from 'svelte'; import { connect, disconnect, onMessage, isConnected } from '$lib/ws'; import { refreshClips } from '$lib/stores/clips'; - import { refreshJobs, updateJobFromWS, currentJob, activeJobCount } from '$lib/stores/jobs'; + import { refreshJobs, updateJobFromWS, currentJob, runningJobs, activeJobCount } from '$lib/stores/jobs'; import { refreshDevice, refreshVRAM, device, vram, wsConnected } from '$lib/stores/system'; import VramMeter from '../components/VramMeter.svelte'; import ToastContainer from '../components/ToastContainer.svelte'; @@ -121,23 +121,23 @@
- {#if $currentJob} + {#each $runningJobs as rJob (rJob.id)}
- {$currentJob.job_type.replace('_', ' ')} - {$currentJob.clip_name} - {#if $currentJob.total_frames > 0} - {Math.round(($currentJob.current_frame / $currentJob.total_frames) * 100)}% + {rJob.job_type.replace('_', ' ')} + {rJob.clip_name} + {#if rJob.total_frames > 0} + {Math.round((rJob.current_frame / rJob.total_frames) * 100)}% {/if}
- {/if} + {/each} {@render children()}
diff --git a/web/frontend/src/routes/jobs/+page.svelte b/web/frontend/src/routes/jobs/+page.svelte index b5012649..179deb0f 100644 --- a/web/frontend/src/routes/jobs/+page.svelte +++ b/web/frontend/src/routes/jobs/+page.svelte @@ -1,5 +1,5 @@ @@ -40,12 +40,14 @@ - - {#if $currentJob} + + {#if $runningJobs.length > 0}
-

RUNNING

+

RUNNING {$runningJobs.length}

- + {#each $runningJobs as job (job.id)} + + {/each}
{/if} @@ -74,7 +76,7 @@ {/if} - {#if !$currentJob && $queuedJobs.length === 0 && $jobHistory.length === 0} + {#if $runningJobs.length === 0 && $queuedJobs.length === 0 && $jobHistory.length === 0}
From 340d9e82a1e453ffbe621d295c66aa3710839759 Mon Sep 17 00:00:00 2001 From: James Nye Date: Mon, 16 Mar 2026 00:36:00 -0700 Subject: [PATCH 5/9] fix: persist all frontend settings to localStorage across page refreshes --- web/frontend/src/lib/stores/settings.ts | 38 +++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/web/frontend/src/lib/stores/settings.ts b/web/frontend/src/lib/stores/settings.ts index 1ec4c3f3..4472b3af 100644 --- a/web/frontend/src/lib/stores/settings.ts +++ b/web/frontend/src/lib/stores/settings.ts @@ -1,9 +1,41 @@ import { writable } from 'svelte/store'; import type { InferenceParams, OutputConfig } from '$lib/api'; -export const autoExtractFrames = writable(true); +/** + * 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 + } + } -export const defaultParams = writable({ + 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, @@ -11,7 +43,7 @@ export const defaultParams = writable({ refiner_scale: 1.0 }); -export const defaultOutputConfig = writable({ +export const defaultOutputConfig = persisted('ck:defaultOutputConfig', { fg_enabled: true, fg_format: 'exr', matte_enabled: true, From 42a195858f0ebd094b611de281cc448a339fbd0d Mon Sep 17 00:00:00 2001 From: James Nye Date: Mon, 16 Mar 2026 11:11:29 -0700 Subject: [PATCH 6/9] fix: Docker permissions error on Projects directory bind mount - Dockerfile.web: create /app/Projects owned by appuser before switching to non-root user - docker-compose.yml: add user: "${UID:-1000}:${GID:-1000}" to web service so bind-mounted volumes match host permissions --- docker-compose.yml | 1 + web/Dockerfile.web | 3 +++ 2 files changed, 4 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index bcd64c75..2f9a4358 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,7 @@ services: context: . dockerfile: web/Dockerfile.web image: corridorkey-web:latest + user: "${UID:-1000}:${GID:-1000}" gpus: ${CK_GPUS:-all} ports: - "3000:3000" diff --git a/web/Dockerfile.web b/web/Dockerfile.web index 93359e4e..895d564c 100644 --- a/web/Dockerfile.web +++ b/web/Dockerfile.web @@ -38,6 +38,9 @@ RUN --mount=type=cache,target=/root/.cache/uv \ # 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 From bf1ed525add8f0826bc1e7f64bf100a825263e30 Mon Sep 17 00:00:00 2001 From: James Nye Date: Mon, 16 Mar 2026 19:27:10 -0700 Subject: [PATCH 7/9] fix: auto-extract broken due to import-time _clips_dir binding in upload route --- web/api/routes/upload.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/web/api/routes/upload.py b/web/api/routes/upload.py index 397afc9f..63c12112 100644 --- a/web/api/routes/upload.py +++ b/web/api/routes/upload.py @@ -19,7 +19,7 @@ ) from ..deps import get_queue, get_service -from ..routes.clips import _clip_to_schema, _clips_dir +from ..routes import clips as _clips_mod logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/upload", tags=["upload"]) @@ -63,7 +63,7 @@ async def upload_video(file: UploadFile, name: str | None = None, auto_extract: # Scan the new clips service = get_service() - clips = service.scan_clips(_clips_dir) + 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 @@ -86,7 +86,7 @@ async def upload_video(file: UploadFile, name: str | None = None, auto_extract: return { "status": "ok", "project_dir": project_dir, - "clips": [_clip_to_schema(c) for c in new_clips], + "clips": [_clips_mod._clip_to_schema(c) for c in new_clips], "extract_jobs": extract_jobs, } @@ -168,13 +168,13 @@ async def upload_frames(file: UploadFile, name: str | None = None): ) service = get_service() - clips = service.scan_clips(_clips_dir) + 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": [_clip_to_schema(c) for c in new_clips], + "clips": [_clips_mod._clip_to_schema(c) for c in new_clips], "frame_count": len(image_files), } @@ -193,7 +193,7 @@ async def upload_alpha_hint(clip_name: str, file: UploadFile): raise HTTPException(status_code=400, detail="Expected a .zip file containing alpha hint frames") service = get_service() - clips = service.scan_clips(_clips_dir) + 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") @@ -232,12 +232,12 @@ async def upload_alpha_hint(clip_name: str, file: UploadFile): dst = os.path.join(alpha_dir, fname) shutil.copy2(src, dst) - clips = service.scan_clips(_clips_dir) + 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": _clip_to_schema(updated) if updated else None, + "clip": _clips_mod._clip_to_schema(updated) if updated else None, "alpha_frames": len(image_files), } @@ -256,7 +256,7 @@ async def upload_videomama_mask(clip_name: str, file: UploadFile): raise HTTPException(status_code=400, detail="Expected a .zip file containing mask frames") service = get_service() - clips = service.scan_clips(_clips_dir) + 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") @@ -295,11 +295,11 @@ async def upload_videomama_mask(clip_name: str, file: UploadFile): dst = os.path.join(mask_dir, fname) shutil.copy2(src, dst) - clips = service.scan_clips(_clips_dir) + 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": _clip_to_schema(updated) if updated else None, + "clip": _clips_mod._clip_to_schema(updated) if updated else None, "mask_frames": len(image_files), } From ab106a127b67fd8a7b6d542e6589b0d50fda68bd Mon Sep 17 00:00:00 2001 From: James Nye Date: Tue, 17 Mar 2026 16:39:49 -0700 Subject: [PATCH 8/9] feat: auto-refresh clip detail on job completion, batch delete clips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-refresh: - Clip detail page subscribes to the clips store and re-fetches when the clip's state changes (e.g. job completes via WebSocket) - No more navigating away and back to see new outputs Batch delete: - Right-click a project → "Delete All Clips" removes all clips in the project with confirmation - Clip thumbnails already working (first frame from comp or input) --- web/frontend/src/routes/clips/+page.svelte | 12 ++++++++++++ web/frontend/src/routes/clips/[name]/+page.svelte | 14 +++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/web/frontend/src/routes/clips/+page.svelte b/web/frontend/src/routes/clips/+page.svelte index 48e8fb69..c6d20f99 100644 --- a/web/frontend/src/routes/clips/+page.svelte +++ b/web/frontend/src/routes/clips/+page.svelte @@ -58,6 +58,18 @@ }, }, { label: '---', action: () => {} }, + { + label: `Delete All Clips (${project.clip_count})`, + danger: true, + disabled: project.clip_count === 0, + action: async () => { + if (!confirm(`Delete all ${project.clip_count} clips in "${project.display_name}"? This cannot be undone.`)) return; + for (const name of clipNames) { + try { await api.clips.delete(name); } catch { /* continue */ } + } + await loadProjects(); + }, + }, { label: 'Delete Project', danger: true, diff --git a/web/frontend/src/routes/clips/[name]/+page.svelte b/web/frontend/src/routes/clips/[name]/+page.svelte index d11a5baa..c35108fb 100644 --- a/web/frontend/src/routes/clips/[name]/+page.svelte +++ b/web/frontend/src/routes/clips/[name]/+page.svelte @@ -6,7 +6,7 @@ import type { Clip, InferenceParams, OutputConfig } from '$lib/api'; import { defaultParams, defaultOutputConfig } from '$lib/stores/settings'; import { refreshJobs } from '$lib/stores/jobs'; - import { refreshClips } from '$lib/stores/clips'; + import { refreshClips, clips } from '$lib/stores/clips'; import { toast } from '$lib/stores/toasts'; import FrameViewer from '../../../components/FrameViewer.svelte'; import InferenceForm from '../../../components/InferenceForm.svelte'; @@ -168,6 +168,18 @@ } onMount(loadClip); + + // Auto-refresh when the clips store updates (e.g. job completes via WebSocket) + $effect(() => { + // Subscribe to clips store — when it changes, re-fetch this clip's data + const allClips = $clips; + if (allClips.length > 0 && !loading) { + const updated = allClips.find((c) => c.name === clipName); + if (updated && clip && updated.state !== clip.state) { + loadClip(); + } + } + }); From af5ef988b8370acd52423c45d9e5d78cf177871d Mon Sep 17 00:00:00 2001 From: James Nye Date: Tue, 17 Mar 2026 16:52:05 -0700 Subject: [PATCH 9/9] feat: gzip compression for file transfers, browser push notifications GZip middleware compresses all HTTP responses > 1KB. Significantly reduces transfer time for PNG frame sequences to remote nodes. Browser push notifications fire when jobs complete or fail, but only when the tab is in the background (document.hidden). Permission requested on first visit. --- web/api/app.py | 4 ++++ web/frontend/src/routes/+layout.svelte | 23 ++++++++++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/web/api/app.py b/web/api/app.py index b6c1d4b1..1d935018 100644 --- a/web/api/app.py +++ b/web/api/app.py @@ -8,6 +8,7 @@ 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 @@ -70,6 +71,9 @@ def create_app() -> FastAPI: 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) diff --git a/web/frontend/src/routes/+layout.svelte b/web/frontend/src/routes/+layout.svelte index bf1b2b13..9f594a5a 100644 --- a/web/frontend/src/routes/+layout.svelte +++ b/web/frontend/src/routes/+layout.svelte @@ -25,7 +25,18 @@ return page.url.pathname === href || page.url.pathname.startsWith(href + '/'); } + function sendBrowserNotification(title: string, body: string) { + if (typeof Notification !== 'undefined' && Notification.permission === 'granted' && document.hidden) { + new Notification(title, { body, icon: '/Corridor_Digital_Logo.svg' }); + } + } + onMount(() => { + // Request notification permission + if (typeof Notification !== 'undefined' && Notification.permission === 'default') { + Notification.requestPermission(); + } + connect(); refreshDevice(); refreshVRAM(); @@ -45,9 +56,15 @@ } else if (msg.type === 'job:status') { const d = msg.data as { job_id: string; status: string; error?: string }; updateJobFromWS(d.job_id, { status: d.status, error_message: d.error ?? null }); - if (d.status === 'completed') toast.success(`Job completed: ${d.job_id}`); - else if (d.status === 'failed') toast.error(`Job failed: ${d.error ?? d.job_id}`); - else if (d.status === 'cancelled') toast.warning(`Job cancelled: ${d.job_id}`); + if (d.status === 'completed') { + toast.success(`Job completed: ${d.job_id}`); + sendBrowserNotification('Job Completed', `Job ${d.job_id} finished successfully`); + } else if (d.status === 'failed') { + toast.error(`Job failed: ${d.error ?? d.job_id}`); + sendBrowserNotification('Job Failed', d.error ?? `Job ${d.job_id} failed`); + } else if (d.status === 'cancelled') { + toast.warning(`Job cancelled: ${d.job_id}`); + } refreshJobs(); refreshClips(); } else if (msg.type === 'job:warning') {