diff --git a/.env.example b/.env.example index fe4b573..4c798c6 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,10 @@ ANTHROPIC_API_KEY=your_anthropic_key_here LLM_PROVIDER=deepseek DEEPSEEK_API_KEY= GROQ_API_KEY= + +# Any OpenAI-compatible endpoint (LM Studio, vLLM, llama.cpp server, Together, Azure OpenAI, ...) +# Set LLM_PROVIDER=openai-compatible and point OPENAI_BASE_URL at the API root. +# The app appends /chat/completions unless the URL already ends with it. +OPENAI_BASE_URL= +OPENAI_COMPATIBLE_MODEL= +OPENAI_COMPATIBLE_API_KEY= diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f9bca81..8af5d4a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,61 @@ name: Release App on: + pull_request: + branches: + ["main"] push: + branches: + ["main"] tags: - 'v*' # Triggers on release tags like v0.1.0 or v1.0.0 jobs: + test: + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # jsdom 30 pulls in undici 8, which needs Node >= 22.19 (it uses + # zlib.createZstdDecompress, absent on Node 20). + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev + + - name: Install node modules + run: npm ci + + # `tauri::generate_context!` validates `frontendDist: "../dist"`, so the + # frontend bundle has to exist before the Rust tests are compiled. + - name: Build frontend bundle + run: npm run build + + - name: Run frontend tests (Vitest) + run: npm test + + - name: Run Rust tests (unit + SQLite integration) + run: cargo test --manifest-path src-tauri/Cargo.toml --all-targets + release: + # Only real version tags cut a release. On a push to `main` `github.ref_name` + # is literally "main", which would create a bogus `main` tag/release. + if: startsWith(github.ref, 'refs/tags/v') + needs: test permissions: contents: write strategy: @@ -21,7 +70,8 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 + cache: npm - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -33,6 +83,14 @@ jobs: sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev + # `bun.lock` used to be committed, which made tauri-action pick bun and fail + # with "'bun' is not recognized". package.json now pins npm via packageManager. + - name: Assert npm is the package manager + shell: bash + run: | + test ! -f bun.lock && test ! -f bun.lockb + node -e "const pm=require('./package.json').packageManager||''; if(!pm.startsWith('npm@')) { console.error('packageManager must pin npm, got: '+pm); process.exit(1); }" + - name: Install node modules run: npm ci @@ -41,8 +99,18 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: + # Pinned explicitly: tauri-action picks the runner from lockfiles, and a + # committed `bun.lock` made it run `bun tauri build` on runners without bun. + tauriScript: npm run tauri tagName: ${{ github.ref_name }} releaseName: 'AutoShorts ${{ github.ref_name }}' - releaseBody: 'See release notes for installation details.' - releaseDraft: true + # `releaseDraft: true` left every build stuck as a draft that was never + # published. Publish directly and let GitHub generate the notes from + # the commit history. + releaseDraft: false prerelease: false + generateReleaseNotes: true + + + + \ No newline at end of file diff --git a/.gitignore b/.gitignore index b6c81a6..6ba6c19 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ src-tauri/target .env .DS_Store *.log +artifacts +# Project builds with npm; a stray bun lockfile makes tauri-action pick bun. +bun.lock +bun.lockb diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6a742d9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:poetry", + "python-envs.defaultPackageManager": "ms-python.python:poetry" +} \ No newline at end of file diff --git a/README.md b/README.md index 2d5b085..788eb98 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ When you first launch the application, you will be greeted by an **Onboarding Wi #### Option A: Fully Offline (Ollama + Whisper) 1. **Ollama Setup**: Select a local model card (`llama3.2 3B`, `qwen2.5 3B`, or `qwen2.5 7B`). The application will check if Ollama is running and automatically pull the model weights, showing a downloader progress bar. -2. **Local Whisper**: Follow the prompt instructions to verify Python is installed and run `pip3 install openai-whisper` to enable fully offline transcription. +2. **Local Whisper**: Click **Instalar Whisper**. The app creates a virtualenv under its data directory (`python-venv`) and installs `openai-whisper` into it, so nothing is added to your system Python. The first install downloads PyTorch (~1–2 GB). Only a Python 3 interpreter is required — the Linux packages declare `python3`/`python3-venv` as dependencies. #### Option B: Cloud API Keys 1. Enter your API credentials for: @@ -97,6 +97,14 @@ When you first launch the application, you will be greeted by an **Onboarding Wi * **Claude**: For premium copywriting, hooks, and moment detection. 2. Click **Save & Start** to immediately load the dashboard. +#### Option C: Any OpenAI-Compatible Endpoint +Select **OpenAI-Compatible (Custom Base URL)** in **API Settings** and provide: +* **Base URL**: the API root, e.g. `http://localhost:1234/v1` (LM Studio), `http://localhost:8000/v1` (vLLM), `http://localhost:8080/v1` (llama.cpp), or a hosted provider URL. +* **Model Name**: the model identifier that endpoint expects. +* **API Key**: optional; leave empty for local servers that do not require auth. + +The app calls `POST {Base URL}/chat/completions` and retries without `response_format` if the server rejects JSON mode. + --- ### ⚙️ Modifying Settings & Resetting Onboarding @@ -125,8 +133,14 @@ DEEPGRAM_API_KEY=your-deepgram-api-key DEEPSEEK_API_KEY=your-deepseek-api-key ANTHROPIC_API_KEY=your-anthropic-api-key -# Choose your default AI analysis provider ("deepseek" or "claude") +# Choose your default AI analysis provider +# ("deepseek", "claude", "gemini", "openai", "openrouter", "groq", "local" or "openai-compatible") LLM_PROVIDER=deepseek + +# Only for LLM_PROVIDER=openai-compatible +OPENAI_BASE_URL=http://localhost:1234/v1 +OPENAI_COMPATIBLE_MODEL=qwen2.5-7b-instruct +OPENAI_COMPATIBLE_API_KEY= ``` ### 2. Run in Development Mode @@ -143,6 +157,62 @@ npm run tauri:build ``` The output installers will be built under `src-tauri/target/release/bundle/`. +### 4. Run the Tests +Rust unit tests live next to the code they cover (`#[cfg(test)]` modules in +`src-tauri/src/*.rs`); the SQLite layer is covered end to end from +`src-tauri/tests/db_integration.rs` against a temporary database file: + +```bash +cargo test --manifest-path src-tauri/Cargo.toml +# or +npm run test:rust +``` + +Frontend tests use Vitest + Testing Library with a jsdom environment: + +```bash +npm test # single run +npm run test:watch # watch mode +``` + +Test layout: + +| Path | Kind | Covers | +| --- | --- | --- | +| `src-tauri/src/lib.rs` | unit | SRT/ASS caption generation, slug + extension validation | +| `src-tauri/src/media.rs` | unit | ffmpeg progress parsing, ASS filter escaping, path handling | +| `src-tauri/src/transcription.rs` | unit | Whisper/Deepgram normalization, segment building, download progress | +| `src-tauri/src/llm.rs` | unit | LLM response parsing, SSE streaming, URL normalization | +| `src-tauri/tests/db_integration.rs` | integration | projects, transcripts, candidates, clips, cascade deletes, migrations | +| `src/lib/format.test.ts` | unit | duration/size/file-name formatting helpers | +| `src/components/common.test.tsx` | component | `PipelineStep`, `EmptyState`, `StatusPill` | +| `src/App.integration.test.tsx` | integration | onboarding gate, sidebar rendering, project detail loading (Tauri IPC mocked) | + +### 5. Multi-Platform Build (Windows + Linux + macOS) +One command attempts every platform the current host can actually target: +```bash +npm run build:all +``` +Each platform is detected first; unsupported ones are skipped with the reason instead of aborting the run. Successful bundles are copied to `artifacts//`. + +```bash +npm run build:linux # .deb / .rpm / .AppImage +npm run build:windows # .msi / NSIS .exe +npm run build:macos # .dmg / .app (macOS only) + +npm run build:all -- --only=linux,macos # subset +npm run build:all -- --skip=windows # exclude one +npm run build:all -- --dry-run # print commands only +``` + +Host capability matrix: + +| Target | Requires | +| --- | --- | +| Linux | Linux host + `webkit2gtk-4.1`, `gtk+-3.0`, `libsoup-3.0` (pkg-config) | +| Windows | Windows host, **or** WSL with Windows `cargo.exe` + `node.exe` + MSVC Build Tools. From WSL the source is mirrored to `%USERPROFILE%\autoshorts-build` and built by the Windows toolchain. | +| macOS | macOS + Xcode only. Cannot be produced from Linux/WSL — use a Mac or the `Release App` GitHub workflow. | + ## ❤️ Support AutoShorts diff --git a/package-lock.json b/package-lock.json index 69cc5aa..9427a67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,11 +16,78 @@ }, "devDependencies": { "@tauri-apps/cli": "^2.5.0", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^30.1.3", "typescript": "^5.7.3", - "vite": "^6.1.0" + "vite": "^6.1.0", + "vitest": "^5.0.5" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.1.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.9.tgz", + "integrity": "sha512-vZ6JQVq8Ivgz6b/IBlAf90wZviXdluY13cQOPO+nJHbmXwGrPl2GcpqUY7a55/8ZVdfONXpCFMpRijOBCHNlcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.3", + "@csstools/css-color-parser": "^4.2.6", + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-10.0.5.tgz", + "integrity": "sha512-EgmJ7oEaGQECTn3H3wmnQpEy4hKY66VvM7VQndwPIQob3p877BgbyOUE3EX0LgXQCciF2H8KMdR+RznqjMNOLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" } }, "node_modules/@babel/code-frame": { @@ -257,6 +324,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -305,6 +382,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.2.1" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -747,6 +977,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -780,9 +1028,9 @@ } }, "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==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -1390,6 +1638,99 @@ "@tauri-apps/api": "^2.11.0" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1435,6 +1776,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1483,6 +1842,89 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/mocker": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.5", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.38", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", @@ -1496,6 +1938,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/browserslist": { "version": "4.28.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", @@ -1551,6 +2003,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1558,6 +2020,27 @@ "dev": true, "license": "MIT" }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1565,6 +2048,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", + "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.2" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1583,6 +2080,31 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/electron-to-chromium": { "version": "1.5.376", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz", @@ -1590,6 +2112,26 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1642,6 +2184,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1685,6 +2247,36 @@ "node": ">=6.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1692,6 +2284,56 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "30.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.3.tgz", + "integrity": "sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.1.3", + "@asamuzakjp/dom-selector": "^10.0.3", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.15", + "@exodus/bytes": "^1.16.0", + "css-tree": "^3.2.1", + "data-urls": "^8.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.12.1", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.2.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1737,6 +2379,44 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.4.tgz", + "integrity": "sha512-jQz9sVhrZuphH4NrAIHSgkDJCluQO7Ioi4KGRLa7aTUjAODQ13hENX0YIytqYE/rIkO485gQG2LeBxJaW53kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1773,6 +2453,33 @@ "node": ">=18" } }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1781,9 +2488,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -1822,6 +2529,32 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -1843,6 +2576,14 @@ "react": "^19.2.7" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -1853,6 +2594,30 @@ "node": ">=0.10.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -1898,6 +2663,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1924,6 +2702,46 @@ "node": ">=0.10.0" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1941,6 +2759,52 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "7.4.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.19.tgz", + "integrity": "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.19.tgz", + "integrity": "sha512-goNXV6Ho9TIOfpJZanTd6CKG5DyYgYAL+k+22T0SLh8l6+KHIfBUPFuePuSZv05UttIaaLmI9tb7TmGIdK5AkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz", + "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1955,6 +2819,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "8.12.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz", + "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2061,6 +2935,167 @@ } } }, + "node_modules/vitest": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.3.0.tgz", + "integrity": "sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.16.0", + "tr46": "^7.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index d23e205..559d96c 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,23 @@ "version": "0.1.3", "private": true, "type": "module", + "packageManager": "npm@10.9.8", + "engines": { + "node": ">=22.19.0" + }, "scripts": { "dev": "vite --host 127.0.0.1 --port 1420", "build": "tsc && vite build", "tauri": "tauri", "tauri:dev": "tauri dev", - "tauri:build": "tauri build" + "tauri:build": "tauri build", + "build:all": "node scripts/build-all.mjs", + "build:linux": "node scripts/build-all.mjs --only=linux", + "build:windows": "node scripts/build-all.mjs --only=windows", + "build:macos": "node scripts/build-all.mjs --only=macos", + "test": "node scripts/test.mjs", + "test:watch": "node scripts/test.mjs watch", + "test:rust": "cargo test --manifest-path src-tauri/Cargo.toml" }, "dependencies": { "@tauri-apps/api": "^2.5.0", @@ -19,10 +30,14 @@ }, "devDependencies": { "@tauri-apps/cli": "^2.5.0", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^30.1.3", "typescript": "^5.7.3", - "vite": "^6.1.0" + "vite": "^6.1.0", + "vitest": "^5.0.5" } } diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..20a4ce7 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1345 @@ +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. + +[[package]] +name = "certifi" +version = "2026.10.23" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1"}, + {file = "certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.2" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "charset_normalizer-3.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:195c26fb65950f8fce54e26349852b7bdd7c5f120aeefbcc440b8a20faaed4a3"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9373ad13ef0d2c0fb761e04e55bfdee5a08b52cef2c882c8fbe9935b1517152e"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ddf19c062bea7a0cc80f519243d2c01dd091be0cf952a0750d4ad576709559f5"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d14b50de6bf4d0edf857a9386836846f982b8f524e188e2e68b96d702bcf4aa"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:28a15fdad492a99b6eccfaaed66ef3f74050680545ea61ec8b2f4c538f1f1320"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a893cc101149f80a653f82062ebc95b34525a2614382e1da5458fe7c6997249"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:619799369eeef6366ed3e8755a5670f4f2f0fb6b30a0fd7264dc0fdc2357058e"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:447441e76ec720b15e64418d32e092297340387053047c7c694f579efb0ee1d9"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:62588a277bfb59def052abd940703fa35107152bf479781a878617d60faf8fb5"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:44bd4fbb29dfbeba60e7d2bd000c59e4b21ddb3cc53912b14048d37092706d7c"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:30fcd120b732aa79317f08dee04d7de0847822e4cf7ee0e9f445bb958832252c"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:50e3adfb96fc189eb27b1cf62d3b598b89b4bb0420d93a3d3e42e137409011be"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b736353c0a625bbd5fcec108576e2385db3496f4f771f785ff32e108d3c3bc45"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-win32.whl", hash = "sha256:f5833ad231be5eb6553de524a70f48d71b2c8563101750531e0b80184e175cd4"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:1461ac396c4fdb983a675f20aa555624f0ee18ac83d832b9244ffff3d8055275"}, + {file = "charset_normalizer-3.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:c6708715abcf3c73b99508253e961a9967f02fe536532834149574eda6de0d1c"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4685902cf26edf013ed7a3da0f426ebba7a00ebb9541386d835afbf002c11cab"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4495c5002a7b28557e7e222e77e0b661183e432b7d6d2e788101e3f240e05b8c"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:211d5a3eb6af8f513b8d4ca19a8c1b7accab1b5f0d3175f9826b03c1a920dc1f"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef4fcbf3327382cd4c9f540babd61248208af7b93eec4de397b4d5f58a09e288"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd16aabe4a02a297c23417aa17ac6299dbd8c49f673bcd645b4929b11f5a4400"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:fb9e68df06293761f9fe66ade60a9bc6d0f5e42b8acf2939a9158af86ab0e5bd"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:59f63901b0031c3136cf64704dcb21de0bbae62ce2c9529bc39d27665463de37"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:304d5463e65a35d7bb0850550e0780395395f6fcf452f04db7d5ca7cecc425ac"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9cf9b1a857e25c4baceeb3624e92a56df3668f398c4acba74e174d81fb4d1d3a"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:114e4d0c92d618409ed82a99e22b5c5e768fe995f2973f78265f4524f49d4640"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-win32.whl", hash = "sha256:2625388c6c754520c37abaf3b41eb34d1cc4a373f457898f08606c8e362b891d"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:87e50a3e7cb90af586b6c5faf23e302a970415ac73bd7bd90a515a04b427ef96"}, + {file = "charset_normalizer-3.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:254eb48b9fa5ee9898a3c445825a1f340fe53712a098904b39b0bddba8ea3cb1"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc"}, + {file = "charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639"}, + {file = "charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c"}, + {file = "charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc"}, + {file = "charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8"}, + {file = "charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21"}, + {file = "charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036"}, + {file = "charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:75a3ceed0724d625d64b86ca20aba182e4df462e04c2414fc941c0f523f06aac"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0891b9d3903c5571c03771ca669a4b0ec5618ca722a5c957d3d29cd4e5062848"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fc14a032f813bf5fe624d991960ea83e9715adc27e4c1830a2361eb1d02ac341"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b2bfab86aa71ae13aa41a6a26aab338e0db2b8bc75434b05aea89e011ff35a4"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9bde855991b7e362c146535e3136a50bfaffc0487d38b33ca7e5edefc6e23849"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55ea99acb17b9325618de155a0cd6a2e8f5d10be008113e1d433bbb58db543b2"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:68eb192d85ab8e5f6ec69c2bc6ac0179fbf04a5ac1569d12fbef74883fe102d0"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d913de495d90407cd859d263bee2e5d1a4ed3eb6573c04e70d9ec619a7cbed7f"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:3ddacd27458c45bdacd6bd6db644bfb730efbf9e830310186e3045c9c5be8fb2"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:588461c2e8384d309bd63e5826019b6977bc66d629b99ac8737bb795d7b2cb5a"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:e80e6c2f55656b4824d72065abb4ddd6a525c74bd78a0aab5d9fc2cf4fb5af50"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:d4a7319f304a774bed22115bc891618e45f85065ab44ea6acd07d274e750519a"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:fd1fbe0f116b6e55da77aca2c6ddcddcfac2186cbf78bdebf40fc156efca389d"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-win32.whl", hash = "sha256:93223adc95033dd47133a46ccfc316a0139176fd79085762e27202ec56018f03"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-win_amd64.whl", hash = "sha256:15bb4005af6320d259dc7593ca84a38d7fe06a421dbcf7b910ae23979101e787"}, + {file = "charset_normalizer-3.5.2-cp39-cp39-win_arm64.whl", hash = "sha256:2cc961b171b3f3440f410489ab3573e86aea8736134ebbb40ea1338b7f0831bc"}, + {file = "charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685"}, + {file = "charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main"] +markers = "platform_system == \"Windows\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "filelock" +version = "4.2.4" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.11" +groups = ["main"] +files = [ + {file = "filelock-4.2.4-py3-none-any.whl", hash = "sha256:301241438c57936ef93f4243a94f2559022268bfcf9b375a9a72eb0f8db8358e"}, + {file = "filelock-4.2.4.tar.gz", hash = "sha256:c6d8663535e1650ee430507b4ffbac7b464dd64b7647ea0112a9f406499d163d"}, +] + +[[package]] +name = "fsspec" +version = "2026.9.0" +description = "File-system specification" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "fsspec-2026.9.0-py3-none-any.whl", hash = "sha256:8dd6e646e99ea382bd85f97a45e6b526a442d79423a7dc673f1e2756d05fcb5f"}, + {file = "fsspec-2026.9.0.tar.gz", hash = "sha256:0f08147951c8cb31d844c3547d631053b127863b60be04cf06e121333ee0e2fe"}, +] + +[package.extras] +abfs = ["adlfs"] +adl = ["adlfs"] +arrow = ["pyarrow (>=1)"] +dask = ["dask", "distributed"] +dev = ["pre-commit", "ruff (>=0.5)"] +doc = ["numpydoc", "sphinx", "sphinx-design", "sphinx-rtd-theme", "yarl"] +dropbox = ["dropbox", "dropboxdrivefs", "requests"] +full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "dask", "distributed", "dropbox", "dropboxdrivefs", "fusepy", "gcsfs (>=2026.4.0)", "libarchive-c", "ocifs", "panel", "paramiko", "pyarrow (>=1)", "pygit2", "requests", "s3fs (>=2026.6.0)", "smbprotocol", "tqdm"] +fuse = ["fusepy"] +gcs = ["gcsfs (>=2026.4.0)"] +git = ["pygit2"] +github = ["requests"] +gs = ["gcsfs (>=2026.4.0)"] +gui = ["panel"] +hdfs = ["pyarrow (>=1)"] +http = ["aiohttp (!=4.0.0a0,!=4.0.0a1)"] +libarchive = ["libarchive-c"] +oci = ["ocifs"] +s3 = ["s3fs (>=2026.6.0)"] +sftp = ["paramiko"] +smb = ["smbprotocol"] +ssh = ["paramiko"] +test = ["aiohttp (!=4.0.0a0,!=4.0.0a1)", "numpy", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "requests"] +test-downstream = ["aiobotocore (>=2.5.4,<3.0.0)", "dask[dataframe,test]", "moto[server] (>4,<5)", "pytest-timeout", "xarray", "zarr"] +test-full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "backports-zstd ; python_version < \"3.14\"", "cloudpickle", "dask", "distributed", "dropbox", "dropboxdrivefs", "fastparquet", "fusepy", "gcsfs (>=2026.4.0)", "jinja2", "kerchunk", "libarchive-c", "lz4", "notebook", "numpy", "ocifs", "pandas (<3.0.0)", "panel", "paramiko", "pyarrow (>=1)", "pyftpdlib", "pygit2", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "python-snappy", "requests", "s3fs (>=2026.6.0)", "smbprotocol", "tqdm", "urllib3", "zarr (<3.2.0)", "zstandard ; python_version < \"3.14\""] +tqdm = ["tqdm"] + +[[package]] +name = "idna" +version = "3.20" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c"}, + {file = "idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44"}, +] + +[package.extras] +all = ["coverage (>=7.10.0)", "hypothesis (>=6.141.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.16.0)", "ty (>=0.0.37)"] + +[[package]] +name = "jinja2" +version = "3.1.6" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "llvmlite" +version = "0.50.0" +description = "lightweight wrapper around basic LLVM functionality" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "llvmlite-0.50.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:211da1b088d566aafa1e444d546f64fc7f13b1af56ff0207a1705d88607be6ab"}, + {file = "llvmlite-0.50.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:accfc36951230e0e694b41bbfc96ba554284e72f0eab2dde0cf273e4109e51ba"}, + {file = "llvmlite-0.50.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2b23236bd0d7ad56a94208263d791956f79c8c45f39458931df556206d4496a"}, + {file = "llvmlite-0.50.0-cp310-cp310-win_amd64.whl", hash = "sha256:cda14ab787e609c2c2c5d1386a6d5f8723e9d047d27341585f606c27dc5744ab"}, + {file = "llvmlite-0.50.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:818b3d4845ac8e126e23cb500867570d0602a42a43e67b14acec31f046e03130"}, + {file = "llvmlite-0.50.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0225351ad77ea30501fc5b4c09ff6868169fde50c5a576cdfda1645091157616"}, + {file = "llvmlite-0.50.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6ffde00d4be8772a24e3e8b3af6bf86a79e7cf066d944ef56136b3957d707dc"}, + {file = "llvmlite-0.50.0-cp311-cp311-win_amd64.whl", hash = "sha256:ffe46ef508df226e54b5fe1f7bf11122e5297bcdbb3902cc5b670a429d56ff47"}, + {file = "llvmlite-0.50.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:55f50a6b7c0b8de88b05d6bc407d70a60486ce024013997dc97e202bd187c75b"}, + {file = "llvmlite-0.50.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e8df54380110ea5e9127386e739d2b0829cc6dfa4a24a9195226336c91b06d5"}, + {file = "llvmlite-0.50.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d501e5103076b9a14be885d2574dc2f6793171aa54a853d1244e011d476f1399"}, + {file = "llvmlite-0.50.0-cp312-cp312-win_amd64.whl", hash = "sha256:c20595cc3a76e3c85140fdafbf9246c732ddf8e0e646ba2f4e4881f87567300d"}, + {file = "llvmlite-0.50.0-cp312-cp312-win_arm64.whl", hash = "sha256:4b78a8b669eda09ca1ff4c1a75003023912092974d3e771d1da0777f1b383bdf"}, + {file = "llvmlite-0.50.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a32980e3d727b0e56974ad89d0764920048602a75805b8917cc0298e798b0ced"}, + {file = "llvmlite-0.50.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7dde9836d144c446a303b57b2dd906c35308411eb07f1279c1db581d3d774048"}, + {file = "llvmlite-0.50.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:425845f415a06dc50db08db033c6b568e0d85c4937e932c605a4d49e1514b2da"}, + {file = "llvmlite-0.50.0-cp313-cp313-win_amd64.whl", hash = "sha256:266a6a29be71c3e3a22960ddcedf66b4e0388e5abb6cc4991cc093d6df402ad7"}, + {file = "llvmlite-0.50.0-cp313-cp313-win_arm64.whl", hash = "sha256:1cb21c420a47dcfa56223228d013c6f9d234e05e06e6819a41638d78bbd78e6c"}, + {file = "llvmlite-0.50.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:ecdc9fae295da8ac793578a27020515e24d970513143efa227e696582aeb16e6"}, + {file = "llvmlite-0.50.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:987600ce6f7bd6d808f4bb0ea61a8eff2fd17cf32355691e801eb0a65a7304f0"}, + {file = "llvmlite-0.50.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33ddf12b1e12d7e551e1c1e6ca8087d0aacc931f480019eb33ef2ab77681da4d"}, + {file = "llvmlite-0.50.0-cp314-cp314-win_amd64.whl", hash = "sha256:7ae211012c6849528a5f7cd17a78d8b2421a2813c7b4184d6c0b2ffa89a7d296"}, + {file = "llvmlite-0.50.0-cp314-cp314-win_arm64.whl", hash = "sha256:e94f9066f1257a9cef6c832e6c9de0f140e2bb150de2db39f657b2a5996e0f6b"}, + {file = "llvmlite-0.50.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:423c8d89d13f7eb4488933d5a86b0fa952927956298cfd0087f6753b5123b5df"}, + {file = "llvmlite-0.50.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:944133e9621d1dfbfdaf0fed3234b99f85e6ba27c38f4045acc8f8a5e699a5c0"}, + {file = "llvmlite-0.50.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1d5b6eac064f201b4aa091030282e6f240d8d322dddd7381840731455c3e664"}, + {file = "llvmlite-0.50.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d88c9b325f5fbefc79d95b1daa8fb96018c40bd2958103eea7334e6c8f17fb40"}, + {file = "llvmlite-0.50.0-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:3f490c0f4800c8ddeee6a607acd037497bf6508586804f4e2f11f53a1ee7fe2d"}, + {file = "llvmlite-0.50.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d5447a6c39171368edfe28a71f605e6e3edd40a1dc31f5e5c9d50585718ae6d0"}, + {file = "llvmlite-0.50.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1ac2b9f699c46219fbbd66b304105f5e1b218f05ffac6fe03cd851f93718e58"}, + {file = "llvmlite-0.50.0-cp315-cp315-win_amd64.whl", hash = "sha256:51a4a716db98591f0a1bea34c6548cdb4017731ee5e678ded8cf842dca8af3c5"}, + {file = "llvmlite-0.50.0-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:e8cc203c1fd509131cd72b7554413d4a3e5527cc5558c5a7ebe19840018c57c1"}, + {file = "llvmlite-0.50.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7d4e2bbb29a860a6e85e22afdb96696241263942a5b214cac3e4b704e1d3abf"}, + {file = "llvmlite-0.50.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:afd7b438c60e0f60c4368ec603bb9f20d938a203b5f59b80bbe50c749b4b2f16"}, + {file = "llvmlite-0.50.0-cp315-cp315t-win_amd64.whl", hash = "sha256:4da0e8c6e6f144b433672a632f75d6b4da7bd4fdb5c3e9981d6ea6741319aeae"}, + {file = "llvmlite-0.50.0.tar.gz", hash = "sha256:f2a2cd6ec9ffcc1b7147dea0d7a49efebf17a2b434e0c2844fe175999d571eb4"}, +] + +[[package]] +name = "markupsafe" +version = "3.0.4" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "markupsafe-3.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd8ea6ebee7aedbf7c749fa80521d9ccf1ba473e0d1e14805caafbaad281c889"}, + {file = "markupsafe-3.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dff05cb7016dff1e9fd68f4122c127b65dfc59de5306cfb7ad92f956f230bee2"}, + {file = "markupsafe-3.0.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf63c214fe879a65e69a386f915e36104fc84254ab141240f8854602d8e0be2a"}, + {file = "markupsafe-3.0.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2a6ef68ae94aed8721934072b27a3b654ea2100b97e4ab864cf1489c90926fbc"}, + {file = "markupsafe-3.0.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd9f8797427910198f95bced71ddfed61130d7e349213bfb8466c9c99e2c46a8"}, + {file = "markupsafe-3.0.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1aca03ede943eb80ab3d63bb082c84b7aab85ea83bd0fd0c200260945fb49d9"}, + {file = "markupsafe-3.0.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0764a13d34cae40db7bbf3a09b7e9b491bf4603e20b263a7a9d6b8e324975d0a"}, + {file = "markupsafe-3.0.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9388003072b95f2f1e3fd908604194d653ba21330d811961a78b7da1a77e9e36"}, + {file = "markupsafe-3.0.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:8698d70a8081ee8c090dbb394768b5789a1da8b131b5499f89d071dd3cfaf6be"}, + {file = "markupsafe-3.0.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:bf053da3c97a4bc5ecfbb218cdd2983febd91c617be8367d139882aa11e490aa"}, + {file = "markupsafe-3.0.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:9438a2648b2195980cb2dd8e53ed7b8df91319e2d0b70ae61a9e1d1bc8d3bec9"}, + {file = "markupsafe-3.0.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:88d59b473bfb03259722600839af9bbd7fa13a2eb514beefeedb95997882f69a"}, + {file = "markupsafe-3.0.4-cp310-cp310-win32.whl", hash = "sha256:4a540e2d3192792fc84eced57bef37851ccb2b41f73291bb17408eea77bcd278"}, + {file = "markupsafe-3.0.4-cp310-cp310-win_amd64.whl", hash = "sha256:5c22873ad1f0532ba40fa1727f3c0fc1bbbaab6d373d4cbe3f0dc74b2e2521c7"}, + {file = "markupsafe-3.0.4-cp310-cp310-win_arm64.whl", hash = "sha256:3d23795802fc8bd72534836d64489bbf0f67c088959091bdb22e10735a5107bf"}, + {file = "markupsafe-3.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9e25feb9e330b63edb0278a0acdf85e50d0cb0fbf49c3084abbe4e24ae195346"}, + {file = "markupsafe-3.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d3391b2188d18737cb2fa147028b1096236eaa7e156446c650a489fa2cadc91"}, + {file = "markupsafe-3.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849dd2bb0e5e4ab2b71c7191726a4a8d5aa8a610daa584728cbee0b710ddc4ef"}, + {file = "markupsafe-3.0.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:befb4158af32106b9a93db8d6d1d1cbbd418c0d5aca0cabb7b1780abf0c89169"}, + {file = "markupsafe-3.0.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71f88e749ea29f67f21f3b36433c1dc54c7729ed2a6d9e2da2e0d9e0d7b224eb"}, + {file = "markupsafe-3.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6da83a088f8ef93b2d483a8232a4dbf4d69d3d8496b568a03c56becac43e1808"}, + {file = "markupsafe-3.0.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f0fac8b13d14bb06c68195f849371924ae53dd7b1c00fed24650f704383b692"}, + {file = "markupsafe-3.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4a7cdc2a420ca01058182da4253329764d4bfa055564d1eced90e6ba1e8b1d3d"}, + {file = "markupsafe-3.0.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:83b3944fea42a8400edf92fd1770fb8d0d4f7de651353bd2d8525a92dba69a21"}, + {file = "markupsafe-3.0.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8138eb83940ec7299024d92d4dee45f601b9e6c5ffde9d25f4e35e326203c707"}, + {file = "markupsafe-3.0.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:811d02d5122171c1941357efd8f9bf4ffe907b7f0a1a4e729a880e4be3f46e3e"}, + {file = "markupsafe-3.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:50b5bedc9ed8a94fc8857a42ef4f84a81ea88f8d4f05dc8705fb23ee6d8dcca7"}, + {file = "markupsafe-3.0.4-cp311-cp311-win32.whl", hash = "sha256:2e5a7cd7fdd14fcb1ae5d7d8bf23d24fbd1daefd1fbca2580132e1ea75f098b5"}, + {file = "markupsafe-3.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:fdb4ca07ab75ffadab4a8b135ad59cdbb3156b99310f3d565370da74a15d6bd3"}, + {file = "markupsafe-3.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:569d65055d367e3dcdf30c3f41119467b73d9ee9faf332bdf40402644f5ac08e"}, + {file = "markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6"}, + {file = "markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f"}, + {file = "markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b"}, + {file = "markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df"}, + {file = "markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c"}, + {file = "markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581"}, + {file = "markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77"}, + {file = "markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c"}, + {file = "markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749"}, + {file = "markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed"}, + {file = "markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786"}, + {file = "markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e"}, + {file = "markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237"}, + {file = "markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7"}, + {file = "markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9"}, + {file = "markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1"}, + {file = "markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1"}, + {file = "markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96"}, + {file = "markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148"}, + {file = "markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e"}, + {file = "markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248"}, + {file = "markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72"}, + {file = "markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2"}, + {file = "markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85"}, + {file = "markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde"}, + {file = "markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6"}, + {file = "markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f"}, + {file = "markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39"}, + {file = "markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee"}, + {file = "markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2"}, + {file = "markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46"}, + {file = "markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17"}, + {file = "markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0"}, + {file = "markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5"}, + {file = "markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc"}, + {file = "markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed"}, + {file = "markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59"}, + {file = "markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453"}, + {file = "markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b"}, + {file = "markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6"}, + {file = "markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634"}, + {file = "markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f"}, + {file = "markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9"}, + {file = "markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f"}, + {file = "markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c"}, + {file = "markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300"}, + {file = "markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0"}, + {file = "markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977"}, + {file = "markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7"}, + {file = "markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17"}, + {file = "markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c"}, + {file = "markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4"}, + {file = "markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c"}, + {file = "markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe"}, + {file = "markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a"}, + {file = "markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2"}, + {file = "markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977"}, + {file = "markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289"}, + {file = "markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe"}, + {file = "markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a"}, + {file = "markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733"}, + {file = "markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34"}, + {file = "markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978"}, + {file = "markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc"}, + {file = "markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc"}, + {file = "markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932"}, + {file = "markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6"}, + {file = "markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691"}, + {file = "markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464"}, + {file = "markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c"}, + {file = "markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65"}, + {file = "markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163"}, + {file = "markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92"}, + {file = "markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a"}, + {file = "markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429"}, + {file = "markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8"}, + {file = "markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97"}, + {file = "markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b"}, + {file = "markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9"}, + {file = "markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653"}, + {file = "markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369"}, + {file = "markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19"}, + {file = "markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e"}, + {file = "markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811"}, + {file = "markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea"}, + {file = "markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916"}, + {file = "markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741"}, + {file = "markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b"}, + {file = "markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214"}, + {file = "markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67"}, + {file = "markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad"}, + {file = "markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99"}, + {file = "markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002"}, + {file = "markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e"}, + {file = "markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c"}, + {file = "markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8"}, + {file = "markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe"}, + {file = "markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2"}, + {file = "markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38"}, + {file = "markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494"}, + {file = "markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d"}, + {file = "markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894"}, + {file = "markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78"}, + {file = "markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c"}, + {file = "markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba"}, + {file = "markupsafe-3.0.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f291bcf42ae98eb5107edb162c3c998b4a89648fd8e99ed4cbd12705292788cd"}, + {file = "markupsafe-3.0.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ac0c7c9f1609b0c4c114feb1d7a3409564c7fb77e360bed9e97e5d25dfeaf868"}, + {file = "markupsafe-3.0.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6768d67d1bce64270e0fdc2e69309d68b9b18ae56ddf6c711d168e9d051c2cac"}, + {file = "markupsafe-3.0.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:14bd2d845d62ab678eaf81da89d7b621b51756c72346745c1a594c09d49207a2"}, + {file = "markupsafe-3.0.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:007e1ffd9bf65bb6ee96df7b258fc632a4868dd5566037986c64781f35a36e98"}, + {file = "markupsafe-3.0.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e8b3d0b18fd623afa12ecb2ce8d8becef69f9b5440c6330c7972200e0bb84b0"}, + {file = "markupsafe-3.0.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:57f9947a7e57a081c1e3e0a2dd0d2dcf290a4531450e6f611e30084c222a7295"}, + {file = "markupsafe-3.0.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b61687d0828e72bf5cda24a2690188f37170bd31c9359ac97e4e66569f120a16"}, + {file = "markupsafe-3.0.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:0cee7cb0f9a1b6892ea482237d9403b3d1b4603aee057d0ff01f0fac2d019a97"}, + {file = "markupsafe-3.0.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:94e4c421742086aeee4c32a506eec8859d7634aad943f7e6aacf70f813478768"}, + {file = "markupsafe-3.0.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:9240187afb63d2f9ddc3e032c670356fe941f6e20662ea168a5dc3f1f317e1b3"}, + {file = "markupsafe-3.0.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e841068dc0be4cb6dfb5c890eb88cbdcff2f4a332393c7ec94e8e618bd32c1a8"}, + {file = "markupsafe-3.0.4-cp39-cp39-win32.whl", hash = "sha256:f61efe1d2fe0de16158a5fe1d1cf3c14bdb6aecd54d8938fd26512c525c1f624"}, + {file = "markupsafe-3.0.4-cp39-cp39-win_amd64.whl", hash = "sha256:2b2b1e18af909b448bb3cf9e3433366f7a8726271fc214e8b10e0f62a78c724b"}, + {file = "markupsafe-3.0.4-cp39-cp39-win_arm64.whl", hash = "sha256:6669c1bf34080161ce49c589cc512ef24d4c704ac9d2b2d3667f519c60418378"}, + {file = "markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6"}, +] + +[[package]] +name = "more-itertools" +version = "11.2.1" +description = "More routines for operating on iterables, beyond itertools" +optional = false +python-versions = ">=3.11" +groups = ["main"] +files = [ + {file = "more_itertools-11.2.1-py3-none-any.whl", hash = "sha256:35a7377edd1dd6608dcb2cdf534ded55ea32d49448875ad042cd3f879fb1ded0"}, + {file = "more_itertools-11.2.1.tar.gz", hash = "sha256:cbf08fd0af284dc69718b9b76a8e6203df624d70209ea511e4219d350c856f63"}, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +description = "Python library for arbitrary-precision floating-point arithmetic" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c"}, + {file = "mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f"}, +] + +[package.extras] +develop = ["codecov", "pycodestyle", "pytest (>=4.6)", "pytest-cov", "wheel"] +docs = ["sphinx"] +gmpy = ["gmpy2 (>=2.1.0a4) ; platform_python_implementation != \"PyPy\""] +tests = ["pytest (>=4.6)"] + +[[package]] +name = "networkx" +version = "3.6" +description = "Python package for creating and manipulating graphs and networks" +optional = false +python-versions = ">=3.11" +groups = ["main"] +files = [ + {file = "networkx-3.6-py3-none-any.whl", hash = "sha256:cdb395b105806062473d3be36458d8f1459a4e4b98e236a66c3a48996e07684f"}, + {file = "networkx-3.6.tar.gz", hash = "sha256:285276002ad1f7f7da0f7b42f004bcba70d381e936559166363707fdad3d72ad"}, +] + +[package.extras] +benchmarking = ["asv", "virtualenv"] +default = ["matplotlib (>=3.8)", "numpy (>=1.25)", "pandas (>=2.0)", "scipy (>=1.11.2)"] +developer = ["mypy (>=1.15)", "pre-commit (>=4.1)"] +doc = ["intersphinx-registry", "myst-nb (>=1.1)", "numpydoc (>=1.8.0)", "pillow (>=10)", "pydata-sphinx-theme (>=0.16)", "sphinx (>=8.0)", "sphinx-gallery (>=0.18)", "texext (>=0.6.7)"] +example = ["cairocffi (>=1.7)", "contextily (>=1.6)", "igraph (>=0.11)", "iplotx (>=0.9.0)", "momepy (>=0.7.2)", "osmnx (>=2.0.0)", "scikit-learn (>=1.5)", "seaborn (>=0.13)"] +extra = ["lxml (>=4.6)", "pydot (>=3.0.1)", "pygraphviz (>=1.14)", "sympy (>=1.10)"] +release = ["build (>=0.10)", "changelist (==0.5)", "twine (>=4.0)", "wheel (>=0.40)"] +test = ["pytest (>=7.2)", "pytest-cov (>=4.0)", "pytest-xdist (>=3.0)"] +test-extras = ["pytest-mpl", "pytest-randomly"] + +[[package]] +name = "numba" +version = "0.68.0" +description = "compiling Python code using LLVM" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "numba-0.68.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:080bf1d0dc6adaa834400b6f92e5407de2a7dd80a665f71f74597e95508b2f1f"}, + {file = "numba-0.68.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:791b8d74951e662cb6a4488c8fb382c862459f62c58f4fe69d959a01fc98b6d5"}, + {file = "numba-0.68.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a5ca82e12b665ef30a19c124f0bd766471cf924c71f70638cb9ade72cc3896f"}, + {file = "numba-0.68.0-cp310-cp310-win_amd64.whl", hash = "sha256:83c22d3cede341102bc215e373c6db30ac36a4aee46ba3d5fb8a574f7a580933"}, + {file = "numba-0.68.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:50399af9d3799a4677044294861169c614bd7e1d8bbfc9479f78a67ab28ff427"}, + {file = "numba-0.68.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:954e2684bca3ea11235272df28e8ef40f18a682c1c635a2398032b404675d8fa"}, + {file = "numba-0.68.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:68f92839637a2aaca8ae124c3abf91f648d2fade50953ea8e81ec604ac05a771"}, + {file = "numba-0.68.0-cp311-cp311-win_amd64.whl", hash = "sha256:d36f7c6a07c27fa175f5a4683083c6a830f7791fbda592a8676ce47a444965f7"}, + {file = "numba-0.68.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:0fdaa2f0256862ebbcd9632ef01ba2a4b94e6d116029e5051a92340d4050a501"}, + {file = "numba-0.68.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3ee1f49b62efbbb804f731f2bd602bd1f8b8d3cc13009f25d69955675f82407"}, + {file = "numba-0.68.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51fe913a70fe9a7a0b193757ff977a9e96c82ae936ae388aec8990814fffdf9d"}, + {file = "numba-0.68.0-cp312-cp312-win_amd64.whl", hash = "sha256:530961dc7e41ee358eca2b828baf7b645ce6fa466d778bb9dc73855dd103c4f7"}, + {file = "numba-0.68.0-cp312-cp312-win_arm64.whl", hash = "sha256:25aa7021e163701f9b3e8e77be81836a4b399500eef073d75bc906ad5eff46e9"}, + {file = "numba-0.68.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:b8b29602f57df06c724fc53b1740887bc4332f202206771d46e47b25b485e904"}, + {file = "numba-0.68.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df6f881c5695f472873d0979bab54261959b3174b6c98a71f6f8a43c3e088985"}, + {file = "numba-0.68.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be647fbc60c18c0323b34479f80173879654894eec58ad061f4b1901e294d854"}, + {file = "numba-0.68.0-cp313-cp313-win_amd64.whl", hash = "sha256:bf7435c81912e271a28a19c348ada5b3986e2409f95a067533c5f4aab8709295"}, + {file = "numba-0.68.0-cp313-cp313-win_arm64.whl", hash = "sha256:50e3c81d8bf6956c7d7330a985bf1468efaa9e4c4539c9fa0ac6c7866ea6e369"}, + {file = "numba-0.68.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bfc890c9ca517823dfae0444595ef50d883ade9d3e17759d9a7650e5d128d950"}, + {file = "numba-0.68.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34ccf54fd9c1d5f4ba00073b81bc492a681f5437c62917fe29813f457564e312"}, + {file = "numba-0.68.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea11c865265e39a6019e2f0fe62743825127b3b7bc4815916f5d5121fd9b262b"}, + {file = "numba-0.68.0-cp314-cp314-win_amd64.whl", hash = "sha256:9c03de7085f08ba11ab2444f252e822c14cee5fa02b73e84d5afd5e28b2bce0f"}, + {file = "numba-0.68.0-cp314-cp314-win_arm64.whl", hash = "sha256:f58c13a6e9bfef062311cb0d3c19f6c159b901213daa325e1db473946010cec7"}, + {file = "numba-0.68.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:79160dc2a3ff0e02aaada2c385faa6de73d71a11f06419d29bb0a90042d243a3"}, + {file = "numba-0.68.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a3aa5558ba1c316020a0c2f6042be6ae063cfc6eb0c7badb3a0c77d2b5308b7"}, + {file = "numba-0.68.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a08750c81fd5c2d9f2c169a73114efb907159401dde9ef4a3b629fa45e097cb7"}, + {file = "numba-0.68.0-cp314-cp314t-win_amd64.whl", hash = "sha256:cad7d5f6fe8eb42a69c500d36c94a61d094f3b91a7a5581a31d1df2eb925d33a"}, + {file = "numba-0.68.0-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:39f935bc854be87784675d9674f5503e56df5a501c95c95bdfb6b3c0b4b9ed1b"}, + {file = "numba-0.68.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7cec6809fe93824e243a8a8c93966b0bb5874a3b7c24c1194c3bafee0ab11f39"}, + {file = "numba-0.68.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1f1180e0332ad5143905288325485b52ac76102330811dc6f2c10088cf4cedc"}, + {file = "numba-0.68.0-cp315-cp315-win_amd64.whl", hash = "sha256:a2d21bb9c4b4818a1e71721ebd19172f488591d548f08453593348b7048ba1fb"}, + {file = "numba-0.68.0.tar.gz", hash = "sha256:8a781de54b980b98f43bff7f1093701b5f07c80d031c7cfa8a87493d8bf73f2d"}, +] + +[package.dependencies] +llvmlite = "==0.50.*" +numpy = ">=1.22,<2.6" + +[[package]] +name = "numpy" +version = "2.5.4" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.12" +groups = ["main"] +files = [ + {file = "numpy-2.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c6342f54c67093cae5c0227eb0eb772fdb79f2a2c37a6eb278b9909ee06aa356"}, + {file = "numpy-2.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b11e8fda06a7d69f15ebf542660b74466c2e51094800c1fb794f47ad4faeef17"}, + {file = "numpy-2.5.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9cb18a327b49c5c337f972b03682f6a49855525faaf3c0d3e9c96cd0fd8880a8"}, + {file = "numpy-2.5.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:aec3fc4b32ff82421274f5d205c559c51c840c8df66a78efd7f3612dd005a26a"}, + {file = "numpy-2.5.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4d21ab149f15e4e6043dfb0de87e6e5f34ac176cde83060e9802981fca2ac2"}, + {file = "numpy-2.5.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbde6962867ee75b48b0ee29b2b9372ec5d617799dbaf38e82dc0596f2f7738a"}, + {file = "numpy-2.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:381a7a3d2e65e64c0ec302795ab9dc12bb1e73f150904699c153716177eebdaf"}, + {file = "numpy-2.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b89d0aaae2fe498c648f4c4795c084db535af5bd98ef942b2a3681fb74ce8645"}, + {file = "numpy-2.5.4-cp312-cp312-win32.whl", hash = "sha256:9968ab7e49b93ac6e1c3b2239732183152c9150f16308d30b66a372cffe3483c"}, + {file = "numpy-2.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7b1b6353e36a7e50de2973a38d705c88ee93adcf120673cee7f45a4a3fa223a"}, + {file = "numpy-2.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:aa1cce2ff3f8d953de38b76bf44602caeb69f101430208f64a10067f7cb4b1d3"}, + {file = "numpy-2.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2377da2dd3ba2c1200956acbab2a358c83b8e1f8531191672d1cd6ad83250d53"}, + {file = "numpy-2.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7415db95818b39ec475a5eea54d9e3b6bc83e3912158e46da3438cdce399804d"}, + {file = "numpy-2.5.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6d6a71b9d9a97c03633aa12565ef2825ffa036cc1d99cfd50dacf0f128af4fe2"}, + {file = "numpy-2.5.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d8200f16437b289a5bb927c6e184eccc3e8389bc0070fea4cd5b9e13c1757959"}, + {file = "numpy-2.5.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2e71b04c6cad90026e544501bbe0ab9290fa8a4d845e7e8c0d124fb429c988"}, + {file = "numpy-2.5.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ffa07666f8da0eef81d149934a626d0d95fbd6838432a33e66245423a9062c0"}, + {file = "numpy-2.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2fa3328f784fc8277fc48026f6cad516f5c561c5d8e2e39b3c9e0c8f23223b34"}, + {file = "numpy-2.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b86966fbe4ad7de710422175572bcdc75fdedadfb54bc6fab7deabccddd7780b"}, + {file = "numpy-2.5.4-cp313-cp313-win32.whl", hash = "sha256:5258bc06526964be5face2fc6f756857a3f24f21ec3e72ca131337a75b165d6c"}, + {file = "numpy-2.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:8b4d2fd2d34e5f8c9235ee787de5631a37a28402b15cb80814df973d2be54129"}, + {file = "numpy-2.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:bc39ac66a7a9a3fbd6134fda43136b60ffde99c8f4501e64e0d2b24da137babf"}, + {file = "numpy-2.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c668b2f0d651605b58892644b0e302c7157f7159544227758c896982ef384b18"}, + {file = "numpy-2.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ffa6ce09a1c6a08e9667dd9c97aa0b14184e8d18f2a14b78b2a2328c9147f076"}, + {file = "numpy-2.5.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:956555e0603a4d38019ae6925711cb9dc43195c076a928accf7ea5d50bddfe53"}, + {file = "numpy-2.5.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2c2c4afffdeb7920e445028dd71eb932cac3e704792e964bc2a232426d4f1255"}, + {file = "numpy-2.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4054173604cd8658796053f1f3bc0befb68ec1c0762c57fdad61e199256a8617"}, + {file = "numpy-2.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d549420b8858885cea8838a727842249218b9c1da24dd517e25c9c7a948310a3"}, + {file = "numpy-2.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:823874a507a84af050493b622affde94b6f7c3a0dc22cb2801381bc03b871c00"}, + {file = "numpy-2.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4e263278bfb5ee6409db8aedbc4cc32973b1b82bc1e8d3c668551d04d83a7e37"}, + {file = "numpy-2.5.4-cp314-cp314-win32.whl", hash = "sha256:cfd73180400042a7c532d30c5e287bdd03c59ff9ee1b4c0316af0539e29dfe23"}, + {file = "numpy-2.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:2ca144f15135b6212a5c47b1e2aeca6e412f102f95a2d5d88d8aec77eb255de3"}, + {file = "numpy-2.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:468397ba3c64427474706e5c9123fe266395496714dc684294eac75cd4930d1e"}, + {file = "numpy-2.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1ef3aa6d7e29bb13677323114280b05acc57607fa2300e66432d665d5418a162"}, + {file = "numpy-2.5.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:98b053943e5a0474ec0da309d2cb9d3f18ea57f8a2067c2ab7b5f763d1068380"}, + {file = "numpy-2.5.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:b64a85f40e154983960a4167d4c1d57a50c7f109b3d3264a3a984154e90a8454"}, + {file = "numpy-2.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a813ed7719bf45463c51779e6a98d0385fe905e48447526938a4b8337333d551"}, + {file = "numpy-2.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9b80cdf5cedba0e90d93fa5f9a333c4d65bd545cd669b71bb97ce2b703c9d73"}, + {file = "numpy-2.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2199ed071f460487c8db2c0e5c0b564494190edb4772fe80f9aad88b2604def5"}, + {file = "numpy-2.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:64f9c9878c1938476365e11ccfb6b770f3b9e5f045ccddc514235041e6959365"}, + {file = "numpy-2.5.4-cp314-cp314t-win32.whl", hash = "sha256:64d1c8ac28a4077cf987e0a71a7a0ef7e2df70722f07f0baa42dbb7eb6938647"}, + {file = "numpy-2.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:067374eb538c34c745436365cf7b0112595c1d326f21ce4ff340f61230239fbb"}, + {file = "numpy-2.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e94aef2c639da4a960ad0db8e06471208d8589974953d78b61d345b4eb99e394"}, + {file = "numpy-2.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8dddfbee2e68d26d0d7d7d9cb247b1fd4409241cce32d815a11d97ec2cfde179"}, + {file = "numpy-2.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:81e3420b27048b65eb14c3acf0c174a8cb0e023277716110347d2dcb26026dad"}, + {file = "numpy-2.5.4-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:0b4724a19de67bea8cfc4970798efa78bcbbe2ac2613cfac16721a42d44de2a5"}, + {file = "numpy-2.5.4-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:2132418bf8dd124a427ca9e6a1daf9ee1a87185344c95119ceae868b99466da1"}, + {file = "numpy-2.5.4-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:325518d4245b9e331387702aa58c2ce1dc4cdcbb41dfb4ccd5dcbc7e08db1266"}, + {file = "numpy-2.5.4-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56733449d2544178beaa4545cee357370440cf056c197f9c7bfb19dbfdd0e86d"}, + {file = "numpy-2.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5ec3753760c1a6d8bb91200666e545c3a9728e6269dfb5d6ce02340996698aa3"}, + {file = "numpy-2.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b1185012870173de7ae33d370bd45b1cf5baee747ea4b97036b65f4e93016877"}, + {file = "numpy-2.5.4-cp315-cp315-win32.whl", hash = "sha256:298eca75243f2cbbfdb460560b9fb2a1792a33cf2ab4286efd43d92e8d3df508"}, + {file = "numpy-2.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:332f3378fe077dd850e677ec01bdcc4f22368fb5d50ef10b2c79230b1bf5a592"}, + {file = "numpy-2.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:d4cccbbc78717966f764cd3af4fb70276fa01fc7a2688af11c78901fa5c04f05"}, + {file = "numpy-2.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:950ea81d57ef070665581b6e1b5f6a029306423cd1739c5b95fe78aa30db6b9d"}, + {file = "numpy-2.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c05ede731b03fb1b7591faca9389ade3267d2bddf1ad8882bb3f2cc5e101694f"}, + {file = "numpy-2.5.4-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:5fbf7141bbfd63aea22f435c9062a032b9ea0082fe9845dad7f021d3f1234e71"}, + {file = "numpy-2.5.4-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:3573cd22564692a5b899ec344e5d5b9cc4576f2985b96f22af3564ed54f2710f"}, + {file = "numpy-2.5.4-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c109eac9cd439193678f69d70733c1108487546ca8eafc107b510ae10c1aecd"}, + {file = "numpy-2.5.4-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80d6ef6e8620eb2c2b4c4caad50b5935d6db3cde2d51581b55dcc79e14016d1d"}, + {file = "numpy-2.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:77045a4b175bbf5316ec08003880804336c78f92281a1b72222b274ea85ec5ac"}, + {file = "numpy-2.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:0f02a46e49cfb6c73bdb7aea1c0d3461dbae9aba613542b65f657cd3d17b9fab"}, + {file = "numpy-2.5.4-cp315-cp315t-win32.whl", hash = "sha256:ad62a416ddcf863bf44bba76fbf6b53366ab0692e294f51cae4b5fbe0d246788"}, + {file = "numpy-2.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:38f47be9f74ab870d2633b5456ae519c43758a8d1fd05342f0ce4ecc034396ee"}, + {file = "numpy-2.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:7a14a461d9340f1b46b8648578aed9cdb8b3b018a8fac6c1dde2c9192a01a87f"}, + {file = "numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a"}, +] + +[[package]] +name = "nvidia-cublas-cu12" +version = "12.6.4.1" +description = "CUBLAS native runtime libraries" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_machine == \"x86_64\" and platform_system == \"Linux\"" +files = [ + {file = "nvidia_cublas_cu12-12.6.4.1-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08ed2686e9875d01b58e3cb379c6896df8e76c75e0d4a7f7dace3d7b6d9ef8eb"}, + {file = "nvidia_cublas_cu12-12.6.4.1-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:235f728d6e2a409eddf1df58d5b0921cf80cfa9e72b9f2775ccb7b4a87984668"}, + {file = "nvidia_cublas_cu12-12.6.4.1-py3-none-win_amd64.whl", hash = "sha256:9e4fa264f4d8a4eb0cdbd34beadc029f453b3bafae02401e999cf3d5a5af75f8"}, +] + +[[package]] +name = "nvidia-cuda-cupti-cu12" +version = "12.6.80" +description = "CUDA profiling tools runtime libs." +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_machine == \"x86_64\" and platform_system == \"Linux\"" +files = [ + {file = "nvidia_cuda_cupti_cu12-12.6.80-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:166ee35a3ff1587f2490364f90eeeb8da06cd867bd5b701bf7f9a02b78bc63fc"}, + {file = "nvidia_cuda_cupti_cu12-12.6.80-py3-none-manylinux2014_aarch64.whl", hash = "sha256:358b4a1d35370353d52e12f0a7d1769fc01ff74a191689d3870b2123156184c4"}, + {file = "nvidia_cuda_cupti_cu12-12.6.80-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6768bad6cab4f19e8292125e5f1ac8aa7d1718704012a0e3272a6f61c4bce132"}, + {file = "nvidia_cuda_cupti_cu12-12.6.80-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a3eff6cdfcc6a4c35db968a06fcadb061cbc7d6dde548609a941ff8701b98b73"}, + {file = "nvidia_cuda_cupti_cu12-12.6.80-py3-none-win_amd64.whl", hash = "sha256:bbe6ae76e83ce5251b56e8c8e61a964f757175682bbad058b170b136266ab00a"}, +] + +[[package]] +name = "nvidia-cuda-nvrtc-cu12" +version = "12.6.77" +description = "NVRTC native runtime libraries" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_machine == \"x86_64\" and platform_system == \"Linux\"" +files = [ + {file = "nvidia_cuda_nvrtc_cu12-12.6.77-py3-none-manylinux2014_aarch64.whl", hash = "sha256:5847f1d6e5b757f1d2b3991a01082a44aad6f10ab3c5c0213fa3e25bddc25a13"}, + {file = "nvidia_cuda_nvrtc_cu12-12.6.77-py3-none-manylinux2014_x86_64.whl", hash = "sha256:35b0cc6ee3a9636d5409133e79273ce1f3fd087abb0532d2d2e8fff1fe9efc53"}, + {file = "nvidia_cuda_nvrtc_cu12-12.6.77-py3-none-win_amd64.whl", hash = "sha256:f7007dbd914c56bd80ea31bc43e8e149da38f68158f423ba845fc3292684e45a"}, +] + +[[package]] +name = "nvidia-cuda-runtime-cu12" +version = "12.6.77" +description = "CUDA Runtime native Libraries" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_machine == \"x86_64\" and platform_system == \"Linux\"" +files = [ + {file = "nvidia_cuda_runtime_cu12-12.6.77-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6116fad3e049e04791c0256a9778c16237837c08b27ed8c8401e2e45de8d60cd"}, + {file = "nvidia_cuda_runtime_cu12-12.6.77-py3-none-manylinux2014_aarch64.whl", hash = "sha256:d461264ecb429c84c8879a7153499ddc7b19b5f8d84c204307491989a365588e"}, + {file = "nvidia_cuda_runtime_cu12-12.6.77-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ba3b56a4f896141e25e19ab287cd71e52a6a0f4b29d0d31609f60e3b4d5219b7"}, + {file = "nvidia_cuda_runtime_cu12-12.6.77-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a84d15d5e1da416dd4774cb42edf5e954a3e60cc945698dc1d5be02321c44dc8"}, + {file = "nvidia_cuda_runtime_cu12-12.6.77-py3-none-win_amd64.whl", hash = "sha256:86c58044c824bf3c173c49a2dbc7a6c8b53cb4e4dca50068be0bf64e9dab3f7f"}, +] + +[[package]] +name = "nvidia-cudnn-cu12" +version = "9.5.1.17" +description = "cuDNN runtime libraries" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_machine == \"x86_64\" and platform_system == \"Linux\"" +files = [ + {file = "nvidia_cudnn_cu12-9.5.1.17-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9fd4584468533c61873e5fda8ca41bac3a38bcb2d12350830c69b0a96a7e4def"}, + {file = "nvidia_cudnn_cu12-9.5.1.17-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:30ac3869f6db17d170e0e556dd6cc5eee02647abc31ca856634d5a40f82c15b2"}, + {file = "nvidia_cudnn_cu12-9.5.1.17-py3-none-win_amd64.whl", hash = "sha256:d7af0f8a4f3b4b9dbb3122f2ef553b45694ed9c384d5a75bab197b8eefb79ab8"}, +] + +[package.dependencies] +nvidia-cublas-cu12 = "*" + +[[package]] +name = "nvidia-cufft-cu12" +version = "11.3.0.4" +description = "CUFFT native runtime libraries" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_machine == \"x86_64\" and platform_system == \"Linux\"" +files = [ + {file = "nvidia_cufft_cu12-11.3.0.4-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d16079550df460376455cba121db6564089176d9bac9e4f360493ca4741b22a6"}, + {file = "nvidia_cufft_cu12-11.3.0.4-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8510990de9f96c803a051822618d42bf6cb8f069ff3f48d93a8486efdacb48fb"}, + {file = "nvidia_cufft_cu12-11.3.0.4-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ccba62eb9cef5559abd5e0d54ceed2d9934030f51163df018532142a8ec533e5"}, + {file = "nvidia_cufft_cu12-11.3.0.4-py3-none-manylinux2014_x86_64.whl", hash = "sha256:768160ac89f6f7b459bee747e8d175dbf53619cfe74b2a5636264163138013ca"}, + {file = "nvidia_cufft_cu12-11.3.0.4-py3-none-win_amd64.whl", hash = "sha256:6048ebddfb90d09d2707efb1fd78d4e3a77cb3ae4dc60e19aab6be0ece2ae464"}, +] + +[package.dependencies] +nvidia-nvjitlink-cu12 = "*" + +[[package]] +name = "nvidia-cufile-cu12" +version = "1.11.1.6" +description = "cuFile GPUDirect libraries" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_machine == \"x86_64\" and platform_system == \"Linux\"" +files = [ + {file = "nvidia_cufile_cu12-1.11.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc23469d1c7e52ce6c1d55253273d32c565dd22068647f3aa59b3c6b005bf159"}, + {file = "nvidia_cufile_cu12-1.11.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:8f57a0051dcf2543f6dc2b98a98cb2719c37d3cee1baba8965d57f3bbc90d4db"}, +] + +[[package]] +name = "nvidia-curand-cu12" +version = "10.3.7.77" +description = "CURAND native runtime libraries" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_machine == \"x86_64\" and platform_system == \"Linux\"" +files = [ + {file = "nvidia_curand_cu12-10.3.7.77-py3-none-manylinux2014_aarch64.whl", hash = "sha256:6e82df077060ea28e37f48a3ec442a8f47690c7499bff392a5938614b56c98d8"}, + {file = "nvidia_curand_cu12-10.3.7.77-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a42cd1344297f70b9e39a1e4f467a4e1c10f1da54ff7a85c12197f6c652c8bdf"}, + {file = "nvidia_curand_cu12-10.3.7.77-py3-none-manylinux2014_x86_64.whl", hash = "sha256:99f1a32f1ac2bd134897fc7a203f779303261268a65762a623bf30cc9fe79117"}, + {file = "nvidia_curand_cu12-10.3.7.77-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:7b2ed8e95595c3591d984ea3603dd66fe6ce6812b886d59049988a712ed06b6e"}, + {file = "nvidia_curand_cu12-10.3.7.77-py3-none-win_amd64.whl", hash = "sha256:6d6d935ffba0f3d439b7cd968192ff068fafd9018dbf1b85b37261b13cfc9905"}, +] + +[[package]] +name = "nvidia-cusolver-cu12" +version = "11.7.1.2" +description = "CUDA solver native runtime libraries" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_machine == \"x86_64\" and platform_system == \"Linux\"" +files = [ + {file = "nvidia_cusolver_cu12-11.7.1.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:0ce237ef60acde1efc457335a2ddadfd7610b892d94efee7b776c64bb1cac9e0"}, + {file = "nvidia_cusolver_cu12-11.7.1.2-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e9e49843a7707e42022babb9bcfa33c29857a93b88020c4e4434656a655b698c"}, + {file = "nvidia_cusolver_cu12-11.7.1.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:6cf28f17f64107a0c4d7802be5ff5537b2130bfc112f25d5a30df227058ca0e6"}, + {file = "nvidia_cusolver_cu12-11.7.1.2-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:dbbe4fc38ec1289c7e5230e16248365e375c3673c9c8bac5796e2e20db07f56e"}, + {file = "nvidia_cusolver_cu12-11.7.1.2-py3-none-win_amd64.whl", hash = "sha256:6813f9d8073f555444a8705f3ab0296d3e1cb37a16d694c5fc8b862a0d8706d7"}, +] + +[package.dependencies] +nvidia-cublas-cu12 = "*" +nvidia-cusparse-cu12 = "*" +nvidia-nvjitlink-cu12 = "*" + +[[package]] +name = "nvidia-cusparse-cu12" +version = "12.5.4.2" +description = "CUSPARSE native runtime libraries" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_machine == \"x86_64\" and platform_system == \"Linux\"" +files = [ + {file = "nvidia_cusparse_cu12-12.5.4.2-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d25b62fb18751758fe3c93a4a08eff08effedfe4edf1c6bb5afd0890fe88f887"}, + {file = "nvidia_cusparse_cu12-12.5.4.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7aa32fa5470cf754f72d1116c7cbc300b4e638d3ae5304cfa4a638a5b87161b1"}, + {file = "nvidia_cusparse_cu12-12.5.4.2-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7556d9eca156e18184b94947ade0fba5bb47d69cec46bf8660fd2c71a4b48b73"}, + {file = "nvidia_cusparse_cu12-12.5.4.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:23749a6571191a215cb74d1cdbff4a86e7b19f1200c071b3fcf844a5bea23a2f"}, + {file = "nvidia_cusparse_cu12-12.5.4.2-py3-none-win_amd64.whl", hash = "sha256:4acb8c08855a26d737398cba8fb6f8f5045d93f82612b4cfd84645a2332ccf20"}, +] + +[package.dependencies] +nvidia-nvjitlink-cu12 = "*" + +[[package]] +name = "nvidia-cusparselt-cu12" +version = "0.6.3" +description = "NVIDIA cuSPARSELt" +optional = false +python-versions = "*" +groups = ["main"] +markers = "platform_machine == \"x86_64\" and platform_system == \"Linux\"" +files = [ + {file = "nvidia_cusparselt_cu12-0.6.3-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8371549623ba601a06322af2133c4a44350575f5a3108fb75f3ef20b822ad5f1"}, + {file = "nvidia_cusparselt_cu12-0.6.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:e5c8a26c36445dd2e6812f1177978a24e2d37cacce7e090f297a688d1ec44f46"}, + {file = "nvidia_cusparselt_cu12-0.6.3-py3-none-win_amd64.whl", hash = "sha256:3b325bcbd9b754ba43df5a311488fca11a6b5dc3d11df4d190c000cf1a0765c7"}, +] + +[[package]] +name = "nvidia-nccl-cu12" +version = "2.26.2" +description = "NVIDIA Collective Communication Library (NCCL) Runtime" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_machine == \"x86_64\" and platform_system == \"Linux\"" +files = [ + {file = "nvidia_nccl_cu12-2.26.2-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c196e95e832ad30fbbb50381eb3cbd1fadd5675e587a548563993609af19522"}, + {file = "nvidia_nccl_cu12-2.26.2-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:694cf3879a206553cc9d7dbda76b13efaf610fdb70a50cba303de1b0d1530ac6"}, +] + +[[package]] +name = "nvidia-nvjitlink-cu12" +version = "12.6.85" +description = "Nvidia JIT LTO Library" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_machine == \"x86_64\" and platform_system == \"Linux\"" +files = [ + {file = "nvidia_nvjitlink_cu12-12.6.85-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:eedc36df9e88b682efe4309aa16b5b4e78c2407eac59e8c10a6a47535164369a"}, + {file = "nvidia_nvjitlink_cu12-12.6.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cf4eaa7d4b6b543ffd69d6abfb11efdeb2db48270d94dfd3a452c24150829e41"}, + {file = "nvidia_nvjitlink_cu12-12.6.85-py3-none-win_amd64.whl", hash = "sha256:e61120e52ed675747825cdd16febc6a0730537451d867ee58bee3853b1b13d1c"}, +] + +[[package]] +name = "nvidia-nvtx-cu12" +version = "12.6.77" +description = "NVIDIA Tools Extension" +optional = false +python-versions = ">=3" +groups = ["main"] +markers = "platform_machine == \"x86_64\" and platform_system == \"Linux\"" +files = [ + {file = "nvidia_nvtx_cu12-12.6.77-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f44f8d86bb7d5629988d61c8d3ae61dddb2015dee142740536bc7481b022fe4b"}, + {file = "nvidia_nvtx_cu12-12.6.77-py3-none-manylinux2014_aarch64.whl", hash = "sha256:adcaabb9d436c9761fca2b13959a2d237c5f9fd406c8e4b723c695409ff88059"}, + {file = "nvidia_nvtx_cu12-12.6.77-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b90bed3df379fa79afbd21be8e04a0314336b8ae16768b58f2d34cb1d04cd7d2"}, + {file = "nvidia_nvtx_cu12-12.6.77-py3-none-manylinux2014_x86_64.whl", hash = "sha256:6574241a3ec5fdc9334353ab8c479fe75841dbe8f4532a8fc97ce63503330ba1"}, + {file = "nvidia_nvtx_cu12-12.6.77-py3-none-win_amd64.whl", hash = "sha256:2fb11a4af04a5e6c84073e6404d26588a34afd35379f0855a99797897efa75c0"}, +] + +[[package]] +name = "openai-whisper" +version = "20250625" +description = "Robust Speech Recognition via Large-Scale Weak Supervision" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "openai_whisper-20250625.tar.gz", hash = "sha256:37a91a3921809d9f44748ffc73c0a55c9f366c85a3ef5c2ae0cc09540432eb96"}, +] + +[package.dependencies] +more-itertools = "*" +numba = "*" +numpy = "*" +tiktoken = "*" +torch = "*" +tqdm = "*" +triton = {version = ">=2", markers = "platform_machine == \"x86_64\" and sys_platform == \"linux\" or sys_platform == \"linux2\""} + +[package.extras] +dev = ["black", "flake8", "isort", "pytest", "scipy"] + +[[package]] +name = "regex" +version = "2026.9.29" +description = "Alternative regular expression module, to replace re." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "regex-2026.9.29-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9916fda742cd4eede63b286f58c06718324265d727ce0856eb1aac86d0d150d6"}, + {file = "regex-2026.9.29-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8873c4a11c50b9989168881aeb3f08859f469d809941866aa1feefd8be5431f6"}, + {file = "regex-2026.9.29-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1d9fe8091b2e89d470df68a9331111ed008ae8aae6bf1e8e1fba4086a495c84e"}, + {file = "regex-2026.9.29-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb00027a09a8f9f08028b40dce4c933cf73e4833240ed356583fdc9cfa721566"}, + {file = "regex-2026.9.29-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14e953ff3607c92d7675bf79c4d4509ef6782aa8c08509f179f9b3d6d0679e86"}, + {file = "regex-2026.9.29-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0476e5bcbe6e1ba3d1c4cc7bbb1c3ba78e3b979b5c8a88d0a6a8cdd4992b8c84"}, + {file = "regex-2026.9.29-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fb41211d2333eb930a51e0546a65999761cf1f572a4da56ef9b8a62966c06f2"}, + {file = "regex-2026.9.29-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:edf06545875f3efa31560d94121e95c7fd70d98b1dfedc0157097d79b13b52ea"}, + {file = "regex-2026.9.29-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6398d5145689503412cc1748895242598d8846b8967b851133b20dc2ed1e21e8"}, + {file = "regex-2026.9.29-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:45010bcfe66df41522d56c9b6114e87ecc597a08970ff6a2ced24415c141ae5f"}, + {file = "regex-2026.9.29-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a5758353650079898dc1b2b0e95aa51fa23a30d020e06f62c430dd08ee56cdd8"}, + {file = "regex-2026.9.29-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6f7121a8914ed13fcfe2099f895341bfb789f004d4c5a0bdece8fa667da10849"}, + {file = "regex-2026.9.29-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:b9d74e4eee9ddb64c2e92d5d61472c59c21684c059eb7b68767be9628e977859"}, + {file = "regex-2026.9.29-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:143533cc4b6fbc5b95aca0a5b8d541088d374831593def000ec89322c220221d"}, + {file = "regex-2026.9.29-cp310-cp310-win32.whl", hash = "sha256:b84f186a7f0536fe4ff9a9fa12d06d007b9b71d4b5352ddcc41f59ad6522a312"}, + {file = "regex-2026.9.29-cp310-cp310-win_amd64.whl", hash = "sha256:23ae6fdad9e63e54038f5ef78aba2933faca61e24d432786589e737bc5522ebb"}, + {file = "regex-2026.9.29-cp310-cp310-win_arm64.whl", hash = "sha256:c0094897d7d01f184b2d7fe8c56c66d64efe01b31f4b7d34205b391387df1111"}, + {file = "regex-2026.9.29-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6abb75ab16bc3281714a5b99548a2225db70dba1f995f6d7f7419b76eb5a8fbe"}, + {file = "regex-2026.9.29-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b7b893976e7fe42053da64f2aa27239c24252fd2ec6df471e1be197c0addc3b1"}, + {file = "regex-2026.9.29-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:066d0e3dbfdd739bce2bf8c2a41dd16f73e3d8adc2eb06dd803a36a307f56075"}, + {file = "regex-2026.9.29-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7020ed44df30b3aa492c00ee3b52d0548c1f30c2c6c5bb13ae897680900d3413"}, + {file = "regex-2026.9.29-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4613d7d9dda60fcba95f846cc6f808017f1843f392cf9daad14a6534493d71"}, + {file = "regex-2026.9.29-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bec37990e3d6121f29ecfb594bd8f1bf009e9f7926daba2e50e3b27d3892a783"}, + {file = "regex-2026.9.29-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:612b709381c0355b70d89cdb51b7f670591ed5cbbc0e3b5337488019dc667b65"}, + {file = "regex-2026.9.29-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a760da040b47767b4b873adfb7c3b691e9ba2fc60f113f9d0b88f1a62f323e85"}, + {file = "regex-2026.9.29-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:49ee178ca31c94621294bf9b8b676a92a2e6bba8af0529591753719e57edb621"}, + {file = "regex-2026.9.29-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:5eeb8edc6110d9194a4d0d54610f64c37a31c605b5dbb7e407fc6ec7fa34a4a1"}, + {file = "regex-2026.9.29-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ccb64d887a9db1cd76dbc0f92051a1a478a2a67e7f56c62d915cb881d7734704"}, + {file = "regex-2026.9.29-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9e4482589065c8ecd761cff522dcd85f2d39e62f551e37e025d1c7d54772def3"}, + {file = "regex-2026.9.29-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d60030baaa7bfbb02d650c126cdcddcb6e33dbff14d819434c8fa2fdcaeeeba5"}, + {file = "regex-2026.9.29-cp311-cp311-win32.whl", hash = "sha256:18ae8eed4526e35bdb754d61562b90bf5c00a67fdcf3cc1380dd59597486631b"}, + {file = "regex-2026.9.29-cp311-cp311-win_amd64.whl", hash = "sha256:1043aedf5917caa861bcb25a9c11460049656bdf0017a90a309fa8f255467725"}, + {file = "regex-2026.9.29-cp311-cp311-win_arm64.whl", hash = "sha256:352cf115a810b357caa35193ab656ecf5ef41056855e82f292c99e8514f8d954"}, + {file = "regex-2026.9.29-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dc79d36d0618752265f0d575915bdc5c5130ecb9c9f6b3bcefeae32e4bdfafcf"}, + {file = "regex-2026.9.29-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3a21a9509d0ee88e7a70e1ad228cd2f0e0fd1e187458db132e8a8d18c97daf9d"}, + {file = "regex-2026.9.29-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f57dc6b8fef170f105d2cf5cdce254f47b137d7755086cf7050f47e16582abba"}, + {file = "regex-2026.9.29-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f93bc1c3486ef3747e07c9d7c1d0a147b8fbaab975f80e348aed6f71309dfaca"}, + {file = "regex-2026.9.29-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e1d3a4cb7993b708f0ada8d0c84590efd853f169e7147d2202c9da503180242"}, + {file = "regex-2026.9.29-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dabee8f4935e731fb46b2a3091bdda0d3d94b3bbfb907d2b4f12eefce4009619"}, + {file = "regex-2026.9.29-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39ab5894d971f9ac68baa6eca5c50387db579cfcacf36ae8df3feceb1815e6d0"}, + {file = "regex-2026.9.29-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c1a9a6651197fbed6f0212591418b9def774fc3f8324f78d1bf0e6a63e5f8aa1"}, + {file = "regex-2026.9.29-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87fb80cbe3557e27e7b28b995c2b2eedf689b8886f941ab93e0e288f0976518a"}, + {file = "regex-2026.9.29-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3c5c2ef13797466aa64170cbb66ad98a32351dd4127694cea7199f80f213750d"}, + {file = "regex-2026.9.29-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:59b49507f47479e299a9e1bc41b5cb83a7afda0540625f1dbae886615978acbf"}, + {file = "regex-2026.9.29-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0dd8af32e9f7b56b7f95cc1fd79b23054c3bdc172392ae560acc24d57b7ffe71"}, + {file = "regex-2026.9.29-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db5e82ba15c142425b8406690032df89e39cca4a2e8afbbb9a3d84edc2373ac3"}, + {file = "regex-2026.9.29-cp312-cp312-win32.whl", hash = "sha256:d0c3082bf79bcd6a614d55916590ad4b8f93200e10b97f463ea5d9d07c9b5f23"}, + {file = "regex-2026.9.29-cp312-cp312-win_amd64.whl", hash = "sha256:fdd88ed5e20b1bcdd234421e454962c971aa44b653bdb7f1ea9ef683e90fb649"}, + {file = "regex-2026.9.29-cp312-cp312-win_arm64.whl", hash = "sha256:4fe97894d1b306c919b4e50def1e6f6c522f4d03a7283811f4d108f1ce5d3ac2"}, + {file = "regex-2026.9.29-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f1a0d5117230dd46b399a30a38afa44f79c99f3168988fdc4f425c3f928b39df"}, + {file = "regex-2026.9.29-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f0fe9834e5aeccaf19a0d8feb296d66a24be1a7c9922002f842a682cd5abb787"}, + {file = "regex-2026.9.29-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c90fcf7804ea0a54b896ce0f2b9565350220b8d4890fd0db461a476a4c687963"}, + {file = "regex-2026.9.29-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e11edba5bc344a32b029a7af9d4b3173982dd79eeafa0b9dbd787364414b0509"}, + {file = "regex-2026.9.29-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb90e7177944b6684738c1fc36aabd2dd00d1de3be7dbe09f91e196f1bc0dc81"}, + {file = "regex-2026.9.29-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d06fcdecc10fc7954d7c8f27a03c96055fe525274dc84a7b0dbdc3d6b9e03dab"}, + {file = "regex-2026.9.29-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d49c18f1ea294cf4adde2e5ac256e98c82ea9d708462ce4bf799dffa7cfe8a2c"}, + {file = "regex-2026.9.29-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e778bfccd63075167709136afbc251c1f683758d5bf49c803c60ac3f894ce6b"}, + {file = "regex-2026.9.29-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:686ac5350fceae63830bb98805fcb8039325bf4c06d9f6f048ff65229d5bffa5"}, + {file = "regex-2026.9.29-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:26ec4ccce55aa533fbd603d08911b01101a8fcfec987845ac3ae2c7087b2bde3"}, + {file = "regex-2026.9.29-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a655d34b2a6943af32401f3d94f72e9d731f6ad16285815550bf2b4ee69d420a"}, + {file = "regex-2026.9.29-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0c992c19cd45058a4b92f68f139c93db168b48fb1f322c9a7cd620806afb6b51"}, + {file = "regex-2026.9.29-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ebb8912f565b8cdbbf27debfe00df04202c20e2f651b9e32767930c5eace3621"}, + {file = "regex-2026.9.29-cp313-cp313-win32.whl", hash = "sha256:4d7d93613b01b0199961330e49cfc52d479b3d5776c56c691db31130c0a07d91"}, + {file = "regex-2026.9.29-cp313-cp313-win_amd64.whl", hash = "sha256:61956f074ecd123f55adca68ee3eab46e6a07ad3f8e64e6db95dfacb444f55c4"}, + {file = "regex-2026.9.29-cp313-cp313-win_arm64.whl", hash = "sha256:bfc71e6d970419c1309b3640305298643e2a734cad3f7cfb6d2ddee4175ab53d"}, + {file = "regex-2026.9.29-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:957bb708e8057ab1649ba566456429d691ec9b90d1c9ad1af1ba7ffbbeaf05f2"}, + {file = "regex-2026.9.29-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c9b602fae1e00b7c035d661ce85575365719192a7b46784bd71cf64c68053aa0"}, + {file = "regex-2026.9.29-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0166844493626c5015c6088ee15c9ca2fd060ca15b7641d1657da6a58432ae33"}, + {file = "regex-2026.9.29-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b97a38fb4c732b6832db6bf108963adbcd82ef1268ba2025dce390f45af75efa"}, + {file = "regex-2026.9.29-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a540abfab208e1b7ef2df231c40ef3b6cbb30a0aad6204e9b6a81c10a6794628"}, + {file = "regex-2026.9.29-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ddfa987262763c3c22a8367d2a49c244b018a74c3a8e3ab1a864119ad45c5633"}, + {file = "regex-2026.9.29-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f7f7aa47b229f2b39a2ae2596d2ad5625d77b5eb9856fac2dab3eb506cdd0a0"}, + {file = "regex-2026.9.29-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d9b77b25b4f395f92de6099ab08e8ae2bc7e51dfe157f22900902243a5cc90c7"}, + {file = "regex-2026.9.29-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34b6925af9853bf461950e6508910f179fd6e9b1a7ec8548e069606b7e51a26b"}, + {file = "regex-2026.9.29-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:addd736a0547d553283adaf4e05d7104e7f2c7b0b092e9b4d28756825f14531f"}, + {file = "regex-2026.9.29-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fe3fa1dd453ed5c7f5ea23a26218329790ed7197a99b90e94330e313959a7f52"}, + {file = "regex-2026.9.29-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0cc63b5e47c12a48d90c7e9d7de6a035dd14f62868aaedbb4e0ff8ba2b8bfe7b"}, + {file = "regex-2026.9.29-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:724184b4aafed865e4f13ca313fdcb43024300c028ec67319cfa16847d84685e"}, + {file = "regex-2026.9.29-cp314-cp314-win32.whl", hash = "sha256:c6c8fabf1dafc1f1ddcbb67896d3f93efb092e8c4b6322d7389b944e76a484e5"}, + {file = "regex-2026.9.29-cp314-cp314-win_amd64.whl", hash = "sha256:1c2a0026062abcc321a53db4a185ceba0b59a66b5d37b0808917a88b55a5257f"}, + {file = "regex-2026.9.29-cp314-cp314-win_arm64.whl", hash = "sha256:121a76a0985db80ceae9e171c337f8c927868e37d01b54e3ce87bc87f9c6a208"}, + {file = "regex-2026.9.29-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e31f72490b7c12f7790e1e25c3afffd20503ee1bfb43461d7838b871ff244b19"}, + {file = "regex-2026.9.29-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:80ea96f5c1a30bf09007d48466521d9c294bebe197c708c3359096e3e3691632"}, + {file = "regex-2026.9.29-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:554bffadcbcb6d5f4e5fb10a61cc52084b9a63d1dab5f10bcd2c4343972e8e2c"}, + {file = "regex-2026.9.29-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:864e9b87ac33c3fb9fb4ad48166d4fdb579c351d5c77deb0d34bccb36a775cd9"}, + {file = "regex-2026.9.29-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:044265d77d94f5e3cb2fd72c76723807c429cb8c533e9d4672d0334a6f14f588"}, + {file = "regex-2026.9.29-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2089fe39c406784d90101c726755ffa1497bb74638fd434300d2b88006186de8"}, + {file = "regex-2026.9.29-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0def9fb6abac55492d6d51cddb7225d07d6f279e774e0adc08569a54a5fc8d46"}, + {file = "regex-2026.9.29-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:888d60953908dcf761aa320c3e390ab8556efbdb551ace63921de90f6ae0848d"}, + {file = "regex-2026.9.29-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed511a0708e2297e1d6431e7fb217e3402791e491e02da800658ace4973df1bb"}, + {file = "regex-2026.9.29-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e1172147d28d8fbcf8cb8d26c41506169f5ad8fe9ec969cb116835a19d4d8eca"}, + {file = "regex-2026.9.29-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:92f05c9c42bde5785dc48770bc2194d9f7442544156f951e19cd31b096cec562"}, + {file = "regex-2026.9.29-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f37964e4a5e993d2fd45147741e9dff7f34a2d8c00ab94c4ea0514a4677f959e"}, + {file = "regex-2026.9.29-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:951733b1bbdb71e377cec567b409f1a7881b47cfcad84121aa74cb575fa425ea"}, + {file = "regex-2026.9.29-cp314-cp314t-win32.whl", hash = "sha256:65b408d8fcb273e3499e7ef2ce796810da1becd208c7fb4373692a242d79d461"}, + {file = "regex-2026.9.29-cp314-cp314t-win_amd64.whl", hash = "sha256:bf48516e35cf848390ea68850aba53e7c333720d2945b4d2c25b69fc5171723f"}, + {file = "regex-2026.9.29-cp314-cp314t-win_arm64.whl", hash = "sha256:9173db3be74a35cb6731701094b98120f7ee4876a287882a59cdea1fa7da342f"}, + {file = "regex-2026.9.29-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c3589f40749acce747510bf5d589d54e376cb0930ea58b35effac97e5312b0c1"}, + {file = "regex-2026.9.29-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:32ab11df9677ca80bcbb5fe4eb1da9109a5019239a054836efc6fa1c64e683cf"}, + {file = "regex-2026.9.29-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7c03031610e3e6ed1768a2b7a8fc84637c1257b50c5eacaf094c6e17a84fc563"}, + {file = "regex-2026.9.29-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:42e82e578c904445d4c8a35b8f28052cf567593215fa5db06266fbc6f77aaa2e"}, + {file = "regex-2026.9.29-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b65c72739f981377c9c22e0c5c3cd7f42da7bd8a3c9209330fac772c7d893ed"}, + {file = "regex-2026.9.29-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4408b2b27a95ca8cc48b7411945753773353b5c93b307754781086c99d3a576f"}, + {file = "regex-2026.9.29-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a714befaacbd10092ffe4cea0d3c5f008fb9efe9bc322c715bcdfdee414b9a3d"}, + {file = "regex-2026.9.29-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33026515aebc0e70d1c89978e53e8d695d35d9e472f8d5b34465ba3c74028650"}, + {file = "regex-2026.9.29-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:31b003f9a070335e2a8233ee9b14a3ca8e6d792012ae011f741bf0aaf11744c5"}, + {file = "regex-2026.9.29-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:c03c6eb6ece86dfdcbb34799efaa339b093132e1aceed491ba5e08fe06cdf699"}, + {file = "regex-2026.9.29-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:a5300757f8a68f5b6cc33f57338d72a0e3589c5cc9ad5f8504ea06f028be582a"}, + {file = "regex-2026.9.29-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:80c7cadd3fd2bfde5df8aa0787e315812cad0c313a753095d02f4c2b6c01677b"}, + {file = "regex-2026.9.29-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3f1e6cb402a89457582cd696f982559217d13484a193202c394015297968c86d"}, + {file = "regex-2026.9.29-cp315-cp315-win32.whl", hash = "sha256:a64b85a4760337cfefdb27d42da6ed8b58e8cde3f2d57b6ef43e76ef6ea9ef47"}, + {file = "regex-2026.9.29-cp315-cp315-win_amd64.whl", hash = "sha256:b3e445b66c80b4eb4234e855ce94d9adc183eedbd632816228d89930b91b2c5b"}, + {file = "regex-2026.9.29-cp315-cp315-win_arm64.whl", hash = "sha256:8f39588af4731c8923c26810eb3b33f76f17633985e40f59c3cd45a33805a895"}, + {file = "regex-2026.9.29-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:fb99cc9d45f48895d9d67f6a0b8a57f08d39c174d9f25ad97a313e0470267b1c"}, + {file = "regex-2026.9.29-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:720537c7ea6f80dc61913184edb0ce2497a306b39ef19f28505b322553d52bdb"}, + {file = "regex-2026.9.29-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0fd2c901cc307a745ad4bc87f20060d7a0825a3371d1e93488af22e7a387f78f"}, + {file = "regex-2026.9.29-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b11b589e00095ec69cf79841a76360f9b079e95b0368a25b5ebb951ab0c157ff"}, + {file = "regex-2026.9.29-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d7cab119d0df0b9413f106b4d7fc34f2872d3574ed3806fb48959c830b1537da"}, + {file = "regex-2026.9.29-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b89efc38431793d28b7cd91227e2f952ad7c48df19132b17f43a5fec3c14143b"}, + {file = "regex-2026.9.29-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80a5ea3b4fd9d6a5b9a44f7976a9acaaab35aa3c1f6b29e5bd857dfabaded223"}, + {file = "regex-2026.9.29-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:19959129885356df0e97556856f77eb2888380dac18bed075a7c05c5128c618d"}, + {file = "regex-2026.9.29-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:6a1a824fbed817e0a891103886b68f063b1e83cc51bc97192a90a60195a9291f"}, + {file = "regex-2026.9.29-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1ba8c6a416569ce0d37e83e28a254a61dc99a419084dfb6476cea02d997f74fa"}, + {file = "regex-2026.9.29-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:446654b29bfaa30500d80947eda42cef1449dc8a87f4e3cf061cc8485d3a1f0b"}, + {file = "regex-2026.9.29-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bf3c49863c23a1ad6da9c30351aed6cff8d5ddbeb63c5c8420ae54e98c7d0138"}, + {file = "regex-2026.9.29-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:01000ddf0e3ffef97f2413ceb514f6313040106b6d18a03ee00a4fe35c1eb1db"}, + {file = "regex-2026.9.29-cp315-cp315t-win32.whl", hash = "sha256:c4e38dd8f39c43a91d2410ad2b85610701b0979342c3df1d69eaf8e838c757d8"}, + {file = "regex-2026.9.29-cp315-cp315t-win_amd64.whl", hash = "sha256:e2c89e9b762c57f59d5e99ee8b20202adb892e35f8d3485741340999ca55058e"}, + {file = "regex-2026.9.29-cp315-cp315t-win_arm64.whl", hash = "sha256:e8c65ef3862a8ad6e86492b6ed9327805dd66904c012bd3649dc67d822ed6c34"}, + {file = "regex-2026.9.29.tar.gz", hash = "sha256:8b5fcc4771732191b2b7d1dd68d8f0353f47f8d90b6150f6dce58bf1112442cb"}, +] + +[[package]] +name = "requests" +version = "2.34.2" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, + {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, +] + +[package.dependencies] +certifi = ">=2023.5.7" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.26,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] + +[[package]] +name = "setuptools" +version = "84.2.1" +description = "Most extensible Python build backend with support for C/C++ extension modules" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "setuptools-84.2.1-py3-none-any.whl", hash = "sha256:ca140b45d5325a9478f880e351484f62fef15f336462e449ca0c03c873d589c2"}, + {file = "setuptools-84.2.1.tar.gz", hash = "sha256:3ff8b3b5492a0aa66960f4419365a86b84e013070d4313e4d4a6894f5d64d6bc"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.13.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=26.3)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=3.4)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=26.3)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.18.*)", "pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] + +[[package]] +name = "sympy" +version = "1.14.0" +description = "Computer algebra system (CAS) in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5"}, + {file = "sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517"}, +] + +[package.dependencies] +mpmath = ">=1.1.0,<1.4" + +[package.extras] +dev = ["hypothesis (>=6.70.0)", "pytest (>=7.1.0)"] + +[[package]] +name = "tiktoken" +version = "0.14.0" +description = "tiktoken is a fast BPE tokeniser for use with OpenAI's models" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tiktoken-0.14.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3b12e54f8bec91433e41aff65d8d1f209a4f678081163747079806e5361f6c91"}, + {file = "tiktoken-0.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:94f77b60a8ab23580db19ae822744c9716c1720020d2179ca5605112d12326f1"}, + {file = "tiktoken-0.14.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:f3d6cf93fbe2e7117eb7bedca684216fbe328a41f0843ce34245451d8eb2df1c"}, + {file = "tiktoken-0.14.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:18a1b651c4b032004bf7b4f1713391a54b2a341a52c6e8a2b59acae9d16e13c7"}, + {file = "tiktoken-0.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4d8d91d68353bd167fdf26467e5ff9e56aaa5f87d6410c0238608629e4dc0d33"}, + {file = "tiktoken-0.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10f31e63e40313f2e518d87f7086cfa44e45f64cc14d8ae14103b41220c30a14"}, + {file = "tiktoken-0.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:c6cb9896a82b9ee44e15ba0b5c8044072f2e4d48acaa704c8d3feeef5ad9487c"}, + {file = "tiktoken-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c2edf09b381fafbc014ae8e018ed25087abb9a3dafa8465a0ea63c6558c47a79"}, + {file = "tiktoken-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cd8ca1305c1c902fe42c486165f2e4808d9997625c98ffb05b9e0366d99d3948"}, + {file = "tiktoken-0.14.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:1f83081065ee5833d35b49e9180f3d8d15622a603dd1c435da0da6cc12b3662f"}, + {file = "tiktoken-0.14.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f5e7665f6624e052e5e7f6a36919ab69279decdc976d7b16b4fa15e1897d0513"}, + {file = "tiktoken-0.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:144a3fc369f92b7d548995217c5d6e84038d3572157a0f6f34080d65291d0f78"}, + {file = "tiktoken-0.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:151d37a150c8f3dfc5f4345597b10e101876bd1bd13494e0185af6b508758d2e"}, + {file = "tiktoken-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:c77d4a3e1deb2707819df92046b89aad1ac81d27e07616b797cbff3f62c037da"}, + {file = "tiktoken-0.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8e947aefe98ef74cce94923f90e48c98fe34eb1ec0a6bfdfadfc5a96359bfc36"}, + {file = "tiktoken-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d6cebe67765569df3dafac8474e4eccf5c19d24140492567a5e58a11445732a4"}, + {file = "tiktoken-0.14.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:7db45b98e94adf4173a5cd7422b150999a7ee11ff847783a14f6e1b80cc38cb6"}, + {file = "tiktoken-0.14.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7896eea257fe497a2b7134474d909156c6744ce8da35bce88011a960e008aa0d"}, + {file = "tiktoken-0.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b950248272f1b303dc32986396e2dccfa10cf6d1e83ec8f0bba1776660305482"}, + {file = "tiktoken-0.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3de75343041a1c57333b1e707ac8a9769738241d7d6a55d39e12cf84548337c6"}, + {file = "tiktoken-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:087538c080e5ff421abd3a0785ed63c5111d06af98e6cd0d374dbe5969147ca3"}, + {file = "tiktoken-0.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9c5fe393aab56469f04e432ff851216d3def3436cf5f07e442a240164bf500f"}, + {file = "tiktoken-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbe2cc3bba939bcdaf103e03df9d5039d33887080b315624be28ec69059e5f94"}, + {file = "tiktoken-0.14.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2157f52e4b4d7ac5ecc7457b3716834706e7ef9a46f5144029bfeb7cf71f4e06"}, + {file = "tiktoken-0.14.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:26e60f6a956ee171ab728b37b8439905d7ea1db435c30f9822f291e9861c861d"}, + {file = "tiktoken-0.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:380873f330b741c4435574f37edb20813d04603ace2d53e0a63560e1fec83010"}, + {file = "tiktoken-0.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3fd7c14b1cb45b486c39fc9b3443bb341f3e2fc7e6f31247f3435a5836651632"}, + {file = "tiktoken-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:90a762670c7f968184723769a06ed51f5cf5ce5dcd1e30164f25c72d85c2d1f1"}, + {file = "tiktoken-0.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e067f4cbcc5d036e8aff7fe7a6b530a8f4de2e4616ad9005a24a1879e24e6450"}, + {file = "tiktoken-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f2af4a336ea56d6c14f27741a0e1d8294a35dd0b038bcf990d232ebb54eb994b"}, + {file = "tiktoken-0.14.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:f702e0aeeb6506e57687e881c59e844ebe8f0a6a097ddafe20e3ab25f387be4e"}, + {file = "tiktoken-0.14.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e3442bbb2f0c588cec876061e37ae67b455b9df9978b003c8fe30e45f2ef5b42"}, + {file = "tiktoken-0.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:979c1524f753b662b0f3cd261b135afe6659cce33caaa7a5ea00dd1756b3055c"}, + {file = "tiktoken-0.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2cc19ac87b41c9493c9778ff5847f0c8bbcf5bd0ec6b87ce06c1c802adc8a771"}, + {file = "tiktoken-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:eceeff0c62419bc78d4b6e70a4762a4d25df3ae8f2d5946e3853ce93e7a57098"}, + {file = "tiktoken-0.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6eb94895c45f26bb8f5546e5fd8a069efcf6e3f108ea9d5cbe3bf6f7f3983438"}, + {file = "tiktoken-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:86951a971c53979ec857bd8c4a32dc227ab0fd33f6c12a3bd62d3fbf5f0bfcaa"}, + {file = "tiktoken-0.14.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:e2eca764c53490f8930dbce329e0769f11108d87d908282a80c5c130e26e7037"}, + {file = "tiktoken-0.14.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:26cc4b4840fa0e9f4b72ed489883e12f57e00d1021ca794720e3c29a12f0edef"}, + {file = "tiktoken-0.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2fc834fbe3f6a0736905c36ab709537e6840dbd63b982dc9e0216ae7d305ba1a"}, + {file = "tiktoken-0.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ca4db6ff5c5bf600f9b7761a0070ed44dfe5797a76bd432fb978bc480ef40c58"}, + {file = "tiktoken-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7aab286a020660a039097912a088236b985d18a3090d73f136c4413d29d37ca0"}, + {file = "tiktoken-0.14.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:14b47e3674f2624803a8acc8fb367b7e24fc53055f9df3296482fe9a3a34a232"}, + {file = "tiktoken-0.14.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:19d643d701fdaa70e5b9c7f8f96abcaffe77ca5e482a3a1a7dde46feb4284695"}, + {file = "tiktoken-0.14.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:e4ddf863b59347deaa92302dcd90e5eb003cdc9be06ec2b692c38d1bdd9efd49"}, + {file = "tiktoken-0.14.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:60c47ca69ddda0dea8256fffd12e1b86f4b59734a20e4a70c61f63cc5f021df4"}, + {file = "tiktoken-0.14.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:728303a072163130c5b477b1f20d6211895569c1d5302c24ffc93a3009160871"}, + {file = "tiktoken-0.14.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3c5349c9f916283bba32bec8af69b763e4faa304dc004d0eaaea66a3cf004c1f"}, + {file = "tiktoken-0.14.0-cp315-cp315-win_amd64.whl", hash = "sha256:1b6e4adcfd285c44502aed51df98aaaca4f0fea028165dbf8a9e857b9f98d8ea"}, + {file = "tiktoken-0.14.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:11d8211b290855d2721334ff17dd9b3a17bfb26872be01f25d73612ef7ece890"}, + {file = "tiktoken-0.14.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d0781223705199b289faa59601bb9c2441712d4c600dd13c43d8fd6a33d22cd5"}, + {file = "tiktoken-0.14.0-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:2ea70afba6b9eddbf22c165142e5f0a2ad7aa36a452873c48b57bb2aeb8492ae"}, + {file = "tiktoken-0.14.0-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:78571efc311c30b73f31eb949a921d6dac39a5d9dc42d1cfa8f8db157b3447b1"}, + {file = "tiktoken-0.14.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86f66c85e796f5d05d5c4a60ec1d40cbfebc47a32464053528c797163fa9ab89"}, + {file = "tiktoken-0.14.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:149d97453c4c98c04b081d64a85e635921269b532710d6faf81e9e82b790e7d3"}, + {file = "tiktoken-0.14.0-cp315-cp315t-win_amd64.whl", hash = "sha256:561e7580f84a79859af1ef6f676968e9030fcc3fe195700b15235bca64f009c9"}, + {file = "tiktoken-0.14.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:2ec16eb585332c55d022d86354e209ddf27326b1ea3477585ab248e7776d3b1f"}, + {file = "tiktoken-0.14.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:aa428a559d5fd02ae619aacaace86c7474a1f2702d2c01fc828908dd60f20f7a"}, + {file = "tiktoken-0.14.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:7b7acbb7a4b8383707bce22ad3c162006478c27b56368acd3e1fcb1658a80425"}, + {file = "tiktoken-0.14.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:c3093001ddce822b4587e6e94bf6de36a5f97b3f31de1c9fc8d4fda144c59ff4"}, + {file = "tiktoken-0.14.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a140e83317fef02faeeb78d9a8efac623887f2feaf0055c55dcdb2b17f0226ad"}, + {file = "tiktoken-0.14.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:50a7e5646cbac2a8f7c3e8c0934ffda1a4357ee9c44b652434b23c3ed54d0900"}, + {file = "tiktoken-0.14.0-cp39-cp39-win_amd64.whl", hash = "sha256:447ada49af4898b5e992f0b5799d2f3af385921102c211947ce3fe960dd919da"}, + {file = "tiktoken-0.14.0.tar.gz", hash = "sha256:231dec90efcdccf1b565a1416107736f1e09b1a08fe736ef9d6363e626d03874"}, +] + +[package.dependencies] +regex = "*" +requests = "*" + +[package.extras] +blobfile = ["blobfile (>=3)"] + +[[package]] +name = "torch" +version = "2.7.1" +description = "Tensors and Dynamic neural networks in Python with strong GPU acceleration" +optional = false +python-versions = ">=3.9.0" +groups = ["main"] +files = [ + {file = "torch-2.7.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:a103b5d782af5bd119b81dbcc7ffc6fa09904c423ff8db397a1e6ea8fd71508f"}, + {file = "torch-2.7.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:fe955951bdf32d182ee8ead6c3186ad54781492bf03d547d31771a01b3d6fb7d"}, + {file = "torch-2.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:885453d6fba67d9991132143bf7fa06b79b24352f4506fd4d10b309f53454162"}, + {file = "torch-2.7.1-cp310-none-macosx_11_0_arm64.whl", hash = "sha256:d72acfdb86cee2a32c0ce0101606f3758f0d8bb5f8f31e7920dc2809e963aa7c"}, + {file = "torch-2.7.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:236f501f2e383f1cb861337bdf057712182f910f10aeaf509065d54d339e49b2"}, + {file = "torch-2.7.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:06eea61f859436622e78dd0cdd51dbc8f8c6d76917a9cf0555a333f9eac31ec1"}, + {file = "torch-2.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:8273145a2e0a3c6f9fd2ac36762d6ee89c26d430e612b95a99885df083b04e52"}, + {file = "torch-2.7.1-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:aea4fc1bf433d12843eb2c6b2204861f43d8364597697074c8d38ae2507f8730"}, + {file = "torch-2.7.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:27ea1e518df4c9de73af7e8a720770f3628e7f667280bce2be7a16292697e3fa"}, + {file = "torch-2.7.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c33360cfc2edd976c2633b3b66c769bdcbbf0e0b6550606d188431c81e7dd1fc"}, + {file = "torch-2.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:d8bf6e1856ddd1807e79dc57e54d3335f2b62e6f316ed13ed3ecfe1fc1df3d8b"}, + {file = "torch-2.7.1-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:787687087412c4bd68d315e39bc1223f08aae1d16a9e9771d95eabbb04ae98fb"}, + {file = "torch-2.7.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:03563603d931e70722dce0e11999d53aa80a375a3d78e6b39b9f6805ea0a8d28"}, + {file = "torch-2.7.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:d632f5417b6980f61404a125b999ca6ebd0b8b4bbdbb5fbbba44374ab619a412"}, + {file = "torch-2.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:23660443e13995ee93e3d844786701ea4ca69f337027b05182f5ba053ce43b38"}, + {file = "torch-2.7.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:0da4f4dba9f65d0d203794e619fe7ca3247a55ffdcbd17ae8fb83c8b2dc9b585"}, + {file = "torch-2.7.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e08d7e6f21a617fe38eeb46dd2213ded43f27c072e9165dc27300c9ef9570934"}, + {file = "torch-2.7.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:30207f672328a42df4f2174b8f426f354b2baa0b7cca3a0adb3d6ab5daf00dc8"}, + {file = "torch-2.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:79042feca1c634aaf6603fe6feea8c6b30dfa140a6bbc0b973e2260c7e79a22e"}, + {file = "torch-2.7.1-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:988b0cbc4333618a1056d2ebad9eb10089637b659eb645434d0809d8d937b946"}, + {file = "torch-2.7.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:e0d81e9a12764b6f3879a866607c8ae93113cbcad57ce01ebde63eb48a576369"}, + {file = "torch-2.7.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:8394833c44484547ed4a47162318337b88c97acdb3273d85ea06e03ffff44998"}, + {file = "torch-2.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:df41989d9300e6e3c19ec9f56f856187a6ef060c3662fe54f4b6baf1fc90bd19"}, + {file = "torch-2.7.1-cp39-none-macosx_11_0_arm64.whl", hash = "sha256:a737b5edd1c44a5c1ece2e9f3d00df9d1b3fb9541138bee56d83d38293fb6c9d"}, +] + +[package.dependencies] +filelock = "*" +fsspec = "*" +jinja2 = "*" +networkx = "*" +nvidia-cublas-cu12 = {version = "12.6.4.1", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-cuda-cupti-cu12 = {version = "12.6.80", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-cuda-nvrtc-cu12 = {version = "12.6.77", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-cuda-runtime-cu12 = {version = "12.6.77", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-cudnn-cu12 = {version = "9.5.1.17", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-cufft-cu12 = {version = "11.3.0.4", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-cufile-cu12 = {version = "1.11.1.6", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-curand-cu12 = {version = "10.3.7.77", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-cusolver-cu12 = {version = "11.7.1.2", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-cusparse-cu12 = {version = "12.5.4.2", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-cusparselt-cu12 = {version = "0.6.3", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-nccl-cu12 = {version = "2.26.2", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-nvjitlink-cu12 = {version = "12.6.85", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +nvidia-nvtx-cu12 = {version = "12.6.77", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +setuptools = {version = "*", markers = "python_version >= \"3.12\""} +sympy = ">=1.13.3" +triton = {version = "3.3.1", markers = "platform_system == \"Linux\" and platform_machine == \"x86_64\""} +typing-extensions = ">=4.10.0" + +[package.extras] +opt-einsum = ["opt-einsum (>=3.3)"] +optree = ["optree (>=0.13.0)"] + +[[package]] +name = "tqdm" +version = "4.70.1" +description = "Fast, Extensible Progress Meter" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "tqdm-4.70.1-py3-none-any.whl", hash = "sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73"}, + {file = "tqdm-4.70.1.tar.gz", hash = "sha256:cefd0eca11b2a37a3aee776544d4f4ae913f02688135b5556b8788dfa474afc4"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +discord = ["envwrap", "requests"] +notebook = ["ipywidgets (>=6)"] +slack = ["envwrap", "slack-sdk"] +telegram = ["envwrap", "requests"] + +[[package]] +name = "triton" +version = "3.3.1" +description = "A language and compiler for custom Deep Learning operations" +optional = false +python-versions = "*" +groups = ["main"] +markers = "(platform_machine == \"x86_64\" or sys_platform == \"linux2\") and (platform_system == \"Linux\" or sys_platform == \"linux\" or sys_platform == \"linux2\")" +files = [ + {file = "triton-3.3.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b74db445b1c562844d3cfad6e9679c72e93fdfb1a90a24052b03bb5c49d1242e"}, + {file = "triton-3.3.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b31e3aa26f8cb3cc5bf4e187bf737cbacf17311e1112b781d4a059353dfd731b"}, + {file = "triton-3.3.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9999e83aba21e1a78c1f36f21bce621b77bcaa530277a50484a7cb4a822f6e43"}, + {file = "triton-3.3.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b89d846b5a4198317fec27a5d3a609ea96b6d557ff44b56c23176546023c4240"}, + {file = "triton-3.3.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3198adb9d78b77818a5388bff89fa72ff36f9da0bc689db2f0a651a67ce6a42"}, + {file = "triton-3.3.1-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f6139aeb04a146b0b8e0fbbd89ad1e65861c57cfed881f21d62d3cb94a36bab7"}, +] + +[package.dependencies] +setuptools = ">=40.8.0" + +[package.extras] +build = ["cmake (>=3.20)", "lit"] +tests = ["autopep8", "isort", "llnl-hatchet", "numpy", "pytest", "pytest-forked", "pytest-xdist", "scipy (>=1.7.1)"] +tutorials = ["matplotlib", "pandas", "tabulate"] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, + {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3"}, + {file = "urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63"}, +] + +[package.extras] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.12" +content-hash = "06e845e25c8adbd8251e55449f3b5a0550314f3c8dfd969723ea371469ae20c7" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..82389f7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "autoshorts" +version = "0.1.0" +description = "" +authors = [ + {name = "Leonardo Bezerra",email = "leosoares.bezerra2017@gmail.com"} +] +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "openai-whisper (>=20250625,<20250626)" +] + +[tool.poetry] +package-mode = false + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" + diff --git a/scripts/build-all.mjs b/scripts/build-all.mjs new file mode 100644 index 0000000..f242023 --- /dev/null +++ b/scripts/build-all.mjs @@ -0,0 +1,382 @@ +#!/usr/bin/env node +/** + * Build AutoShorts for every platform the current host is able to target. + * + * npm run build:all # tenta todas as plataformas disponíveis + * npm run build:all -- --only=linux,macos + * npm run build:all -- --skip=windows + * npm run build:all -- --dry-run # mostra os comandos sem executar + * + * Regras reais de plataforma: + * - Linux : nativo (deb/rpm/AppImage) + * - Windows : nativo no Windows; a partir do WSL usa o toolchain do Windows + * (cargo.exe + node.exe + MSVC Build Tools) num diretório de staging + * - macOS : SOMENTE macOS + Xcode (impossível a partir de Linux/WSL) + * + * Artefatos finais são copiados para `artifacts//`. + * O script continua nas demais plataformas quando uma falha ou é pulada. + */ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const ARTIFACTS = path.join(ROOT, 'artifacts'); +const IS_WIN = process.platform === 'win32'; +const IS_MAC = process.platform === 'darwin'; +const IS_LINUX = process.platform === 'linux'; +const IS_WSL = + IS_LINUX && + (Boolean(process.env.WSL_DISTRO_NAME) || + fs.existsSync('/proc/sys/fs/binfmt_misc/WSLInterop') || + /microsoft/i.test(fs.existsSync('/proc/version') ? fs.readFileSync('/proc/version', 'utf8') : '')); + +const argv = process.argv.slice(2); +const listArg = (flag) => { + const hit = argv.find((a) => a === flag || a.startsWith(`${flag}=`)); + if (!hit) return null; + const value = hit.includes('=') ? hit.slice(hit.indexOf('=') + 1) : ''; + return value + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); +}; +const ONLY = listArg('--only'); +const SKIP = listArg('--skip') ?? []; +const DRY_RUN = argv.includes('--dry-run'); + +/* ------------------------------------------------------------------ helpers */ + +const c = { + dim: (s) => `\x1b[2m${s}\x1b[0m`, + bold: (s) => `\x1b[1m${s}\x1b[0m`, + green: (s) => `\x1b[32m${s}\x1b[0m`, + yellow: (s) => `\x1b[33m${s}\x1b[0m`, + red: (s) => `\x1b[31m${s}\x1b[0m`, + cyan: (s) => `\x1b[36m${s}\x1b[0m`, +}; + +function log(msg = '') { + console.log(msg); +} + +function step(msg) { + log(`\n${c.cyan('▶')} ${c.bold(msg)}`); +} + +function ok(msg) { + log(` ${c.green('✔')} ${msg}`); +} + +function warn(msg) { + log(` ${c.yellow('!')} ${msg}`); +} + +function fail(msg) { + log(` ${c.red('✖')} ${msg}`); +} + +function info(msg) { + log(` ${c.dim(msg)}`); +} + +/** Roda um comando herdando stdout/stderr. */ +function run(cmd, args, opts = {}) { + const pretty = [cmd, ...args].join(' '); + if (DRY_RUN) { + info(`[dry-run] ${pretty}`); + return { status: 0 }; + } + info(`$ ${pretty}`); + const res = spawnSync(cmd, args, { + stdio: 'inherit', + cwd: opts.cwd ?? ROOT, + env: { ...process.env, ...opts.env }, + shell: false, + }); + return { status: res.status ?? 1 }; +} + +/** Roda um comando capturando stdout (retorna null em falha). */ +function capture(cmd, args, opts = {}) { + const res = spawnSync(cmd, args, { + encoding: 'utf8', + cwd: opts.cwd ?? ROOT, + env: { ...process.env, ...opts.env }, + }); + if (res.status !== 0) return null; + return (res.stdout ?? '').trim(); +} + +function which(cmd) { + return IS_WIN ? capture('where', [cmd]) : capture('which', [cmd]); +} + +function firstExisting(paths) { + return paths.find((p) => p && fs.existsSync(p)) ?? null; +} + +function unixPath(winPath) { + if (!winPath) return null; + const converted = capture('wslpath', ['-u', winPath]); + return converted || null; +} + +/** Copia apenas os instaláveis relevantes para artifacts/. */ +function collect(id, sources, extensions) { + if (DRY_RUN) { + info(`[dry-run] copiaria bundles para artifacts/${id}/`); + return; + } + const exts = extensions.map((e) => e.toLowerCase()); + const dest = path.join(ARTIFACTS, id); + fs.rmSync(dest, { recursive: true, force: true }); + fs.mkdirSync(dest, { recursive: true }); + + let copied = 0; + const walk = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + continue; + } + if (!exts.some((e) => entry.name.toLowerCase().endsWith(e))) continue; + fs.cpSync(full, path.join(dest, entry.name)); + copied += 1; + } + }; + + for (const src of sources.filter((s) => s && fs.existsSync(s))) walk(src); + if (copied === 0) { + warn(`nenhum bundle (${exts.join(', ')}) encontrado para ${id}`); + return; + } + ok(`${copied} artefato(s) copiado(s) para ${path.relative(ROOT, dest)}/`); +} + +/* --------------------------------------------------------- environment data */ + +const linuxDeps = () => { + if (!which('pkg-config')) return ['pkg-config']; + const probe = (lib) => spawnSync('pkg-config', ['--exists', lib]).status === 0; + return ['webkit2gtk-4.1', 'gtk+-3.0', 'libsoup-3.0'].filter((lib) => !probe(lib)); +}; + +const winToolchain = (() => { + if (!IS_WSL) return null; + const profile = unixPath(capture('cmd.exe', ['/c', 'echo %USERPROFILE%'])); + if (!profile) return null; + const cargoExe = firstExisting([path.join(profile, '.cargo/bin/cargo.exe')]); + const nodeDir = firstExisting([ + '/mnt/c/Program Files/nodejs', + path.join(profile, 'AppData/Roaming/npm'), + ]); + const msBuildTools = firstExisting([ + '/mnt/c/Program Files/Microsoft Visual Studio/2022/BuildTools', + '/mnt/c/Program Files (x86)/Microsoft Visual Studio/2022/BuildTools', + '/mnt/c/Program Files/Microsoft Visual Studio/2022/Community', + '/mnt/c/Program Files (x86)/Microsoft Visual Studio/2022/Community', + ]); + return { + profile, + cargoExe, + nodeDir: nodeDir && fs.existsSync(path.join(nodeDir, 'npm.cmd')) ? nodeDir : null, + msBuildTools, + staging: path.join(profile, 'autoshorts-build'), + }; +})(); + +/* ------------------------------------------------------------ build targets */ + +const targets = [ + { + id: 'linux', + label: 'Linux (.deb / .rpm / .AppImage)', + reason() { + if (!IS_LINUX) return 'host não é Linux — rode em uma máquina Linux ou via CI'; + const missing = linuxDeps(); + if (missing.length) return `dependências ausentes: ${missing.join(', ')}`; + return null; + }, + build() { + const bundles = process.env.LINUX_BUNDLES ?? 'deb,rpm,appimage'; + const res = run('npx', ['tauri', 'build', '--bundles', bundles]); + if (res.status !== 0) throw new Error('`tauri build` falhou'); + collect('linux', [path.join(ROOT, 'src-tauri/target/release/bundle')], [ + '.deb', + '.rpm', + '.AppImage', + ]); + }, + }, + { + id: 'windows', + label: 'Windows (.msi / .exe NSIS)', + reason() { + if (IS_WIN) return null; + if (!IS_WSL) return 'host não é Windows/WSL — rode no Windows ou via CI'; + if (!winToolchain?.cargoExe) return 'cargo.exe do Windows não encontrado (rustup no Windows)'; + if (!winToolchain?.nodeDir) return 'Node.js do Windows não encontrado (node.exe/npm.cmd)'; + if (!winToolchain?.msBuildTools) return 'Visual Studio Build Tools (MSVC) não encontrado'; + return null; + }, + build() { + const bundles = process.env.WINDOWS_BUNDLES ?? ''; + const bundleArgs = bundles ? ['--bundles', bundles] : []; + if (IS_WIN) { + const res = run('npx', ['tauri', 'build', ...bundleArgs]); + if (res.status !== 0) throw new Error('`tauri build` falhou'); + collect('windows', [path.join(ROOT, 'src-tauri/target/release/bundle')], [ + '.msi', + '.exe', + ]); + return; + } + + // WSL -> toolchain nativa do Windows. cmd.exe não aceita caminhos UNC, + // então o código é espelhado em /mnt/c e compilado do lado Windows. + const { staging, profile, nodeDir } = winToolchain; + step(`Windows: espelhando fonte para ${staging}`); + fs.mkdirSync(staging, { recursive: true }); + const rsync = run('rsync', [ + '-a', + '--delete', + '--exclude', 'node_modules', + '--exclude', 'target', + '--exclude', 'dist', + '--exclude', 'artifacts', + '--exclude', '.git', + `${ROOT}/`, + `${staging}/`, + ]); + if (rsync.status !== 0) throw new Error('rsync para o staging do Windows falhou'); + + const stagingWin = capture('wslpath', ['-w', staging]); + const npmCmd = capture('wslpath', ['-w', path.join(nodeDir, 'npm.cmd')]); + const nodeWinDir = capture('wslpath', ['-w', nodeDir]); + const cargoBinWin = capture('wslpath', ['-w', path.join(profile, '.cargo/bin')]); + if (!stagingWin || !npmCmd) throw new Error('não foi possível converter caminhos para Windows'); + + // cmd.exe não roda com cwd UNC (caminho \\wsl.localhost\...), então o build + // é disparado por um .cmd gravado no próprio staging do Windows. + // O staging guarda o próprio node_modules (o rsync o exclui). Ele fica + // desatualizado quando package-lock.json muda, então reinstalamos sempre + // que o lockfile for mais novo que a instalação registrada pelo npm. + const stagingLock = path.join(staging, 'package-lock.json'); + const stagingInstallMark = path.join(staging, 'node_modules/.package-lock.json'); + const stagingTauriCli = path.join(staging, 'node_modules/.bin/tauri.cmd'); + const needsInstall = + !fs.existsSync(stagingTauriCli) || + !fs.existsSync(stagingInstallMark) || + (fs.existsSync(stagingLock) && + fs.statSync(stagingLock).mtimeMs > fs.statSync(stagingInstallMark).mtimeMs); + info( + needsInstall + ? 'dependências do staging desatualizadas — rodando `npm ci` no Windows' + : 'dependências do staging atualizadas', + ); + + const scriptPath = path.join(staging, 'build-autoshorts.cmd'); + const scriptWin = path.join(stagingWin, 'build-autoshorts.cmd'); + const bundleFlag = bundles ? ` -- --bundles ${bundles}` : ''; + const installLines = needsInstall + ? [ + 'echo [build] instalando dependencias no Windows...', + 'call npm ci --no-audit --no-fund', + 'if errorlevel 1 exit /b 1', + ] + : []; + fs.writeFileSync( + scriptPath, + [ + '@echo off', + 'setlocal', + 'cd /d "%~dp0"', + `set "PATH=${[nodeWinDir, cargoBinWin].filter(Boolean).join(';')};%PATH%"`, + ...installLines, + `call npm run tauri:build${bundleFlag}`, + 'exit /b %errorlevel%', + '', + ].join('\r\n'), + ); + + const res = run('cmd.exe', ['/c', scriptWin], { cwd: staging }); + if (res.status !== 0) throw new Error('build no lado Windows falhou'); + collect('windows', [path.join(staging, 'src-tauri/target/release/bundle')], [ + '.msi', + '.exe', + ]); + }, + }, + { + id: 'macos', + label: 'macOS (.dmg / .app)', + reason() { + if (!IS_MAC) return 'macOS exige macOS + Xcode — impossível compilar a partir de Linux/WSL'; + if (!which('xcodebuild')) return 'Xcode não instalado'; + return null; + }, + build() { + const bundles = process.env.MACOS_BUNDLES ?? 'app,dmg'; + const res = run('npx', ['tauri', 'build', '--bundles', bundles]); + if (res.status !== 0) throw new Error('`tauri build` falhou'); + collect('macos', [path.join(ROOT, 'src-tauri/target/release/bundle')], ['.dmg', '.app']); + }, + }, +]; + +/* ---------------------------------------------------------------- execution */ + +const selected = targets.filter( + (t) => (!ONLY || ONLY.includes(t.id)) && !SKIP.includes(t.id), +); + +if (selected.length === 0) { + fail('nenhuma plataforma selecionada (verifique --only/--skip)'); + process.exit(1); +} + +log(c.bold(`AutoShorts — build multiplataforma (${selected.length} alvo(s))`)); +info(`host: ${process.platform}${IS_WSL ? ' (WSL)' : ''} · raiz: ${ROOT}`); +if (DRY_RUN) warn('modo dry-run: nenhum comando será executado'); + +const results = []; + +for (const target of selected) { + step(target.label); + const blocked = target.reason(); + if (blocked) { + warn(`pulado: ${blocked}`); + results.push({ id: target.id, status: 'skipped', detail: blocked }); + continue; + } + const started = Date.now(); + try { + target.build(); + const secs = Math.round((Date.now() - started) / 1000); + ok(`concluído em ${secs}s`); + results.push({ id: target.id, status: 'built', detail: `${secs}s` }); + } catch (err) { + fail(err.message); + results.push({ id: target.id, status: 'failed', detail: err.message }); + } +} + +step('Resumo'); +const icon = { built: c.green('✔'), skipped: c.yellow('!'), failed: c.red('✖') }; +for (const r of results) { + const label = targets.find((t) => t.id === r.id).label; + log(` ${icon[r.status]} ${r.id.padEnd(8)} ${r.status.padEnd(8)} ${c.dim(label)} — ${r.detail}`); +} +const built = results.filter((r) => r.status === 'built').map((r) => r.id); +if (built.length) { + log(`\n ${c.green('Artefatos:')} ${path.relative(ROOT, ARTIFACTS)}/{${built.join(',')}}`); +} +const hardFailures = results.filter((r) => r.status === 'failed'); +if (hardFailures.length) { + log(''); + fail(`${hardFailures.length} build(s) falharam`); + process.exit(1); +} diff --git a/scripts/test.mjs b/scripts/test.mjs new file mode 100644 index 0000000..9f4c309 --- /dev/null +++ b/scripts/test.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +/** + * Wrapper de `vitest` para os scripts do package.json. + * + * npm test # roda a suíte uma vez (vitest run) + * npm test -- format # roda apenas arquivos que casam com "format" + * npm run test watch # modo watch (equivalente a `npm run test:watch`) + * npm run test:watch # modo watch + * + * Motivo de existir: `npm run test watch` entrega `watch` como argumento + * posicional, e o Vitest interpreta posicionais como filtro de nome de + * arquivo. O resultado era `No test files found, exiting with code 1` mesmo + * com a suíte toda verde. Aqui um `watch` isolado vira o modo watch; os + * demais argumentos continuam sendo filtros. + */ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const VITEST = path.join(ROOT, 'node_modules', 'vitest', 'vitest.mjs'); + +if (!fs.existsSync(VITEST)) { + console.error('Vitest não encontrado. Rode `npm install` antes de `npm test`.'); + process.exit(1); +} + +const raw = process.argv.slice(2); +const watch = raw.includes('watch'); +const args = raw.filter((arg) => arg !== 'watch'); + +const result = spawnSync( + process.execPath, + [VITEST, ...(watch ? [] : ['run']), ...args], + { cwd: ROOT, stdio: 'inherit' }, +); + +if (result.error) { + console.error(result.error.message); + process.exit(1); +} + +process.exit(result.status ?? 1); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f119b11..2f9a304 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1385,6 +1385,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2149c4b4b83390c71fab669730347d578ec6bdf759f2160c581ad8b8819733b4" + [[package]] name = "httparse" version = "1.10.1" @@ -3532,6 +3538,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "http-range", "jni", "libc", "log", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ac15ae0..35a546f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,10 +25,10 @@ reqwest = { version = "0.12", features = ["json", "multipart", "rustls-tls"] } rusqlite = { version = "0.32", features = ["bundled"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -tauri = { version = "2.2.5", features = [] } +tauri = { version = "2.2.5", features = ["protocol-asset"] } tauri-plugin-dialog = "2.2.0" thiserror = "2.0" -tokio = { version = "1.43", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1.43", features = ["macros", "rt-multi-thread", "time"] } uuid = { version = "1.12", features = ["v4", "serde"] } [features] diff --git a/src-tauri/gen/schemas/linux-schema.json b/src-tauri/gen/schemas/linux-schema.json new file mode 100644 index 0000000..24c9001 --- /dev/null +++ b/src-tauri/gen/schemas/linux-schema.json @@ -0,0 +1,2358 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CapabilityFile", + "description": "Capability formats accepted in a capability file.", + "anyOf": [ + { + "description": "A single capability.", + "allOf": [ + { + "$ref": "#/definitions/Capability" + } + ] + }, + { + "description": "A list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + }, + { + "description": "A list of capabilities.", + "type": "object", + "required": [ + "capabilities" + ], + "properties": { + "capabilities": { + "description": "The list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + } + } + } + ], + "definitions": { + "Capability": { + "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", + "type": "object", + "required": [ + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", + "type": "string" + }, + "description": { + "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.", + "default": "", + "type": "string" + }, + "remote": { + "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", + "anyOf": [ + { + "$ref": "#/definitions/CapabilityRemote" + }, + { + "type": "null" + } + ] + }, + "local": { + "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", + "default": true, + "type": "boolean" + }, + "windows": { + "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "webviews": { + "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionEntry" + }, + "uniqueItems": true + }, + "platforms": { + "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "CapabilityRemote": { + "description": "Configuration for remote URLs that are associated with the capability.", + "type": "object", + "required": [ + "urls" + ], + "properties": { + "urls": { + "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionEntry": { + "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", + "anyOf": [ + { + "description": "Reference a permission or permission set by identifier.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + { + "description": "Reference a permission or permission set by identifier and extends its scope.", + "type": "object", + "allOf": [ + { + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + } + ], + "required": [ + "identifier" + ] + } + ] + }, + "Identifier": { + "description": "Permission identifier", + "oneOf": [ + { + "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", + "type": "string", + "const": "core:default", + "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`", + "type": "string", + "const": "core:app:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`" + }, + { + "description": "Enables the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-hide", + "markdownDescription": "Enables the app_hide command without any pre-configured scope." + }, + { + "description": "Enables the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-show", + "markdownDescription": "Enables the app_show command without any pre-configured scope." + }, + { + "description": "Enables the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-bundle-type", + "markdownDescription": "Enables the bundle_type command without any pre-configured scope." + }, + { + "description": "Enables the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-default-window-icon", + "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." + }, + { + "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-fetch-data-store-identifiers", + "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Enables the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-identifier", + "markdownDescription": "Enables the identifier command without any pre-configured scope." + }, + { + "description": "Enables the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-name", + "markdownDescription": "Enables the name command without any pre-configured scope." + }, + { + "description": "Enables the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-register-listener", + "markdownDescription": "Enables the register_listener command without any pre-configured scope." + }, + { + "description": "Enables the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-data-store", + "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." + }, + { + "description": "Enables the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-listener", + "markdownDescription": "Enables the remove_listener command without any pre-configured scope." + }, + { + "description": "Enables the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-app-theme", + "markdownDescription": "Enables the set_app_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-dock-visibility", + "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Enables the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-supports-multiple-windows", + "markdownDescription": "Enables the supports_multiple_windows command without any pre-configured scope." + }, + { + "description": "Enables the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-tauri-version", + "markdownDescription": "Enables the tauri_version command without any pre-configured scope." + }, + { + "description": "Enables the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-version", + "markdownDescription": "Enables the version command without any pre-configured scope." + }, + { + "description": "Denies the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-hide", + "markdownDescription": "Denies the app_hide command without any pre-configured scope." + }, + { + "description": "Denies the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-show", + "markdownDescription": "Denies the app_show command without any pre-configured scope." + }, + { + "description": "Denies the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-bundle-type", + "markdownDescription": "Denies the bundle_type command without any pre-configured scope." + }, + { + "description": "Denies the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-default-window-icon", + "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." + }, + { + "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-fetch-data-store-identifiers", + "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Denies the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-identifier", + "markdownDescription": "Denies the identifier command without any pre-configured scope." + }, + { + "description": "Denies the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-name", + "markdownDescription": "Denies the name command without any pre-configured scope." + }, + { + "description": "Denies the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-register-listener", + "markdownDescription": "Denies the register_listener command without any pre-configured scope." + }, + { + "description": "Denies the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-data-store", + "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." + }, + { + "description": "Denies the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-listener", + "markdownDescription": "Denies the remove_listener command without any pre-configured scope." + }, + { + "description": "Denies the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-app-theme", + "markdownDescription": "Denies the set_app_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-dock-visibility", + "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Denies the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-supports-multiple-windows", + "markdownDescription": "Denies the supports_multiple_windows command without any pre-configured scope." + }, + { + "description": "Denies the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-tauri-version", + "markdownDescription": "Denies the tauri_version command without any pre-configured scope." + }, + { + "description": "Denies the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-version", + "markdownDescription": "Denies the version command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`", + "type": "string", + "const": "core:event:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`" + }, + { + "description": "Enables the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit", + "markdownDescription": "Enables the emit command without any pre-configured scope." + }, + { + "description": "Enables the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit-to", + "markdownDescription": "Enables the emit_to command without any pre-configured scope." + }, + { + "description": "Enables the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-listen", + "markdownDescription": "Enables the listen command without any pre-configured scope." + }, + { + "description": "Enables the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-unlisten", + "markdownDescription": "Enables the unlisten command without any pre-configured scope." + }, + { + "description": "Denies the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit", + "markdownDescription": "Denies the emit command without any pre-configured scope." + }, + { + "description": "Denies the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit-to", + "markdownDescription": "Denies the emit_to command without any pre-configured scope." + }, + { + "description": "Denies the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-listen", + "markdownDescription": "Denies the listen command without any pre-configured scope." + }, + { + "description": "Denies the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-unlisten", + "markdownDescription": "Denies the unlisten command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`", + "type": "string", + "const": "core:image:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`" + }, + { + "description": "Enables the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-bytes", + "markdownDescription": "Enables the from_bytes command without any pre-configured scope." + }, + { + "description": "Enables the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-path", + "markdownDescription": "Enables the from_path command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-rgba", + "markdownDescription": "Enables the rgba command without any pre-configured scope." + }, + { + "description": "Enables the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-size", + "markdownDescription": "Enables the size command without any pre-configured scope." + }, + { + "description": "Denies the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-bytes", + "markdownDescription": "Denies the from_bytes command without any pre-configured scope." + }, + { + "description": "Denies the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-path", + "markdownDescription": "Denies the from_path command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-rgba", + "markdownDescription": "Denies the rgba command without any pre-configured scope." + }, + { + "description": "Denies the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-size", + "markdownDescription": "Denies the size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`", + "type": "string", + "const": "core:menu:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`" + }, + { + "description": "Enables the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-append", + "markdownDescription": "Enables the append command without any pre-configured scope." + }, + { + "description": "Enables the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-create-default", + "markdownDescription": "Enables the create_default command without any pre-configured scope." + }, + { + "description": "Enables the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-get", + "markdownDescription": "Enables the get command without any pre-configured scope." + }, + { + "description": "Enables the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-insert", + "markdownDescription": "Enables the insert command without any pre-configured scope." + }, + { + "description": "Enables the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-checked", + "markdownDescription": "Enables the is_checked command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-items", + "markdownDescription": "Enables the items command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-popup", + "markdownDescription": "Enables the popup command without any pre-configured scope." + }, + { + "description": "Enables the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-prepend", + "markdownDescription": "Enables the prepend command without any pre-configured scope." + }, + { + "description": "Enables the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove", + "markdownDescription": "Enables the remove command without any pre-configured scope." + }, + { + "description": "Enables the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove-at", + "markdownDescription": "Enables the remove_at command without any pre-configured scope." + }, + { + "description": "Enables the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-accelerator", + "markdownDescription": "Enables the set_accelerator command without any pre-configured scope." + }, + { + "description": "Enables the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-app-menu", + "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-help-menu-for-nsapp", + "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-window-menu", + "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-windows-menu-for-nsapp", + "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-checked", + "markdownDescription": "Enables the set_checked command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-text", + "markdownDescription": "Enables the set_text command without any pre-configured scope." + }, + { + "description": "Enables the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-text", + "markdownDescription": "Enables the text command without any pre-configured scope." + }, + { + "description": "Denies the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-append", + "markdownDescription": "Denies the append command without any pre-configured scope." + }, + { + "description": "Denies the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-create-default", + "markdownDescription": "Denies the create_default command without any pre-configured scope." + }, + { + "description": "Denies the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-get", + "markdownDescription": "Denies the get command without any pre-configured scope." + }, + { + "description": "Denies the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-insert", + "markdownDescription": "Denies the insert command without any pre-configured scope." + }, + { + "description": "Denies the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-checked", + "markdownDescription": "Denies the is_checked command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-items", + "markdownDescription": "Denies the items command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-popup", + "markdownDescription": "Denies the popup command without any pre-configured scope." + }, + { + "description": "Denies the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-prepend", + "markdownDescription": "Denies the prepend command without any pre-configured scope." + }, + { + "description": "Denies the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove", + "markdownDescription": "Denies the remove command without any pre-configured scope." + }, + { + "description": "Denies the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove-at", + "markdownDescription": "Denies the remove_at command without any pre-configured scope." + }, + { + "description": "Denies the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-accelerator", + "markdownDescription": "Denies the set_accelerator command without any pre-configured scope." + }, + { + "description": "Denies the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-app-menu", + "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-help-menu-for-nsapp", + "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-window-menu", + "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-windows-menu-for-nsapp", + "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-checked", + "markdownDescription": "Denies the set_checked command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-text", + "markdownDescription": "Denies the set_text command without any pre-configured scope." + }, + { + "description": "Denies the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-text", + "markdownDescription": "Denies the text command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`", + "type": "string", + "const": "core:path:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`" + }, + { + "description": "Enables the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-basename", + "markdownDescription": "Enables the basename command without any pre-configured scope." + }, + { + "description": "Enables the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-dirname", + "markdownDescription": "Enables the dirname command without any pre-configured scope." + }, + { + "description": "Enables the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-extname", + "markdownDescription": "Enables the extname command without any pre-configured scope." + }, + { + "description": "Enables the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-is-absolute", + "markdownDescription": "Enables the is_absolute command without any pre-configured scope." + }, + { + "description": "Enables the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-join", + "markdownDescription": "Enables the join command without any pre-configured scope." + }, + { + "description": "Enables the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-normalize", + "markdownDescription": "Enables the normalize command without any pre-configured scope." + }, + { + "description": "Enables the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve", + "markdownDescription": "Enables the resolve command without any pre-configured scope." + }, + { + "description": "Enables the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve-directory", + "markdownDescription": "Enables the resolve_directory command without any pre-configured scope." + }, + { + "description": "Denies the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-basename", + "markdownDescription": "Denies the basename command without any pre-configured scope." + }, + { + "description": "Denies the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-dirname", + "markdownDescription": "Denies the dirname command without any pre-configured scope." + }, + { + "description": "Denies the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-extname", + "markdownDescription": "Denies the extname command without any pre-configured scope." + }, + { + "description": "Denies the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-is-absolute", + "markdownDescription": "Denies the is_absolute command without any pre-configured scope." + }, + { + "description": "Denies the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-join", + "markdownDescription": "Denies the join command without any pre-configured scope." + }, + { + "description": "Denies the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-normalize", + "markdownDescription": "Denies the normalize command without any pre-configured scope." + }, + { + "description": "Denies the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve", + "markdownDescription": "Denies the resolve command without any pre-configured scope." + }, + { + "description": "Denies the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve-directory", + "markdownDescription": "Denies the resolve_directory command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`", + "type": "string", + "const": "core:resources:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`" + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`", + "type": "string", + "const": "core:tray:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`" + }, + { + "description": "Enables the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-get-by-id", + "markdownDescription": "Enables the get_by_id command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-remove-by-id", + "markdownDescription": "Enables the remove_by_id command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-as-template", + "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-with-as-template", + "markdownDescription": "Enables the set_icon_with_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-menu", + "markdownDescription": "Enables the set_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-show-menu-on-left-click", + "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Enables the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-temp-dir-path", + "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-tooltip", + "markdownDescription": "Enables the set_tooltip command without any pre-configured scope." + }, + { + "description": "Enables the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-visible", + "markdownDescription": "Enables the set_visible command without any pre-configured scope." + }, + { + "description": "Denies the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-get-by-id", + "markdownDescription": "Denies the get_by_id command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-remove-by-id", + "markdownDescription": "Denies the remove_by_id command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-as-template", + "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-with-as-template", + "markdownDescription": "Denies the set_icon_with_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-menu", + "markdownDescription": "Denies the set_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-show-menu-on-left-click", + "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Denies the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-temp-dir-path", + "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-tooltip", + "markdownDescription": "Denies the set_tooltip command without any pre-configured scope." + }, + { + "description": "Denies the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-visible", + "markdownDescription": "Denies the set_visible command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`", + "type": "string", + "const": "core:webview:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`" + }, + { + "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-clear-all-browsing-data", + "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Enables the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview", + "markdownDescription": "Enables the create_webview command without any pre-configured scope." + }, + { + "description": "Enables the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview-window", + "markdownDescription": "Enables the create_webview_window command without any pre-configured scope." + }, + { + "description": "Enables the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-get-all-webviews", + "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-internal-toggle-devtools", + "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Enables the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-print", + "markdownDescription": "Enables the print command without any pre-configured scope." + }, + { + "description": "Enables the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-reparent", + "markdownDescription": "Enables the reparent command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-auto-resize", + "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-background-color", + "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-focus", + "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-position", + "markdownDescription": "Enables the set_webview_position command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-size", + "markdownDescription": "Enables the set_webview_size command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-zoom", + "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Enables the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-close", + "markdownDescription": "Enables the webview_close command without any pre-configured scope." + }, + { + "description": "Enables the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-hide", + "markdownDescription": "Enables the webview_hide command without any pre-configured scope." + }, + { + "description": "Enables the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-position", + "markdownDescription": "Enables the webview_position command without any pre-configured scope." + }, + { + "description": "Enables the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-show", + "markdownDescription": "Enables the webview_show command without any pre-configured scope." + }, + { + "description": "Enables the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-size", + "markdownDescription": "Enables the webview_size command without any pre-configured scope." + }, + { + "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-clear-all-browsing-data", + "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Denies the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview", + "markdownDescription": "Denies the create_webview command without any pre-configured scope." + }, + { + "description": "Denies the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview-window", + "markdownDescription": "Denies the create_webview_window command without any pre-configured scope." + }, + { + "description": "Denies the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-get-all-webviews", + "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-internal-toggle-devtools", + "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Denies the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-print", + "markdownDescription": "Denies the print command without any pre-configured scope." + }, + { + "description": "Denies the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-reparent", + "markdownDescription": "Denies the reparent command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-auto-resize", + "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-background-color", + "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-focus", + "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-position", + "markdownDescription": "Denies the set_webview_position command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-size", + "markdownDescription": "Denies the set_webview_size command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-zoom", + "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Denies the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-close", + "markdownDescription": "Denies the webview_close command without any pre-configured scope." + }, + { + "description": "Denies the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-hide", + "markdownDescription": "Denies the webview_hide command without any pre-configured scope." + }, + { + "description": "Denies the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-position", + "markdownDescription": "Denies the webview_position command without any pre-configured scope." + }, + { + "description": "Denies the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-show", + "markdownDescription": "Denies the webview_show command without any pre-configured scope." + }, + { + "description": "Denies the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-size", + "markdownDescription": "Denies the webview_size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`", + "type": "string", + "const": "core:window:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`" + }, + { + "description": "Enables the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-activity-name", + "markdownDescription": "Enables the activity_name command without any pre-configured scope." + }, + { + "description": "Enables the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-available-monitors", + "markdownDescription": "Enables the available_monitors command without any pre-configured scope." + }, + { + "description": "Enables the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-center", + "markdownDescription": "Enables the center command without any pre-configured scope." + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Enables the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-create", + "markdownDescription": "Enables the create command without any pre-configured scope." + }, + { + "description": "Enables the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-current-monitor", + "markdownDescription": "Enables the current_monitor command without any pre-configured scope." + }, + { + "description": "Enables the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-cursor-position", + "markdownDescription": "Enables the cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-destroy", + "markdownDescription": "Enables the destroy command without any pre-configured scope." + }, + { + "description": "Enables the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-get-all-windows", + "markdownDescription": "Enables the get_all_windows command without any pre-configured scope." + }, + { + "description": "Enables the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-hide", + "markdownDescription": "Enables the hide command without any pre-configured scope." + }, + { + "description": "Enables the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-position", + "markdownDescription": "Enables the inner_position command without any pre-configured scope." + }, + { + "description": "Enables the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-size", + "markdownDescription": "Enables the inner_size command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-internal-toggle-maximize", + "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-always-on-top", + "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-closable", + "markdownDescription": "Enables the is_closable command without any pre-configured scope." + }, + { + "description": "Enables the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-decorated", + "markdownDescription": "Enables the is_decorated command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-focused", + "markdownDescription": "Enables the is_focused command without any pre-configured scope." + }, + { + "description": "Enables the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-fullscreen", + "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximizable", + "markdownDescription": "Enables the is_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximized", + "markdownDescription": "Enables the is_maximized command without any pre-configured scope." + }, + { + "description": "Enables the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimizable", + "markdownDescription": "Enables the is_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimized", + "markdownDescription": "Enables the is_minimized command without any pre-configured scope." + }, + { + "description": "Enables the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-resizable", + "markdownDescription": "Enables the is_resizable command without any pre-configured scope." + }, + { + "description": "Enables the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-visible", + "markdownDescription": "Enables the is_visible command without any pre-configured scope." + }, + { + "description": "Enables the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-maximize", + "markdownDescription": "Enables the maximize command without any pre-configured scope." + }, + { + "description": "Enables the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-minimize", + "markdownDescription": "Enables the minimize command without any pre-configured scope." + }, + { + "description": "Enables the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-monitor-from-point", + "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Enables the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-position", + "markdownDescription": "Enables the outer_position command without any pre-configured scope." + }, + { + "description": "Enables the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-size", + "markdownDescription": "Enables the outer_size command without any pre-configured scope." + }, + { + "description": "Enables the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-primary-monitor", + "markdownDescription": "Enables the primary_monitor command without any pre-configured scope." + }, + { + "description": "Enables the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-request-user-attention", + "markdownDescription": "Enables the request_user_attention command without any pre-configured scope." + }, + { + "description": "Enables the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scale-factor", + "markdownDescription": "Enables the scale_factor command without any pre-configured scope." + }, + { + "description": "Enables the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scene-identifier", + "markdownDescription": "Enables the scene_identifier command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-bottom", + "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-top", + "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-background-color", + "markdownDescription": "Enables the set_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-count", + "markdownDescription": "Enables the set_badge_count command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-label", + "markdownDescription": "Enables the set_badge_label command without any pre-configured scope." + }, + { + "description": "Enables the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-closable", + "markdownDescription": "Enables the set_closable command without any pre-configured scope." + }, + { + "description": "Enables the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-content-protected", + "markdownDescription": "Enables the set_content_protected command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-grab", + "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-icon", + "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-position", + "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-visible", + "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Enables the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-decorations", + "markdownDescription": "Enables the set_decorations command without any pre-configured scope." + }, + { + "description": "Enables the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-effects", + "markdownDescription": "Enables the set_effects command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focus", + "markdownDescription": "Enables the set_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focusable", + "markdownDescription": "Enables the set_focusable command without any pre-configured scope." + }, + { + "description": "Enables the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-fullscreen", + "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-ignore-cursor-events", + "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Enables the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-max-size", + "markdownDescription": "Enables the set_max_size command without any pre-configured scope." + }, + { + "description": "Enables the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-maximizable", + "markdownDescription": "Enables the set_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-min-size", + "markdownDescription": "Enables the set_min_size command without any pre-configured scope." + }, + { + "description": "Enables the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-minimizable", + "markdownDescription": "Enables the set_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-overlay-icon", + "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-position", + "markdownDescription": "Enables the set_position command without any pre-configured scope." + }, + { + "description": "Enables the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-progress-bar", + "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Enables the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-resizable", + "markdownDescription": "Enables the set_resizable command without any pre-configured scope." + }, + { + "description": "Enables the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-shadow", + "markdownDescription": "Enables the set_shadow command without any pre-configured scope." + }, + { + "description": "Enables the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-simple-fullscreen", + "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size", + "markdownDescription": "Enables the set_size command without any pre-configured scope." + }, + { + "description": "Enables the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size-constraints", + "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Enables the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-skip-taskbar", + "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Enables the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-theme", + "markdownDescription": "Enables the set_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title-bar-style", + "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-visible-on-all-workspaces", + "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Enables the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-show", + "markdownDescription": "Enables the show command without any pre-configured scope." + }, + { + "description": "Enables the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-dragging", + "markdownDescription": "Enables the start_dragging command without any pre-configured scope." + }, + { + "description": "Enables the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-resize-dragging", + "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Enables the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-theme", + "markdownDescription": "Enables the theme command without any pre-configured scope." + }, + { + "description": "Enables the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-title", + "markdownDescription": "Enables the title command without any pre-configured scope." + }, + { + "description": "Enables the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-toggle-maximize", + "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unmaximize", + "markdownDescription": "Enables the unmaximize command without any pre-configured scope." + }, + { + "description": "Enables the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unminimize", + "markdownDescription": "Enables the unminimize command without any pre-configured scope." + }, + { + "description": "Denies the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-activity-name", + "markdownDescription": "Denies the activity_name command without any pre-configured scope." + }, + { + "description": "Denies the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-available-monitors", + "markdownDescription": "Denies the available_monitors command without any pre-configured scope." + }, + { + "description": "Denies the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-center", + "markdownDescription": "Denies the center command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Denies the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-create", + "markdownDescription": "Denies the create command without any pre-configured scope." + }, + { + "description": "Denies the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-current-monitor", + "markdownDescription": "Denies the current_monitor command without any pre-configured scope." + }, + { + "description": "Denies the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-cursor-position", + "markdownDescription": "Denies the cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-destroy", + "markdownDescription": "Denies the destroy command without any pre-configured scope." + }, + { + "description": "Denies the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-get-all-windows", + "markdownDescription": "Denies the get_all_windows command without any pre-configured scope." + }, + { + "description": "Denies the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-hide", + "markdownDescription": "Denies the hide command without any pre-configured scope." + }, + { + "description": "Denies the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-position", + "markdownDescription": "Denies the inner_position command without any pre-configured scope." + }, + { + "description": "Denies the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-size", + "markdownDescription": "Denies the inner_size command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-internal-toggle-maximize", + "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-always-on-top", + "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-closable", + "markdownDescription": "Denies the is_closable command without any pre-configured scope." + }, + { + "description": "Denies the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-decorated", + "markdownDescription": "Denies the is_decorated command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-focused", + "markdownDescription": "Denies the is_focused command without any pre-configured scope." + }, + { + "description": "Denies the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-fullscreen", + "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximizable", + "markdownDescription": "Denies the is_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximized", + "markdownDescription": "Denies the is_maximized command without any pre-configured scope." + }, + { + "description": "Denies the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimizable", + "markdownDescription": "Denies the is_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimized", + "markdownDescription": "Denies the is_minimized command without any pre-configured scope." + }, + { + "description": "Denies the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-resizable", + "markdownDescription": "Denies the is_resizable command without any pre-configured scope." + }, + { + "description": "Denies the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-visible", + "markdownDescription": "Denies the is_visible command without any pre-configured scope." + }, + { + "description": "Denies the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-maximize", + "markdownDescription": "Denies the maximize command without any pre-configured scope." + }, + { + "description": "Denies the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-minimize", + "markdownDescription": "Denies the minimize command without any pre-configured scope." + }, + { + "description": "Denies the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-monitor-from-point", + "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Denies the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-position", + "markdownDescription": "Denies the outer_position command without any pre-configured scope." + }, + { + "description": "Denies the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-size", + "markdownDescription": "Denies the outer_size command without any pre-configured scope." + }, + { + "description": "Denies the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-primary-monitor", + "markdownDescription": "Denies the primary_monitor command without any pre-configured scope." + }, + { + "description": "Denies the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-request-user-attention", + "markdownDescription": "Denies the request_user_attention command without any pre-configured scope." + }, + { + "description": "Denies the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scale-factor", + "markdownDescription": "Denies the scale_factor command without any pre-configured scope." + }, + { + "description": "Denies the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scene-identifier", + "markdownDescription": "Denies the scene_identifier command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-bottom", + "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-top", + "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-background-color", + "markdownDescription": "Denies the set_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-count", + "markdownDescription": "Denies the set_badge_count command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-label", + "markdownDescription": "Denies the set_badge_label command without any pre-configured scope." + }, + { + "description": "Denies the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-closable", + "markdownDescription": "Denies the set_closable command without any pre-configured scope." + }, + { + "description": "Denies the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-content-protected", + "markdownDescription": "Denies the set_content_protected command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-grab", + "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-icon", + "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-position", + "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-visible", + "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Denies the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-decorations", + "markdownDescription": "Denies the set_decorations command without any pre-configured scope." + }, + { + "description": "Denies the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-effects", + "markdownDescription": "Denies the set_effects command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focus", + "markdownDescription": "Denies the set_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focusable", + "markdownDescription": "Denies the set_focusable command without any pre-configured scope." + }, + { + "description": "Denies the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-fullscreen", + "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-ignore-cursor-events", + "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Denies the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-max-size", + "markdownDescription": "Denies the set_max_size command without any pre-configured scope." + }, + { + "description": "Denies the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-maximizable", + "markdownDescription": "Denies the set_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-min-size", + "markdownDescription": "Denies the set_min_size command without any pre-configured scope." + }, + { + "description": "Denies the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-minimizable", + "markdownDescription": "Denies the set_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-overlay-icon", + "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-position", + "markdownDescription": "Denies the set_position command without any pre-configured scope." + }, + { + "description": "Denies the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-progress-bar", + "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Denies the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-resizable", + "markdownDescription": "Denies the set_resizable command without any pre-configured scope." + }, + { + "description": "Denies the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-shadow", + "markdownDescription": "Denies the set_shadow command without any pre-configured scope." + }, + { + "description": "Denies the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-simple-fullscreen", + "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size", + "markdownDescription": "Denies the set_size command without any pre-configured scope." + }, + { + "description": "Denies the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size-constraints", + "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Denies the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-skip-taskbar", + "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Denies the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-theme", + "markdownDescription": "Denies the set_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title-bar-style", + "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-visible-on-all-workspaces", + "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Denies the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-show", + "markdownDescription": "Denies the show command without any pre-configured scope." + }, + { + "description": "Denies the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-dragging", + "markdownDescription": "Denies the start_dragging command without any pre-configured scope." + }, + { + "description": "Denies the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-resize-dragging", + "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Denies the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-theme", + "markdownDescription": "Denies the theme command without any pre-configured scope." + }, + { + "description": "Denies the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-title", + "markdownDescription": "Denies the title command without any pre-configured scope." + }, + { + "description": "Denies the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-toggle-maximize", + "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unmaximize", + "markdownDescription": "Denies the unmaximize command without any pre-configured scope." + }, + { + "description": "Denies the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unminimize", + "markdownDescription": "Denies the unminimize command without any pre-configured scope." + }, + { + "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "type": "string", + "const": "dialog:default", + "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`" + }, + { + "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-ask", + "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-confirm", + "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-message", + "markdownDescription": "Enables the message command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-save", + "markdownDescription": "Enables the save command without any pre-configured scope." + }, + { + "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-ask", + "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-confirm", + "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-message", + "markdownDescription": "Denies the message command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-save", + "markdownDescription": "Denies the save command without any pre-configured scope." + } + ] + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + } + } +} \ No newline at end of file diff --git a/src-tauri/src/autoedit.rs b/src-tauri/src/autoedit.rs new file mode 100644 index 0000000..f54cda9 --- /dev/null +++ b/src-tauri/src/autoedit.rs @@ -0,0 +1,607 @@ +//! Automatic "tight cut" editing: silence removal and pacing boosts. +//! +//! The strategy is two-pass on purpose. `ffmpeg`'s `silenceremove` filter edits +//! audio only, which would desync captions and prevent jump cuts. Instead this +//! module detects silence once, builds an explicit cut list +//! ([`AutoEditPlan`]), and the renderer replays that list with +//! `trim`/`atrim` + `concat`. Because the plan is data, captions can be +//! remapped onto the compressed timeline ([`remap_cues`]) and the same plan can +//! be previewed in the UI before spending render time. + +use std::process::Command; + +use anyhow::{anyhow, Context, Result}; + +use crate::models::{AutoEditSettings, CaptionCue, TranscriptWord}; + +/// Shortest piece of video worth keeping; shorter leftovers are merged. +const MIN_SEGMENT_SEC: f64 = 0.12; + +/// Absorbs float noise such as `1.4 - 1.0 = 0.3999999999999999` so an exactly +/// threshold-sized gap is not silently dropped. +const TIMING_EPSILON: f64 = 1e-6; + +/// One span of the source timeline kept in the output, optionally retimed. +#[derive(Debug, Clone, PartialEq)] +pub struct RenderSegment { + pub start: f64, + pub end: f64, + /// Playback rate. 1.0 = realtime, >1 = faster (shorter on output). + pub speed: f64, + /// Extra punch-in zoom applied after the crop; 1.0 keeps the framing. + pub zoom: f64, +} + +impl RenderSegment { + pub fn output_len(&self) -> f64 { + if self.speed <= 0.0 { + 0.0 + } else { + (self.end - self.start).max(0.0) / self.speed + } + } +} + +/// Cut list for one clip: source spans, in order, plus output timing. +#[derive(Debug, Clone, PartialEq)] +pub struct AutoEditPlan { + pub segments: Vec, + pub source_start: f64, + pub source_end: f64, + pub output_duration: f64, + /// Seconds of source material removed from the clip. + pub removed_sec: f64, + /// Number of silence gaps found (before padding/refinement). + pub silence_count: usize, +} + +impl AutoEditPlan { + /// True when the plan changes nothing and the simple render path applies. + pub fn is_noop(&self) -> bool { + self.segments.len() <= 1 + && self + .segments + .first() + .map(|segment| (segment.speed - 1.0).abs() < 1e-3) + .unwrap_or(true) + } + + /// Output timestamp of a source timestamp, or `None` when it was cut. + pub fn map_time(&self, source_time: f64) -> Option { + let mut cursor = 0.0; + for segment in &self.segments { + if source_time < segment.start { + return None; + } + if source_time <= segment.end { + let offset = (source_time - segment.start) / segment.speed.max(1e-6); + return Some(cursor + offset); + } + cursor += segment.output_len(); + } + None + } +} + +/// Ask ffmpeg where the quiet stretches are inside `start_sec..end_sec`. +/// +/// `silencedetect` reports on stderr, so the whole log is captured and parsed. +/// Times are shifted by the seek offset, returning absolute source seconds. +pub fn detect_silences( + source_path: &str, + start_sec: f64, + end_sec: f64, + threshold_db: f64, + min_silence_sec: f64, +) -> Result> { + let duration = (end_sec - start_sec).max(0.1); + let output = Command::new("ffmpeg") + .args(["-hide_banner", "-nostats", "-vn"]) + .args(["-ss", &format!("{start_sec:.3}")]) + .args(["-t", &format!("{duration:.3}")]) + .args(["-i", source_path]) + .args([ + "-af", + &format!("silencedetect=noise={threshold_db:.1}dB:d={min_silence_sec:.2}"), + "-f", + "null", + "-", + ]) + .output() + .context("running ffmpeg silencedetect")?; + + if !output.status.success() { + return Err(anyhow!( + "ffmpeg silencedetect failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + Ok(parse_silence_gaps( + &String::from_utf8_lossy(&output.stderr), + start_sec, + end_sec, + )) +} + +/// Parse `silence_start`/`silence_end` log lines into absolute gaps. +fn parse_silence_gaps(stderr: &str, offset: f64, window_end: f64) -> Vec<(f64, f64)> { + let mut gaps: Vec<(f64, f64)> = Vec::new(); + let mut open: Option = None; + + for line in stderr.lines() { + if let Some(index) = line.find("silence_start:") { + let value = line[index + "silence_start:".len()..].trim(); + if let Some(seconds) = value + .split_whitespace() + .next() + .and_then(|token| token.parse::().ok()) + { + // A repeated start without an end would otherwise swallow the + // previous gap; keep the earliest one. + let start = offset + seconds; + open = Some(open.map_or(start, |existing| existing.min(start))); + } + } else if let Some(index) = line.find("silence_end:") { + let value = line[index + "silence_end:".len()..].trim(); + let seconds = value + .split('|') + .next() + .unwrap_or_default() + .trim() + .parse::() + .ok(); + if let (Some(start), Some(end)) = (open.take(), seconds) { + let end = offset + end; + if end > start { + gaps.push((start, end)); + } + } + } + } + + // Silence that runs until the end of the window never gets a `silence_end`. + if let Some(start) = open { + if window_end > start { + gaps.push((start, window_end)); + } + } + + gaps +} + +/// Detect silence and turn it into a cut list in one call. +pub fn build_plan( + source_path: &str, + start_sec: f64, + end_sec: f64, + settings: &AutoEditSettings, + words: &[TranscriptWord], +) -> Result { + if end_sec <= start_sec { + return Err(anyhow!("invalid clip window")); + } + let settings = settings.clone().sanitized(); + let silences = detect_silences( + source_path, + start_sec, + end_sec, + settings.silence_threshold_db, + settings.min_silence_sec, + )?; + Ok(plan_from_silences( + start_sec, + end_sec, + &silences, + words, + &settings, + )) +} + +/// Pure cut-list builder: silence gaps + transcript + settings -> plan. +/// +/// Detection by audio is the source of truth, but the transcript refines it: +/// a gap that overlaps a spoken word is not a real pause (breath, soft voice), +/// so only the silent remainder of that gap becomes a cut. +pub fn plan_from_silences( + source_start: f64, + source_end: f64, + silences: &[(f64, f64)], + words: &[TranscriptWord], + settings: &AutoEditSettings, +) -> AutoEditPlan { + let window = (source_end - source_start).max(0.0); + let fallback = AutoEditPlan { + segments: vec![RenderSegment { + start: source_start, + end: source_end, + speed: 1.0, + zoom: 1.0, + }], + source_start, + source_end, + output_duration: window, + removed_sec: 0.0, + silence_count: 0, + }; + if window <= 0.0 { + return AutoEditPlan { + segments: Vec::new(), + output_duration: 0.0, + ..fallback + }; + } + + // Speech map from the transcript, used to protect spoken material. + let mut speech: Vec<(f64, f64)> = words + .iter() + .filter(|word| word.end > source_start && word.start < source_end && word.end > word.start) + .map(|word| (word.start.max(source_start), word.end.min(source_end))) + .collect(); + merge_intervals(&mut speech); + + let mut gaps: Vec<(f64, f64)> = silences + .iter() + .map(|(start, end)| (start.max(source_start), end.min(source_end))) + .filter(|(start, end)| end > start) + .collect(); + merge_intervals(&mut gaps); + let silence_count = gaps.len(); + + // Silence that survives the transcript, shrunk by the padding the user + // wants to keep on each side of a cut. + let mut cuts: Vec<(f64, f64)> = Vec::new(); + for gap in &gaps { + if gap.1 - gap.0 + TIMING_EPSILON < settings.min_silence_sec { + continue; + } + for piece in subtract_intervals(*gap, &speech) { + if piece.1 - piece.0 + TIMING_EPSILON < settings.min_silence_sec { + continue; + } + let start = piece.0 + settings.padding_sec; + let end = piece.1 - settings.padding_sec; + if end - start >= MIN_SEGMENT_SEC { + cuts.push((start, end)); + } + } + } + merge_intervals(&mut cuts); + + // Walk the window, alternating speech and silence spans. + let mut segments: Vec = Vec::new(); + let mut cursor = source_start; + for (cut_start, cut_end) in &cuts { + if cut_start - cursor >= MIN_SEGMENT_SEC { + segments.push(RenderSegment { + start: cursor, + end: *cut_start, + speed: 1.0, + zoom: 1.0, + }); + } + if settings.keeps_silence() { + segments.push(RenderSegment { + start: *cut_start, + end: *cut_end, + speed: settings.silence_speed, + zoom: 1.0, + }); + } + cursor = *cut_end; + } + if source_end - cursor >= MIN_SEGMENT_SEC { + segments.push(RenderSegment { + start: cursor, + end: source_end, + speed: 1.0, + zoom: 1.0, + }); + } + + // Punch-in alternates on realtime (speech) segments only: the first one + // keeps the framing the user picked in the editor, the next one is tighter. + if settings.punch_in_zoom > 1.0 { + let mut speech_index = 0usize; + for segment in &mut segments { + if (segment.speed - 1.0).abs() > 1e-3 { + continue; + } + if speech_index % 2 == 1 { + segment.zoom = settings.punch_in_zoom; + } + speech_index += 1; + } + } + + // A single cut in the middle of a long take leaves three tiny segments; + // merging neighbours of the same speed keeps the filter graph short. + let mut merged: Vec = Vec::new(); + for segment in segments { + match merged.last_mut() { + Some(last) + if (last.speed - segment.speed).abs() < 1e-3 + && (last.zoom - segment.zoom).abs() < 1e-3 + && segment.start - last.end < 1e-3 => + { + last.end = segment.end; + } + _ => merged.push(segment), + } + } + merged.retain(|segment| segment.end - segment.start >= MIN_SEGMENT_SEC); + + if merged.is_empty() { + return fallback; + } + + let output_duration: f64 = merged.iter().map(RenderSegment::output_len).sum(); + AutoEditPlan { + segments: merged, + source_start, + source_end, + output_duration, + removed_sec: (window - output_duration).max(0.0), + silence_count, + } +} + +/// Rewrite caption cues from source time onto the compressed output timeline. +/// +/// Cues are returned 0-based (the rendered clip starts at zero) so the ASS +/// builder can be called with a `0.0..output_duration` window. A cue split by a +/// cut becomes one cue per surviving side. +pub fn remap_cues(cues: &[CaptionCue], plan: &AutoEditPlan) -> Vec { + let mut remapped: Vec = Vec::new(); + let mut output_cursor = 0.0; + + for segment in &plan.segments { + let speed = segment.speed.max(1e-6); + for cue in cues { + let start = cue.start.max(segment.start); + let end = cue.end.min(segment.end); + if end - start < 0.05 { + continue; + } + let text = cue.text.trim(); + if text.is_empty() { + continue; + } + let mapped_start = output_cursor + (start - segment.start) / speed; + let mapped_end = output_cursor + (end - segment.start) / speed; + if mapped_end - mapped_start < 0.05 { + continue; + } + remapped.push(CaptionCue { + start: mapped_start, + end: mapped_end, + text: text.to_string(), + }); + } + output_cursor += segment.output_len(); + } + + remapped.sort_by(|a, b| a.start.partial_cmp(&b.start).unwrap_or(std::cmp::Ordering::Equal)); + remapped +} + +/// Merge overlapping or touching intervals, in place, sorted by start. +fn merge_intervals(intervals: &mut Vec<(f64, f64)>) { + intervals.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + let mut merged: Vec<(f64, f64)> = Vec::with_capacity(intervals.len()); + for (start, end) in intervals.drain(..) { + match merged.last_mut() { + Some(last) if start <= last.1 + 1e-6 => { + if end > last.1 { + last.1 = end; + } + } + _ => merged.push((start, end)), + } + } + *intervals = merged; +} + +/// `interval` minus every span in `holes`, preserving order. +fn subtract_intervals(interval: (f64, f64), holes: &[(f64, f64)]) -> Vec<(f64, f64)> { + let mut pieces = vec![interval]; + for hole in holes { + let mut next: Vec<(f64, f64)> = Vec::new(); + for (start, end) in pieces { + if hole.1 <= start || hole.0 >= end { + next.push((start, end)); + continue; + } + if hole.0 > start { + next.push((start, hole.0)); + } + if hole.1 < end { + next.push((hole.1, end)); + } + } + pieces = next; + } + pieces.retain(|(start, end)| end > start); + pieces +} + +#[cfg(test)] +mod tests { + use super::*; + + fn settings() -> AutoEditSettings { + AutoEditSettings { + enabled: true, + silence_threshold_db: -32.0, + min_silence_sec: 0.4, + padding_sec: 0.05, + silence_mode: "cut".to_string(), + silence_speed: 1.5, + punch_in_zoom: 1.0, + } + } + + fn word(text: &str, start: f64, end: f64) -> TranscriptWord { + TranscriptWord { + text: text.to_string(), + start, + end, + speaker: None, + } + } + + #[test] + fn parses_silence_start_and_end_pairs() { + let log = "[silencedetect @ 0x1] silence_start: 1.0\n\ + [silencedetect @ 0x1] silence_end: 2.5 | silence_duration: 1.5\n"; + assert_eq!(parse_silence_gaps(log, 0.0, 10.0), vec![(1.0, 2.5)]); + assert_eq!(parse_silence_gaps(log, 4.0, 10.0), vec![(5.0, 6.5)]); + } + + #[test] + fn closes_unterminated_silence_at_window_end() { + let log = "[silencedetect @ 0x1] silence_start: 8.0\n"; + assert_eq!(parse_silence_gaps(log, 0.0, 10.0), vec![(8.0, 10.0)]); + } + + #[test] + fn cut_mode_removes_silence_and_keeps_speech() { + let words = vec![word("oi", 0.2, 1.0), word("tudo", 3.0, 4.0)]; + let plan = plan_from_silences(0.0, 5.0, &[(1.2, 2.8)], &words, &settings()); + + assert_eq!(plan.silence_count, 1); + // 1.6s of silence minus 2 * 0.05 padding = 1.5s removed. + assert!((plan.removed_sec - 1.5).abs() < 1e-6, "{plan:?}"); + assert!((plan.output_duration - 3.5).abs() < 1e-6, "{plan:?}"); + assert_eq!(plan.segments.len(), 2); + assert!(plan.segments.iter().all(|segment| (segment.speed - 1.0).abs() < 1e-9)); + } + + #[test] + fn transcript_protects_spoken_words_inside_a_gap() { + // Quiet voice registered as silence, but the word is clearly spoken. + let words = vec![word("baixo", 1.4, 1.8)]; + let plan = plan_from_silences(0.0, 4.0, &[(1.0, 2.0)], &words, &settings()); + + // Only the 0.4s before the word is long enough to cut; the 0.2s tail + // after it is shorter than `min_silence_sec` and stays. + assert_eq!(plan.segments.len(), 2, "{plan:?}"); + assert!(plan + .segments + .iter() + .any(|segment| segment.start <= 1.4 && segment.end >= 1.8)); + } + + #[test] + fn speed_mode_keeps_silence_but_retimes_it() { + let mut settings = settings(); + settings.silence_mode = "speed".to_string(); + settings.silence_speed = 2.0; + let plan = plan_from_silences(0.0, 4.0, &[(1.0, 2.0)], &[], &settings); + + assert_eq!(plan.segments.len(), 3, "{plan:?}"); + assert!((plan.segments[1].speed - 2.0).abs() < 1e-9); + // 1.05 + 0.9/2 + 2.05 -> the 0.9s silence plays in 0.45s. + assert!((plan.output_duration - 3.55).abs() < 1e-6, "{plan:?}"); + assert!((plan.removed_sec - 0.45).abs() < 1e-6, "{plan:?}"); + } + + #[test] + fn short_gaps_are_left_alone() { + let plan = plan_from_silences(0.0, 3.0, &[(1.0, 1.2)], &[], &settings()); + assert!(plan.is_noop(), "{plan:?}"); + assert!((plan.removed_sec - 0.0).abs() < 1e-9); + } + + #[test] + fn remaps_cues_onto_the_compressed_timeline() { + let plan = plan_from_silences(0.0, 5.0, &[(1.5, 3.0)], &[], &settings()); + let cues = vec![ + CaptionCue { + start: 0.0, + end: 1.0, + text: "a".into(), + }, + CaptionCue { + start: 3.2, + end: 4.0, + text: "b".into(), + }, + ]; + + let remapped = remap_cues(&cues, &plan); + assert_eq!(remapped.len(), 2, "{remapped:?}"); + assert!((remapped[0].start - 0.0).abs() < 1e-6); + // First segment keeps 1.55s, then cue starts 0.25s into the second. + assert!((remapped[1].start - 1.80).abs() < 1e-6, "{remapped:?}"); + } + + #[test] + fn cues_inside_a_removed_gap_disappear() { + let plan = plan_from_silences(0.0, 5.0, &[(1.5, 3.0)], &[], &settings()); + let cues = vec![CaptionCue { + start: 2.0, + end: 2.6, + text: "silence".into(), + }]; + assert!(remap_cues(&cues, &plan).is_empty()); + } + + #[test] + fn punch_in_alternates_on_speech_segments() { + let mut settings = settings(); + settings.punch_in_zoom = 1.08; + let plan = plan_from_silences(0.0, 8.0, &[(1.0, 2.0), (4.0, 5.0)], &[], &settings); + + let speech: Vec<&RenderSegment> = plan + .segments + .iter() + .filter(|segment| (segment.speed - 1.0).abs() < 1e-9) + .collect(); + assert_eq!(speech.len(), 3, "{plan:?}"); + assert!((speech[0].zoom - 1.0).abs() < 1e-9); + assert!((speech[1].zoom - 1.08).abs() < 1e-9); + assert!((speech[2].zoom - 1.0).abs() < 1e-9); + } + + #[test] + fn map_time_tracks_speed_segments() { + let plan = AutoEditPlan { + segments: vec![ + RenderSegment { + start: 0.0, + end: 2.0, + speed: 1.0, + zoom: 1.0, + }, + RenderSegment { + start: 2.0, + end: 4.0, + speed: 2.0, + zoom: 1.0, + }, + ], + source_start: 0.0, + source_end: 4.0, + output_duration: 3.0, + removed_sec: 1.0, + silence_count: 1, + }; + + assert_eq!(plan.map_time(1.0), Some(1.0)); + assert_eq!(plan.map_time(3.0), Some(2.5)); + assert_eq!(plan.map_time(9.0), None); + } + + #[test] + fn subtract_intervals_splits_around_a_hole() { + assert_eq!( + subtract_intervals((0.0, 10.0), &[(2.0, 4.0)]), + vec![(0.0, 2.0), (4.0, 10.0)] + ); + assert_eq!( + subtract_intervals((0.0, 10.0), &[(0.0, 10.0)]), + Vec::<(f64, f64)>::new() + ); + } +} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index c487928..b540804 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -9,7 +9,7 @@ use rusqlite::{params, Connection, OptionalExtension}; use uuid::Uuid; use crate::models::{ - Candidate, CandidateDraft, Clip, ClipCopy, Project, ProjectDetail, Transcript, + Candidate, CandidateDraft, Clip, ClipCopy, ClipEdit, Project, ProjectDetail, Transcript, }; #[derive(Clone)] @@ -95,10 +95,28 @@ impl Database { scheduled_for TEXT, status TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS clip_edits ( + candidate_id TEXT PRIMARY KEY REFERENCES candidates(id) ON DELETE CASCADE, + aspect_ratio TEXT NOT NULL, + crop_x REAL NOT NULL, + crop_y REAL NOT NULL, + captions_json TEXT NOT NULL, + caption_style TEXT, + updated_at TEXT NOT NULL + ); ", )?; let _ = conn.execute("ALTER TABLE projects ADD COLUMN name TEXT", []); let _ = conn.execute("ALTER TABLE projects ADD COLUMN caption_style TEXT", []); + // Fine-grained caption look (colour, font, position) edited per clip. + let _ = conn.execute("ALTER TABLE clip_edits ADD COLUMN caption_override_json TEXT", []); + // Automatic tight-cut settings (silence removal + pacing). + let _ = conn.execute("ALTER TABLE clip_edits ADD COLUMN auto_edit_json TEXT", []); + // No ffmpeg process survives an app restart, so any clip still marked + // `cutting` was interrupted. Reset it instead of showing a render that + // can never finish. + let _ = conn.execute("UPDATE clips SET status = 'pending' WHERE status = 'cutting'", []); Ok(()) } @@ -171,6 +189,7 @@ impl Database { let candidates = self.list_candidates(project_id)?; let clips = self.list_clips_for_project(project_id)?; let copy = self.list_copy_for_project(project_id)?; + let clip_edits = self.list_clip_edits(project_id)?; Ok(ProjectDetail { project, @@ -178,6 +197,7 @@ impl Database { candidates, clips, copy, + clip_edits, }) } @@ -395,6 +415,37 @@ impl Database { self.list_candidates(project_id) } + /// Move a candidate's clip window, keeping its rank/score untouched. + /// + /// The editor lets the user stretch a moment when the transcript cut lands + /// mid-sentence; only the two timestamps change here. + pub fn update_candidate_range( + &self, + candidate_id: &str, + start_sec: f64, + end_sec: f64, + ) -> Result { + let start = if start_sec.is_finite() { + start_sec.max(0.0) + } else { + 0.0 + }; + let end = if end_sec.is_finite() { end_sec } else { start }; + let end = end.max(start + 0.1); + let conn = self.conn.lock().expect("database mutex poisoned"); + conn.execute( + "UPDATE candidates SET start_sec = ?1, end_sec = ?2 WHERE id = ?3", + params![start, end, candidate_id], + )?; + let candidate = conn.query_row( + "SELECT id, project_id, start_sec, end_sec, score, hook, rationale, rank, selected + FROM candidates WHERE id = ?1", + params![candidate_id], + candidate_from_row, + )?; + Ok(candidate) + } + fn list_clips_for_project(&self, project_id: &str) -> Result> { let conn = self.conn.lock().expect("database mutex poisoned"); let mut stmt = conn.prepare( @@ -442,6 +493,81 @@ impl Database { .map_err(Into::into) } + /// Saved clip edit for a candidate, if the user ever changed its framing. + pub fn clip_edit(&self, candidate_id: &str) -> Result> { + let conn = self.conn.lock().expect("database mutex poisoned"); + let edit = conn + .query_row( + "SELECT candidate_id, aspect_ratio, crop_x, crop_y, captions_json, caption_style, + caption_override_json, auto_edit_json + FROM clip_edits WHERE candidate_id = ?1", + params![candidate_id], + clip_edit_from_row, + ) + .optional()?; + Ok(edit.map(ClipEdit::sanitized)) + } + + /// Upsert the edit for its own `candidate_id`, storing the sanitized value. + pub fn save_clip_edit(&self, edit: &ClipEdit) -> Result { + let stored = edit.clone().sanitized(); + let captions_json = serde_json::to_string(&stored.captions)?; + let override_json = match stored.caption_override.as_ref() { + Some(value) => Some(serde_json::to_string(value)?), + None => None, + }; + let auto_edit_json = match stored.auto_edit.as_ref() { + Some(value) => Some(serde_json::to_string(value)?), + None => None, + }; + let now = Utc::now().to_rfc3339(); + let conn = self.conn.lock().expect("database mutex poisoned"); + conn.execute( + "INSERT INTO clip_edits + (candidate_id, aspect_ratio, crop_x, crop_y, captions_json, caption_style, + caption_override_json, auto_edit_json, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(candidate_id) DO UPDATE SET + aspect_ratio = excluded.aspect_ratio, + crop_x = excluded.crop_x, + crop_y = excluded.crop_y, + captions_json = excluded.captions_json, + caption_style = excluded.caption_style, + caption_override_json = excluded.caption_override_json, + auto_edit_json = excluded.auto_edit_json, + updated_at = excluded.updated_at", + params![ + stored.candidate_id, + stored.aspect_ratio, + stored.crop_x, + stored.crop_y, + captions_json, + stored.caption_style, + override_json, + auto_edit_json, + now + ], + )?; + Ok(stored) + } + + /// Every saved edit belonging to a project, ordered like the candidates. + pub fn list_clip_edits(&self, project_id: &str) -> Result> { + let conn = self.conn.lock().expect("database mutex poisoned"); + let mut stmt = conn.prepare( + "SELECT clip_edits.candidate_id, clip_edits.aspect_ratio, clip_edits.crop_x, + clip_edits.crop_y, clip_edits.captions_json, clip_edits.caption_style, + clip_edits.caption_override_json, clip_edits.auto_edit_json + FROM clip_edits + INNER JOIN candidates ON candidates.id = clip_edits.candidate_id + WHERE candidates.project_id = ?1 + ORDER BY candidates.rank ASC", + )?; + let rows = stmt.query_map(params![project_id], clip_edit_from_row)?; + let edits = rows.collect::>>()?; + Ok(edits.into_iter().map(ClipEdit::sanitized).collect()) + } + pub fn delete_project(&self, project_id: &str) -> Result<()> { let conn = self.conn.lock().expect("database mutex poisoned"); conn.execute("DELETE FROM projects WHERE id = ?1", params![project_id])?; @@ -473,6 +599,22 @@ fn project_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { }) } +fn clip_edit_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let captions_json: String = row.get(4)?; + let override_json: Option = row.get(6)?; + let auto_edit_json: Option = row.get(7)?; + Ok(ClipEdit { + candidate_id: row.get(0)?, + aspect_ratio: row.get(1)?, + crop_x: row.get(2)?, + crop_y: row.get(3)?, + captions: serde_json::from_str(&captions_json).unwrap_or_default(), + caption_style: row.get(5)?, + caption_override: override_json.and_then(|json| serde_json::from_str(&json).ok()), + auto_edit: auto_edit_json.and_then(|json| serde_json::from_str(&json).ok()), + }) +} + fn candidate_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { let selected: i64 = row.get(8)?; Ok(Candidate { diff --git a/src-tauri/src/hardware.rs b/src-tauri/src/hardware.rs new file mode 100644 index 0000000..9015e42 --- /dev/null +++ b/src-tauri/src/hardware.rs @@ -0,0 +1,501 @@ +//! Runtime detection of GPU acceleration. +//! +//! Nothing here is required for the app to work: every lookup is best-effort and +//! falls back to the CPU implementation. Detection happens once per process and +//! is validated with a real probe (a tiny test encode for ffmpeg encoders, a +//! `torch` query for Whisper), because an encoder can be compiled into ffmpeg +//! while no usable GPU is present. +//! +//! Environment overrides: +//! - `AUTOSHORTS_VIDEO_ENCODER=x264|nvenc|videotoolbox|amf|vaapi` forces an encoder. +//! - `AUTOSHORTS_DISABLE_GPU=1` forces the CPU encoder. +//! - `AUTOSHORTS_WHISPER_DEVICE=cpu|cuda|mps` forces the Whisper device. + +use std::process::Command; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::OnceLock; + +/// Hardware video encoders we know how to drive, plus the CPU baseline. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VideoEncoder { + Libx264, + Nvenc, + VideoToolbox, + Amf, + Vaapi, +} + +impl VideoEncoder { + pub fn id(self) -> &'static str { + match self { + VideoEncoder::Libx264 => "libx264", + VideoEncoder::Nvenc => "h264_nvenc", + VideoEncoder::VideoToolbox => "h264_videotoolbox", + VideoEncoder::Amf => "h264_amf", + VideoEncoder::Vaapi => "h264_vaapi", + } + } + + pub fn label(self) -> &'static str { + match self { + VideoEncoder::Libx264 => "libx264 (CPU)", + VideoEncoder::Nvenc => "h264_nvenc (NVIDIA GPU)", + VideoEncoder::VideoToolbox => "h264_videotoolbox (Apple GPU)", + VideoEncoder::Amf => "h264_amf (AMD GPU)", + VideoEncoder::Vaapi => "h264_vaapi (VA-API GPU)", + } + } + + pub fn is_hardware(self) -> bool { + !matches!(self, VideoEncoder::Libx264) + } +} + +/// ffmpeg arguments that a given encoder requires. +pub struct EncoderPlan { + /// Global options that must precede the input (e.g. `-vaapi_device`). + pub pre_input: Vec, + /// Codec options (`-c:v ...`). + pub codec: Vec, + /// Filter-chain suffix needed before the encoder accepts frames. + pub filter_suffix: &'static str, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HardwareInfo { + /// Best encoder that actually works on this machine (CPU when none). + pub video_encoder: VideoEncoder, + /// Render node used for VA-API, when that is the selected backend. + pub vaapi_device: Option, +} + +static INFO: OnceLock = OnceLock::new(); +static ENCODER_FAILED: AtomicBool = AtomicBool::new(false); +static WHISPER_DEVICE: OnceLock = OnceLock::new(); + +/// Parse a user-supplied encoder name. Returns `None` for unknown values. +pub fn parse_encoder_override(value: &str) -> Option { + match value.trim().to_lowercase().as_str() { + "" => None, + "x264" | "libx264" | "cpu" => Some(VideoEncoder::Libx264), + "nvenc" | "h264_nvenc" | "cuda" => Some(VideoEncoder::Nvenc), + "videotoolbox" | "vt" | "h264_videotoolbox" => Some(VideoEncoder::VideoToolbox), + "amf" | "h264_amf" => Some(VideoEncoder::Amf), + "vaapi" | "h264_vaapi" => Some(VideoEncoder::Vaapi), + _ => None, + } +} + +/// Map the output of the torch probe onto a Whisper device name. +pub fn parse_torch_device(output: &str) -> &'static str { + let trimmed = output.trim().to_lowercase(); + if trimmed.starts_with("cuda") { + "cuda" + } else if trimmed.starts_with("mps") { + "mps" + } else { + "cpu" + } +} + +fn env_flag(name: &str) -> bool { + std::env::var(name) + .map(|value| { + let value = value.trim().to_lowercase(); + !value.is_empty() && value != "0" && value != "false" && value != "no" + }) + .unwrap_or(false) +} + +fn ffmpeg_available() -> bool { + Command::new("ffmpeg") + .arg("-version") + .output() + .map(|out| out.status.success()) + .unwrap_or(false) +} + +fn encoder_is_compiled_in(encoders_listing: &str, encoder: VideoEncoder) -> bool { + encoders_listing.contains(encoder.id()) +} + +/// First `/dev/dri/renderD*` node, used by VA-API. +fn find_vaapi_device() -> Option { + let entries = std::fs::read_dir("/dev/dri").ok()?; + let mut candidates: Vec = entries + .flatten() + .map(|entry| entry.path().to_string_lossy().to_string()) + .filter(|path| { + path.rsplit('/') + .next() + .map(|name| name.starts_with("renderD")) + .unwrap_or(false) + }) + .collect(); + candidates.sort(); + candidates.into_iter().next() +} + +/// Verify the encoder can really encode on this machine, not just that it is +/// compiled into ffmpeg. +fn probe_encoder(encoder: VideoEncoder, vaapi_device: Option<&str>) -> bool { + let mut cmd = Command::new("ffmpeg"); + cmd.args([ + "-hide_banner", + "-v", + "error", + "-f", + "lavfi", + "-i", + "color=c=black:s=256x256:r=30:d=0.2", + ]); + + match encoder { + VideoEncoder::Vaapi => { + let Some(device) = vaapi_device else { return false }; + cmd.args(["-vaapi_device", device]); + cmd.args(["-vf", "format=nv12,hwupload"]); + cmd.args(["-c:v", encoder.id(), "-qp", "20"]); + } + VideoEncoder::VideoToolbox => { + cmd.args(["-c:v", encoder.id(), "-allow_sw", "1"]); + } + _ => { + cmd.args(["-c:v", encoder.id(), "-pix_fmt", "yuv420p"]); + } + } + + cmd.args(["-f", "null", "-"]); + + match cmd.output() { + Ok(output) => output.status.success(), + Err(_) => false, + } +} + +fn detect() -> HardwareInfo { + let cpu = HardwareInfo { + video_encoder: VideoEncoder::Libx264, + vaapi_device: None, + }; + + if env_flag("AUTOSHORTS_DISABLE_GPU") || !ffmpeg_available() { + return cpu; + } + + let listing = Command::new("ffmpeg") + .args(["-hide_banner", "-encoders"]) + .output() + .map(|out| String::from_utf8_lossy(&out.stdout).to_string()) + .unwrap_or_default(); + + let vaapi_device = find_vaapi_device(); + + // Prefer the backend that is native to the platform to keep probing cheap. + let candidates: &[VideoEncoder] = if cfg!(target_os = "macos") { + &[ + VideoEncoder::VideoToolbox, + VideoEncoder::Nvenc, + VideoEncoder::Amf, + VideoEncoder::Vaapi, + ] + } else if cfg!(windows) { + &[ + VideoEncoder::Nvenc, + VideoEncoder::Amf, + VideoEncoder::VideoToolbox, + VideoEncoder::Vaapi, + ] + } else { + &[ + VideoEncoder::Nvenc, + VideoEncoder::Vaapi, + VideoEncoder::Amf, + VideoEncoder::VideoToolbox, + ] + }; + + let forced = std::env::var("AUTOSHORTS_VIDEO_ENCODER") + .ok() + .and_then(|value| parse_encoder_override(&value)); + + let ordered: Vec = match forced { + Some(VideoEncoder::Libx264) => vec![VideoEncoder::Libx264], + Some(forced_encoder) => vec![forced_encoder], + None => candidates.to_vec(), + }; + + for encoder in ordered { + if encoder == VideoEncoder::Libx264 { + return cpu; + } + if encoder == VideoEncoder::Vaapi && vaapi_device.is_none() { + continue; + } + if !encoder_is_compiled_in(&listing, encoder) { + continue; + } + if probe_encoder(encoder, vaapi_device.as_deref()) { + return HardwareInfo { + video_encoder: encoder, + vaapi_device: if encoder == VideoEncoder::Vaapi { + vaapi_device.clone() + } else { + None + }, + }; + } + } + + cpu +} + +/// Cached hardware profile. Detection runs once per process. +pub fn info() -> &'static HardwareInfo { + INFO.get_or_init(detect) +} + +/// Encoder to use right now: the detected one, or libx264 once a GPU attempt failed. +pub fn active_video_encoder() -> VideoEncoder { + if ENCODER_FAILED.load(Ordering::Relaxed) { + VideoEncoder::Libx264 + } else { + info().video_encoder + } +} + +/// Demote a hardware encoder after a real render failure so the remaining +/// clips in a batch go straight to the CPU path. +pub fn mark_video_encoder_failed(encoder: VideoEncoder) { + if encoder.is_hardware() { + ENCODER_FAILED.store(true, Ordering::Relaxed); + eprintln!( + "[autoshorts] {} failed at runtime; falling back to libx264", + encoder.id() + ); + } +} + +/// ffmpeg arguments for the given encoder. +pub fn plan(encoder: VideoEncoder) -> EncoderPlan { + match encoder { + VideoEncoder::Libx264 => EncoderPlan { + pre_input: Vec::new(), + codec: vec![ + "-c:v".into(), + "libx264".into(), + "-preset".into(), + "veryfast".into(), + "-crf".into(), + "20".into(), + "-pix_fmt".into(), + "yuv420p".into(), + ], + filter_suffix: "", + }, + VideoEncoder::Nvenc => EncoderPlan { + pre_input: Vec::new(), + codec: vec![ + "-c:v".into(), + "h264_nvenc".into(), + "-preset".into(), + "p4".into(), + "-tune".into(), + "hq".into(), + "-rc".into(), + "vbr".into(), + "-cq".into(), + "20".into(), + "-b:v".into(), + "0".into(), + "-pix_fmt".into(), + "yuv420p".into(), + ], + filter_suffix: "", + }, + VideoEncoder::VideoToolbox => EncoderPlan { + pre_input: Vec::new(), + codec: vec![ + "-c:v".into(), + "h264_videotoolbox".into(), + "-b:v".into(), + "8M".into(), + "-allow_sw".into(), + "1".into(), + "-pix_fmt".into(), + "yuv420p".into(), + ], + filter_suffix: "", + }, + VideoEncoder::Amf => EncoderPlan { + pre_input: Vec::new(), + codec: vec![ + "-c:v".into(), + "h264_amf".into(), + "-quality".into(), + "speed".into(), + "-rc".into(), + "cqp".into(), + "-qp_i".into(), + "20".into(), + "-qp_p".into(), + "22".into(), + "-pix_fmt".into(), + "yuv420p".into(), + ], + filter_suffix: "", + }, + VideoEncoder::Vaapi => { + let device = info().vaapi_device.clone().unwrap_or_default(); + if device.is_empty() { + // No render node: silently use the CPU encoder instead. + return plan(VideoEncoder::Libx264); + } + EncoderPlan { + pre_input: vec!["-vaapi_device".into(), device], + codec: vec![ + "-c:v".into(), + "h264_vaapi".into(), + "-qp".into(), + "20".into(), + ], + // VA-API needs system frames uploaded to the device. + filter_suffix: ",format=nv12,hwupload", + } + } + } +} + +/// Device to hand to Whisper: `cuda`, `mps` or `cpu`. +/// +/// Uses the Python interpreter's own PyTorch build as the source of truth, so a +/// CPU-only torch install never selects a GPU. Cached after the first probe. +pub fn whisper_device() -> &'static str { + whisper_device_for(None) +} + +/// Like [`whisper_device`], but probes `python` first. +/// +/// The interpreter that will actually run Whisper is the only one whose PyTorch +/// build matters, so callers pass it when they know it. +pub fn whisper_device_for(python: Option<&str>) -> &'static str { + WHISPER_DEVICE.get_or_init(|| { + if let Ok(forced) = std::env::var("AUTOSHORTS_WHISPER_DEVICE") { + match parse_torch_device(&forced) { + "cuda" => return "cuda".to_string(), + "mps" => return "mps".to_string(), + _ => return "cpu".to_string(), + } + } + if env_flag("AUTOSHORTS_DISABLE_GPU") { + return "cpu".to_string(); + } + + const PROBE: &str = "import torch\n\ +print('cuda' if torch.cuda.is_available() else ('mps' if getattr(torch.backends, 'mps', None) and torch.backends.mps.is_available() else 'cpu'))\n"; + + let mut candidates: Vec<&str> = Vec::new(); + if let Some(bin) = python { + candidates.push(bin); + } + candidates.extend_from_slice(if cfg!(windows) { + &["python", "py", "python3"] + } else { + &["python3", "python"] + }); + + for cmd in candidates { + if let Ok(output) = Command::new(cmd).args(["-c", PROBE]).output() { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + return parse_torch_device(&stdout).to_string(); + } + } + } + "cpu".to_string() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_known_encoder_overrides() { + assert_eq!(parse_encoder_override("nvenc"), Some(VideoEncoder::Nvenc)); + assert_eq!(parse_encoder_override(" NVENC "), Some(VideoEncoder::Nvenc)); + assert_eq!(parse_encoder_override("x264"), Some(VideoEncoder::Libx264)); + assert_eq!(parse_encoder_override("cpu"), Some(VideoEncoder::Libx264)); + assert_eq!( + parse_encoder_override("h264_vaapi"), + Some(VideoEncoder::Vaapi) + ); + assert_eq!( + parse_encoder_override("videotoolbox"), + Some(VideoEncoder::VideoToolbox) + ); + assert_eq!(parse_encoder_override("amf"), Some(VideoEncoder::Amf)); + } + + #[test] + fn rejects_unknown_encoder_overrides() { + assert_eq!(parse_encoder_override(""), None); + assert_eq!(parse_encoder_override("vulkan"), None); + } + + #[test] + fn maps_torch_probe_output_to_device() { + assert_eq!(parse_torch_device("cuda\n"), "cuda"); + assert_eq!(parse_torch_device(" CUDA "), "cuda"); + assert_eq!(parse_torch_device("mps"), "mps"); + assert_eq!(parse_torch_device("cpu"), "cpu"); + assert_eq!(parse_torch_device(""), "cpu"); + assert_eq!(parse_torch_device("Traceback..."), "cpu"); + } + + #[test] + fn libx264_plan_is_the_cpu_baseline() { + let plan = plan(VideoEncoder::Libx264); + assert!(plan.codec.contains(&"libx264".to_string())); + assert!(plan.codec.contains(&"-crf".to_string())); + assert!(plan.pre_input.is_empty()); + assert!(plan.filter_suffix.is_empty()); + } + + #[test] + fn hardware_plans_select_their_encoder() { + assert!(plan(VideoEncoder::Nvenc) + .codec + .contains(&"h264_nvenc".to_string())); + assert!(plan(VideoEncoder::Amf) + .codec + .contains(&"h264_amf".to_string())); + assert!(plan(VideoEncoder::VideoToolbox) + .codec + .contains(&"h264_videotoolbox".to_string())); + } + + #[test] + fn vaapi_plan_uploads_frames_and_sets_device() { + // Without a render node the plan degrades to the CPU encoder. + let plan = plan(VideoEncoder::Vaapi); + if info().vaapi_device.is_some() { + assert!(plan.codec.contains(&"h264_vaapi".to_string())); + assert_eq!(plan.filter_suffix, ",format=nv12,hwupload"); + assert!(plan.pre_input.contains(&"-vaapi_device".to_string())); + } else { + assert!(plan.codec.contains(&"libx264".to_string())); + } + } + + #[test] + fn cpu_encoder_is_not_hardware() { + assert!(!VideoEncoder::Libx264.is_hardware()); + assert!(VideoEncoder::Nvenc.is_hardware()); + } + + #[test] + fn detected_encoder_is_stable_across_calls() { + assert_eq!(active_video_encoder(), active_video_encoder()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ed01ead..d233fd3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,17 +1,26 @@ -mod db; -mod llm; -mod media; -mod models; -mod transcription; - +pub mod autoedit; +pub mod db; +pub mod hardware; +pub mod llm; +pub mod media; +pub mod media_server; +pub mod models; +pub mod naming; +pub mod progress; +pub mod python_env; +pub mod transcription; + +use std::collections::HashMap; use std::path::PathBuf; +use std::sync::{Arc, Mutex}; use anyhow::{anyhow, Context, Result}; use tauri::{Emitter, Manager}; use db::Database; use models::{ - Candidate, EnvironmentStatus, MediaProbe, NormalizedTranscript, Project, ProjectDetail, + Candidate, CandidateThumbnail, CaptionCue, CaptionStyleOverride, ClipEdit, ClipEditor, + ClipPreview, EnvironmentStatus, MediaProbe, NormalizedTranscript, Project, ProjectDetail, Transcript, TranscriptWord, }; @@ -27,6 +36,26 @@ struct PullProgressPayload { struct AppState { db: Database, data_dir: PathBuf, + /// Loopback HTTP server used to hand media files to the webview, which + /// cannot open Tauri's `asset://` scheme from its media pipeline. + media_server: media_server::MediaServer, + /// Per-project preview render slots. The app pre-renders the proxy in the + /// background and the editor asks for the same file, so both callers must + /// queue instead of running two ffmpeg jobs on one output path. + preview_locks: Arc>>>>, +} + +impl AppState { + fn preview_lock(&self, project_id: &str) -> Arc> { + let mut locks = self + .preview_locks + .lock() + .expect("preview lock mutex poisoned"); + locks + .entry(project_id.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() + } } #[tauri::command] @@ -55,13 +84,51 @@ async fn environment_status(state: tauri::State<'_, AppState>) -> Result) -> python_env::PythonEnvStatus { + python_env::status(&state.data_dir) +} + +/// Provisions the managed venv and installs `openai-whisper` into it. Blocking +/// (pip + a ~1-2 GB PyTorch download), so it runs off the async runtime. +#[tauri::command] +async fn install_python_env( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, +) -> Result<(), String> { + let data_dir = state.data_dir.clone(); + tokio::task::spawn_blocking(move || python_env::install(&app, &data_dir)) + .await + .map_err(to_command_error)? + .map_err(to_command_error) +} + +#[tauri::command] +fn list_whisper_models() -> Vec { + transcription::list_whisper_models() +} + +#[tauri::command] +async fn download_whisper_model(app: tauri::AppHandle, model_name: String) -> Result<(), String> { + transcription::download_whisper_model(app, model_name) + .await + .map_err(|error| error.to_string()) +} + #[tauri::command] async fn pull_ollama_model( app: tauri::AppHandle, @@ -314,6 +381,7 @@ fn get_project_detail( #[tauri::command] fn probe_project( + app: tauri::AppHandle, state: tauri::State<'_, AppState>, project_id: String, ) -> Result { @@ -321,16 +389,19 @@ fn probe_project( .db .get_project(&project_id) .map_err(to_command_error)?; + progress::emit(&app, "import", "Analyzing media…", None); let probe = media::probe_media(&project.source_path).map_err(to_command_error)?; state .db .update_project_status(&project_id, "ingest", probe.duration_sec) .map_err(to_command_error)?; + progress::finish(&app, "import", "Media ready"); Ok(probe) } #[tauri::command] fn extract_project_audio( + app: tauri::AppHandle, state: tauri::State<'_, AppState>, project_id: String, ) -> Result { @@ -338,23 +409,36 @@ fn extract_project_audio( .db .get_project(&project_id) .map_err(to_command_error)?; - let audio_path = media::extract_audio(&project.source_path, &project_dir(&state, &project_id)) - .map_err(to_command_error)?; + let progress_app = app.clone(); + let audio_path = media::extract_audio( + &project.source_path, + &project_dir(&state, &project_id), + move |percentage| { + progress::emit(&progress_app, "extract", "Extracting audio…", Some(percentage)); + }, + ) + .map_err(to_command_error)?; + progress::finish(&app, "extract", "Audio extracted"); Ok(audio_path.to_string_lossy().to_string()) } #[tauri::command] async fn transcribe_project( + app: tauri::AppHandle, state: tauri::State<'_, AppState>, project_id: String, provider: String, api_key: Option, + whisper_model: Option, ) -> Result { let db = state.db.clone(); let data_dir = state.data_dir.clone(); let project = db.get_project(&project_id).map_err(to_command_error)?; db.update_project_status(&project_id, "transcribing", None) .map_err(to_command_error)?; + let total_duration = media::probe_media(&project.source_path) + .ok() + .and_then(|probe| probe.duration_sec); let transcript = match provider.as_str() { "deepgram" => { @@ -364,11 +448,16 @@ async fn transcribe_project( "Set DEEPGRAM_API_KEY or paste an API key to use cloud transcription." .to_string() })?; + let extract_app = app.clone(); let audio_path = media::extract_audio( &project.source_path, &data_dir.join("projects").join(&project_id), + move |percentage| { + progress::emit(&extract_app, "extract", "Extracting audio…", Some(percentage)); + }, ) .map_err(to_command_error)?; + progress::emit(&app, "transcribe", "Transcribing with Deepgram…", None); transcription::transcribe_deepgram(&audio_path.to_string_lossy(), &key) .await .map_err(to_command_error)? @@ -376,20 +465,41 @@ async fn transcribe_project( "local" => { let has_whisper = transcription::whisper_cli_exists() || transcription::whisper_python_exists(); if !has_whisper { - return Err("Whisper is not installed. Please install it (e.g., via Homebrew 'brew install whisper-cli' or via Python 'pip3 install openai-whisper').".to_string()); + return Err("Whisper não está instalado. Abra as configurações e clique em \"Instalar Whisper\" para baixar as dependências Python automaticamente.".to_string()); } + let extract_app = app.clone(); let audio_path = media::extract_audio( &project.source_path, &data_dir.join("projects").join(&project_id), + move |percentage| { + progress::emit(&extract_app, "extract", "Extracting audio…", Some(percentage)); + }, ) .map_err(to_command_error)?; - transcription::transcribe_local(&audio_path.to_string_lossy(), &data_dir.to_string_lossy()) - .await - .map_err(to_command_error)? + let model = whisper_model.unwrap_or_else(|| "base".to_string()); + let transcribe_app = app.clone(); + transcription::transcribe_local( + &audio_path.to_string_lossy(), + &data_dir.to_string_lossy(), + &model, + total_duration, + Some(Box::new(move |percentage: f64| { + progress::emit( + &transcribe_app, + "transcribe", + "Transcribing audio…", + Some(percentage), + ); + })), + ) + .await + .map_err(to_command_error)? } other => return Err(format!("Unsupported transcription provider: {other}")), }; + progress::finish(&app, "transcribe", "Transcription complete"); + let raw_json = serde_json::to_string_pretty(&transcript).map_err(to_command_error)?; let saved = db .save_transcript( @@ -424,14 +534,17 @@ fn save_demo_transcript( #[tauri::command] async fn generate_candidates( + app: tauri::AppHandle, state: tauri::State<'_, AppState>, project_id: String, api_key: Option, provider: Option, model_name: Option, + base_url: Option, _allow_demo: bool, ) -> Result, String> { let db = state.db.clone(); + progress::emit(&app, "moments", "Finding viral moments…", None); let transcript = db .latest_transcript(&project_id) .map_err(to_command_error)? @@ -493,6 +606,21 @@ async fn generate_candidates( .await .map_err(to_command_error)? } + "openai-compatible" | "openai_compatible" | "compatible" | "custom" => { + // API keys are optional here: local servers (LM Studio, vLLM, llama.cpp) usually need none. + let key = api_key + .or_else(|| std::env::var("OPENAI_COMPATIBLE_API_KEY").ok()) + .or_else(|| std::env::var("OPENAI_API_KEY").ok()) + .filter(|value| !value.trim().is_empty()); + llm::detect_candidates_with_openai_compatible( + &normalized, + key.as_deref(), + model_name.as_deref(), + base_url.as_deref(), + ) + .await + .map_err(to_command_error)? + } _ => { let key = api_key .or_else(|| std::env::var("DEEPSEEK_API_KEY").ok()) @@ -512,6 +640,7 @@ async fn generate_candidates( .map_err(to_command_error)?; db.update_project_status(&project_id, "ready", None) .map_err(to_command_error)?; + progress::finish(&app, "moments", "Viral moments ready"); Ok(candidates) } @@ -527,8 +656,309 @@ fn set_selected_clip_count( .map_err(to_command_error) } +#[tauri::command] +fn get_clip_editor( + state: tauri::State<'_, AppState>, + candidate_id: String, +) -> Result { + let (candidate, project) = state + .db + .get_candidate_with_project(&candidate_id) + .map_err(to_command_error)?; + let edit = state + .db + .clip_edit(&candidate_id) + .map_err(to_command_error)? + .unwrap_or_else(|| ClipEdit::default_for(&candidate_id)); + + // Saved cues win; otherwise fall back to auto cues from the transcript. + let cues = if edit.captions.is_empty() { + auto_caption_cues(&state.db, &candidate).unwrap_or_default() + } else { + edit.captions.clone() + }; + + let caption_style = edit + .caption_style + .clone() + .or_else(|| project.caption_style.clone()) + .filter(|style| !style.trim().is_empty()) + .unwrap_or_else(|| "modern-box".to_string()); + + let probe = media::probe_media(&project.source_path).ok(); + + Ok(ClipEditor { + candidate, + source_path: project.source_path, + edit, + cues, + caption_style, + source_width: probe.as_ref().and_then(|probe| probe.width), + source_height: probe.as_ref().and_then(|probe| probe.height), + source_duration_sec: probe + .as_ref() + .and_then(|probe| probe.duration_sec) + .or(project.source_duration), + source_video_codec: probe.as_ref().and_then(|probe| probe.video_codec.clone()), + source_audio_codec: probe.as_ref().and_then(|probe| probe.audio_codec.clone()), + }) +} + +/// Produce (and cache) a VP8/Opus proxy of the whole project source. +/// +/// The editor needs a moving picture before the cut, but webviews only decode +/// the codecs their platform ships: WebKitGTK on a stock Linux install has no +/// H.264/AAC decoder, so an MP4 source renders a black frame. +/// +/// One proxy per project serves every candidate: the editor seeks to the clip +/// start inside it instead of waiting for a per-clip transcode. The returned +/// URL goes through the loopback media server because WebKitGTK's media +/// pipeline cannot open Tauri's `asset://` scheme. +#[tauri::command] +async fn prepare_project_preview( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, + project_id: String, +) -> Result { + let db = state.db.clone(); + let data_dir = state.data_dir.clone(); + let server = state.media_server.clone(); + let job = state.preview_lock(&project_id); + + tokio::task::spawn_blocking(move || { + let project = db.get_project(&project_id).map_err(to_command_error)?; + + let duration = media::probe_media(&project.source_path) + .ok() + .and_then(|probe| probe.duration_sec) + .unwrap_or(0.0); + + let previews_dir = data_dir.join("projects").join(&project.id).join("previews"); + let output_path = previews_dir.join("source.webm"); + + // A background pre-render may already own this project: wait for it + // instead of transcoding the same file twice. + let _guard = job.lock().expect("preview job mutex poisoned"); + + if !output_path.is_file() { + let label = "Preparando pré-visualização…"; + progress::emit(&app, "preview", label, Some(0.0)); + media::render_preview_proxy( + &project.source_path, + &output_path, + Some(duration), + |percentage| progress::emit(&app, "preview", label, Some(percentage)), + ) + .map_err(|error| error.to_string())?; + progress::finish(&app, "preview", label); + } + + Ok(output_path) + }) + .await + .map_err(|error| error.to_string())? + .map(|path| { + let url = server.url_for(&path); + ClipPreview { + path: path.to_string_lossy().to_string(), + url, + start_sec: 0.0, + end_sec: 0.0, + } + }) +} + +/// Produce (and cache) one still frame per candidate for the clip list. +/// +/// The list used to show a grey `9:16` placeholder; a real frame at 35% of the +/// window makes moments recognisable at a glance. Frames are cached on disk +/// and re-rendered only when the clip window moves. +#[tauri::command] +async fn prepare_project_thumbnails( + state: tauri::State<'_, AppState>, + project_id: String, +) -> Result, String> { + let db = state.db.clone(); + let data_dir = state.data_dir.clone(); + let server = state.media_server.clone(); + + tokio::task::spawn_blocking(move || { + let project = db.get_project(&project_id).map_err(to_command_error)?; + let candidates = db.list_candidates(&project_id).map_err(to_command_error)?; + let thumbs_dir = data_dir + .join("projects") + .join(&project.id) + .join("thumbs"); + std::fs::create_dir_all(&thumbs_dir).map_err(|error| error.to_string())?; + + let mut thumbnails = Vec::with_capacity(candidates.len()); + for candidate in candidates { + let span = (candidate.end_sec - candidate.start_sec).max(0.0); + let time_sec = candidate.start_sec + span * 0.35; + let path = thumbs_dir.join(format!("{}.jpg", candidate.id)); + let cached = path + .metadata() + .map(|meta| meta.len() > 0) + .unwrap_or(false); + if !cached { + if let Err(error) = media::render_thumbnail(&project.source_path, time_sec, &path) { + eprintln!( + "[thumbnails] skipping {}: {error}", + candidate.id + ); + continue; + } + } + let url = server.url_for(&path); + thumbnails.push(CandidateThumbnail { + candidate_id: candidate.id, + path: path.to_string_lossy().to_string(), + url, + time_sec, + }); + } + + Ok(thumbnails) + }) + .await + .map_err(|error| error.to_string())? +} + +/// Copy one clip's caption look onto every candidate of the project. +/// +/// Repeating the same font, colours and box settings on ten clips by hand is +/// busywork, so the editor offers a single "apply to all" action. Candidates +/// without a saved edit get one, which also keeps the list badge in sync. +#[tauri::command] +fn apply_caption_style_to_project( + state: tauri::State<'_, AppState>, + project_id: String, + caption_style: Option, + caption_override: Option, +) -> Result, String> { + let candidates = state + .db + .list_candidates(&project_id) + .map_err(to_command_error)?; + + let mut saved = Vec::with_capacity(candidates.len()); + for candidate in candidates { + let mut edit = state + .db + .clip_edit(&candidate.id) + .map_err(to_command_error)? + .unwrap_or_else(|| ClipEdit::default_for(&candidate.id)); + edit.caption_style = caption_style.clone(); + edit.caption_override = caption_override.clone(); + saved.push( + state + .db + .save_clip_edit(&edit) + .map_err(to_command_error)?, + ); + } + + Ok(saved) +} + +/// Move a candidate's clip window, so a cut can be stretched over a whole +/// sentence instead of stopping mid-word. +#[tauri::command] +fn update_candidate_range( + state: tauri::State<'_, AppState>, + candidate_id: String, + start_sec: f64, + end_sec: f64, +) -> Result { + let candidate = state + .db + .update_candidate_range(&candidate_id, start_sec, end_sec) + .map_err(to_command_error)?; + + // The cached frame was taken inside the old window; drop it so the list + // re-renders the thumbnail on its next pass. + let _ = std::fs::remove_file( + state + .data_dir + .join("projects") + .join(&candidate.project_id) + .join("thumbs") + .join(format!("{}.jpg", candidate.id)), + ); + + Ok(candidate) +} + +/// Re-derive transcript captions for an arbitrary window, used when the user +/// stretches a clip and wants the lines that now fall inside it. +#[tauri::command] +fn caption_cues_for_range( + state: tauri::State<'_, AppState>, + project_id: String, + start_sec: f64, + end_sec: f64, +) -> Result, String> { + let Some(record) = state + .db + .latest_transcript(&project_id) + .map_err(to_command_error)? + else { + return Ok(Vec::new()); + }; + let normalized: NormalizedTranscript = + serde_json::from_str(&record.raw_json).map_err(|error| error.to_string())?; + Ok(cues_from_words(&normalized.words, start_sec, end_sec)) +} + +#[tauri::command] +fn save_clip_edit(state: tauri::State<'_, AppState>, edit: ClipEdit) -> Result { + state.db.save_clip_edit(&edit).map_err(to_command_error) +} + +/// Derive editor cues from the latest transcript, three words per line. +fn auto_caption_cues(db: &Database, candidate: &Candidate) -> Result> { + let Some(record) = db.latest_transcript(&candidate.project_id)? else { + return Ok(Vec::new()); + }; + let normalized: NormalizedTranscript = serde_json::from_str(&record.raw_json)?; + Ok(cues_from_words( + &normalized.words, + candidate.start_sec, + candidate.end_sec, + )) +} + +/// Chunk transcript words into absolute-time cues inside the clip window. +fn cues_from_words(words: &[TranscriptWord], start_sec: f64, end_sec: f64) -> Vec { + words + .iter() + .filter(|word| word.end > start_sec && word.start < end_sec) + .collect::>() + .chunks(3) + .filter_map(|chunk| { + let first = chunk.first()?; + let last = chunk.last()?; + let start = first.start.max(start_sec); + let end = last.end.min(end_sec); + if end <= start { + return None; + } + Some(CaptionCue { + start, + end, + text: chunk + .iter() + .map(|word| word.text.as_str()) + .collect::>() + .join(" "), + }) + }) + .collect() +} + #[tauri::command] async fn render_flat_clip_for_candidate( + app: tauri::AppHandle, state: tauri::State<'_, AppState>, candidate_id: String, ) -> Result { @@ -539,82 +969,209 @@ async fn render_flat_clip_for_candidate( let (candidate, project) = db .get_candidate_with_project(&candidate_id) .map_err(to_command_error)?; + let edit = db + .clip_edit(&candidate_id) + .map_err(to_command_error)? + .unwrap_or_else(|| ClipEdit::default_for(&candidate_id)); db .update_clip_for_candidate(&candidate_id, "cutting", None, None, None) .map_err(to_command_error)?; + let clip_label = format!("Rendering clip #{}…", candidate.rank); + progress::emit(&app, "cut", clip_label.clone(), Some(0.0)); + + let output_path = match documents_project_dir(&project) { + Ok(dir) => { + let clips_dir = dir.join("clips"); + // Name the file after the project, the clip rank and the hook + // so the exported videos are identifiable outside the app. + // A renamed project wins over the source file stem. + let project_label = project + .name + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| project_output_slug(&project)); + let file_name = naming::clip_file_name( + &project_label, + candidate.rank, + &candidate.hook, + candidate.start_sec, + candidate.end_sec, + ); + // Deterministic name on purpose: re-cutting a clip replaces its + // previous render instead of piling up `-2`, `-3` duplicates. + clips_dir.join(file_name) + } + Err(error) => { + // Surface the failure as a clip error instead of leaving the + // candidate stuck in `cutting` with a frozen 0% bar. + let _ = db.update_clip_for_candidate(&candidate_id, "error", None, None, Some(&error)); + return Err(error); + } + }; - let output_path = documents_project_dir(&project)? - .join("clips") - .join(format!("clip-{:02}_flat.mp4", candidate.rank)); - - let mut srt_path = None; - let mut drawtext_filters = None; + let mut caption_ass_path: Option = None; + // Probe once and reuse the result for the whole render. let probe = media::probe_media(&project.source_path).ok(); - let cropped_width = if let Some(p) = &probe { - let iw = p.width.unwrap_or(1920) as f64; - let ih = p.height.unwrap_or(1080) as f64; - let w = (iw.min(ih * 9.0 / 16.0) / 2.0).floor() * 2.0; - w as i64 - } else { - 1080 - }; - - if let Ok(Some(transcript_record)) = db.latest_transcript(&project.id) { - if let Ok(normalized) = serde_json::from_str::(&transcript_record.raw_json) { - let srt_content = generate_srt(&normalized.words, candidate.start_sec, candidate.end_sec); - let clip_srt_path = data_dir.join("projects").join(&project.id).join(format!("clip-{}.srt", candidate.id)); - if std::fs::write(&clip_srt_path, srt_content).is_ok() { - srt_path = Some(clip_srt_path); - } - let style = project.caption_style.as_deref().unwrap_or("modern-box"); - let drawtext = build_drawtext_filters( - &normalized.words, + let has_video = probe.as_ref().map(|probe| probe.has_video).unwrap_or(true); + let has_audio = probe + .as_ref() + .map(|probe| probe.audio_codec.is_some()) + .unwrap_or(true); + + let caption_style = edit + .caption_style + .clone() + .or_else(|| project.caption_style.clone()) + .unwrap_or_else(|| "modern-box".to_string()); + let project_data_dir = data_dir.join("projects").join(&project.id); + let _ = std::fs::create_dir_all(&project_data_dir); + + // The transcript feeds both the caption text and the auto-edit speech + // map, so parse it once. + let normalized = db + .latest_transcript(&project.id) + .ok() + .flatten() + .and_then(|record| serde_json::from_str::(&record.raw_json).ok()); + + // Auto-edit is opt-in per clip. A failure (missing ffmpeg, unreadable + // source) degrades to the normal render instead of losing the clip. + let auto_edit_plan = edit + .auto_edit + .clone() + .filter(|settings| settings.enabled) + .and_then(|settings| { + let words = normalized + .as_ref() + .map(|transcript| transcript.words.as_slice()) + .unwrap_or(&[]); + match autoedit::build_plan( + &project.source_path, candidate.start_sec, candidate.end_sec, - cropped_width, - style, - ); - if !drawtext.is_empty() { - drawtext_filters = Some(drawtext); + &settings, + words, + ) { + Ok(plan) if !plan.is_noop() => Some(plan), + Ok(_) => None, + Err(error) => { + eprintln!("[autoshorts] auto-edit skipped: {error}"); + None + } } + }); + + // Captions authored in the editor win; otherwise derive them from the + // latest transcript exactly like before. With auto-edit the cues are + // rewritten onto the compressed timeline and the ASS window becomes + // `0..output_duration`. + let mut cues: Vec = if !edit.captions.is_empty() { + edit.captions.clone() + } else { + normalized + .as_ref() + .map(|transcript| { + cues_from_words(&transcript.words, candidate.start_sec, candidate.end_sec) + }) + .unwrap_or_default() + }; + let (caption_start, caption_end) = match auto_edit_plan.as_ref() { + Some(plan) => { + cues = autoedit::remap_cues(&cues, plan); + (0.0, plan.output_duration) + } + None => (candidate.start_sec, candidate.end_sec), + }; + + let ass_content = if cues.is_empty() { + None + } else { + // Plain-text sidecar kept for the UI / upload metadata. + let srt_content = generate_srt_from_cues(&cues, caption_start, caption_end); + let clip_srt_path = project_data_dir.join(format!("clip-{}.srt", candidate.id)); + let _ = std::fs::write(&clip_srt_path, srt_content); + + // One ASS file replaces the previous drawtext chain: ffmpeg + // evaluates a single `ass` filter per frame. + Some(build_ass_captions_from_cues( + &cues, + caption_start, + caption_end, + &caption_style, + edit.caption_override.as_ref(), + )) + }; + + if let Some(ass_content) = ass_content { + let clip_ass_path = + project_data_dir.join(format!("clip-{:02}_captions.ass", candidate.rank)); + if std::fs::write(&clip_ass_path, ass_content).is_ok() { + caption_ass_path = Some(clip_ass_path); } } - match media::render_flat_clip( - &project.source_path, - candidate.start_sec, - candidate.end_sec, - &output_path, - drawtext_filters.as_deref(), - ) { + let segments: &[autoedit::RenderSegment] = auto_edit_plan + .as_ref() + .map(|plan| plan.segments.as_slice()) + .unwrap_or(&[]); + + let request = media::ClipRenderRequest { + source_path: &project.source_path, + start_sec: candidate.start_sec, + end_sec: candidate.end_sec, + output_path: &output_path, + has_video, + has_audio, + ass_path: caption_ass_path.as_deref(), + aspect_ratio: &edit.aspect_ratio, + crop_x: edit.crop_x, + crop_y: edit.crop_y, + segments, + }; + + match media::render_flat_clip(&request, |percentage| { + progress::emit(&app, "cut", clip_label.clone(), Some(percentage)); + }) { Ok(path) => { let path_string = path.to_string_lossy().to_string(); - let srt_string = srt_path.map(|p| p.to_string_lossy().to_string()); + let ass_string = caption_ass_path + .as_ref() + .map(|p| p.to_string_lossy().to_string()); db .update_clip_for_candidate( &candidate_id, "done", Some(&path_string), - srt_string.as_deref(), + ass_string.as_deref(), None, ) .map_err(to_command_error)?; + progress::finish(&app, "cut", clip_label.clone()); Ok(path_string) } Err(error) => { let err_msg = error.to_string(); - // Fallback retry rendering without captions overlay on any error - match media::render_flat_clip( - &project.source_path, - candidate.start_sec, - candidate.end_sec, - &output_path, - None, - ) { + // Only retry without captions when captions were actually used: + // re-rendering blindly doubled the work on every failure. + if caption_ass_path.is_none() { + db + .update_clip_for_candidate(&candidate_id, "error", None, None, Some(&err_msg)) + .map_err(to_command_error)?; + return Err(err_msg); + } + + let fallback = media::ClipRenderRequest { + ass_path: None, + ..request + }; + match media::render_flat_clip(&fallback, |percentage| { + progress::emit(&app, "cut", clip_label.clone(), Some(percentage)); + }) { Ok(path) => { let path_string = path.to_string_lossy().to_string(); - let srt_string = srt_path.map(|p| p.to_string_lossy().to_string()); let warning_msg = format!( "Clip rendered successfully, but captions were skipped. Error: {}", err_msg @@ -624,10 +1181,11 @@ async fn render_flat_clip_for_candidate( &candidate_id, "done", Some(&path_string), - srt_string.as_deref(), + None, Some(&warning_msg), ) .map_err(to_command_error)?; + progress::finish(&app, "cut", clip_label.clone()); Ok(path_string) } Err(retry_err) => { @@ -659,6 +1217,7 @@ fn rename_project( state.db.rename_project(&project_id, &name).map_err(to_command_error) } +#[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let _ = dotenvy::dotenv(); @@ -671,12 +1230,24 @@ pub fn run() { .context("resolving app data directory")?; std::fs::create_dir_all(&data_dir).context("creating app data directory")?; std::fs::create_dir_all(data_dir.join("models")).context("creating models directory")?; + // Registered before anything can probe for Whisper: the managed + // venv is what transcription should prefer over system Python. + python_env::register_venv_dir(&data_dir); let db = Database::open(&data_dir.join("autoshorts.sqlite"))?; - app.manage(AppState { db, data_dir }); + let media_server = + media_server::MediaServer::start().context("starting the media server")?; + app.manage(AppState { + db, + data_dir, + media_server, + preview_locks: Arc::new(Mutex::new(HashMap::new())), + }); Ok(()) }) .invoke_handler(tauri::generate_handler![ environment_status, + list_whisper_models, + download_whisper_model, pull_ollama_model, install_ollama, create_project_from_path, @@ -688,11 +1259,20 @@ pub fn run() { save_demo_transcript, generate_candidates, set_selected_clip_count, + get_clip_editor, + save_clip_edit, + apply_caption_style_to_project, + update_candidate_range, + caption_cues_for_range, + prepare_project_preview, + prepare_project_thumbnails, render_flat_clip_for_candidate, delete_project, rename_project, check_youtube_copyright, - download_youtube_video + download_youtube_video, + python_env_status, + install_python_env ]) .run(tauri::generate_context!()) .expect("error while running AutoShorts"); @@ -734,8 +1314,9 @@ async fn check_youtube_copyright(url: String) -> Result Result { +async fn download_youtube_video(app: tauri::AppHandle, url: String) -> Result { tokio::task::spawn_blocking(move || { + progress::emit(&app, "youtube", "Downloading video…", None); let downloads_dir = dirs::download_dir() .ok_or_else(|| "Could not find Downloads folder".to_string())?; @@ -766,6 +1347,7 @@ async fn download_youtube_video(url: String) -> Result { return Err("Could not locate downloaded file".to_string()); } + progress::finish(&app, "youtube", "Download complete"); Ok(filepath.to_string()) }) .await @@ -777,11 +1359,23 @@ fn project_dir(state: &AppState, project_id: &str) -> PathBuf { } fn documents_project_dir(project: &Project) -> Result { - let documents_dir = dirs::document_dir() - .ok_or_else(|| "Could not find your Documents folder for clip output.".to_string())?; - Ok(documents_dir - .join("AutoShorts") - .join(project_output_slug(project))) + // Minimal Linux/WSL setups ship without `~/Documents` and without + // `~/.config/user-dirs.dirs`, so `dirs::document_dir()` returns `None`. + // Failing here aborted the render right after the 0% progress event. + let base = dirs::document_dir() + .or_else(|| { + let candidate = dirs::home_dir()?.join("Documents"); + std::fs::create_dir_all(&candidate).ok()?; + Some(candidate) + }) + .or_else(|| { + let fallback = dirs::data_dir()?; + std::fs::create_dir_all(&fallback).ok()?; + Some(fallback) + }) + .ok_or_else(|| "Could not find a folder to write clips to.".to_string())?; + + Ok(base.join("AutoShorts").join(project_output_slug(project))) } fn project_output_slug(project: &Project) -> String { @@ -867,10 +1461,23 @@ fn demo_transcript() -> NormalizedTranscript { } } -fn to_command_error(error: impl std::fmt::Display) -> String { - error.to_string() +fn to_command_error(error: impl Into) -> String { + let error = error.into(); + // `Display` on an `anyhow::Error` prints only the outermost context, so the + // user would just see "calling X" with no cause. Walk the chain instead. + let mut message = error.to_string(); + for cause in error.chain().skip(1) { + let text = cause.to_string(); + if text.is_empty() || message.contains(&text) { + continue; + } + message.push_str(": "); + message.push_str(&text); + } + message } +#[cfg(test)] fn generate_srt(words: &[TranscriptWord], start_sec: f64, end_sec: f64) -> String { let mut srt = String::new(); let mut index = 1; @@ -908,6 +1515,28 @@ fn generate_srt(words: &[TranscriptWord], start_sec: f64, end_sec: f64) -> Strin srt } +/// Serialize already-chunked cues as SRT, rebased onto `start_sec`. Cues are +/// absolute before the auto-edit remap and 0-based after it, so both paths run +/// through this helper. +fn generate_srt_from_cues(cues: &[CaptionCue], start_sec: f64, end_sec: f64) -> String { + let mut srt = String::new(); + let mut index = 1; + + for cue in cues { + let start_rel = (cue.start - start_sec).max(0.0); + let end_rel = (cue.end - start_sec).min(end_sec - start_sec).max(0.0); + if end_rel <= start_rel { + continue; + } + srt.push_str(&format!("{index}\n")); + srt.push_str(&format!("{}\n", format_srt_time(start_rel, end_rel))); + srt.push_str(&format!("{}\n\n", cue.text.trim())); + index += 1; + } + + srt +} + fn format_srt_time(start: f64, end: f64) -> String { let format_time = |secs: f64| { let hours = (secs / 3600.0) as u32; @@ -919,6 +1548,315 @@ fn format_srt_time(start: f64, end: f64) -> String { format!("{} --> {}", format_time(start), format_time(end)) } +/// ASS timestamps use `H:MM:SS.cc`. +fn format_ass_time(seconds: f64) -> String { + let total = seconds.max(0.0); + let hours = (total / 3600.0) as u32; + let minutes = ((total % 3600.0) / 60.0) as u32; + let secs = total % 60.0; + format!("{hours}:{minutes:02}:{secs:05.2}") +} + +/// Pick a font family that ships with the OS; libass falls back to its own +/// default when the family is missing. +fn caption_font_name() -> &'static str { + if cfg!(target_os = "windows") { + "Segoe UI" + } else if cfg!(target_os = "macos") { + "Avenir Next" + } else { + "DejaVu Sans" + } +} + +/// Resolved ASS style for the caption line, after preset + user overrides. +struct AssCaptionStyle { + font_family: String, + font_size: f64, + primary_colour: String, + outline_colour: String, + back_colour: String, + border_style: u8, + outline: f64, + shadow: f64, + bold: bool, + margin_v: i64, + margin_lr: i64, +} + +/// ASS canvas the styles are authored against; libass scales it to the clip. +const ASS_CANVAS_WIDTH: f64 = 1080.0; +const ASS_CANVAS_HEIGHT: f64 = 1920.0; + +/// Convert `#RRGGBB` into ASS `&HAABBGGRR`. +fn ass_colour(hex: &str, alpha: u8) -> Option { + let hex = hex.trim().trim_start_matches('#'); + if hex.len() != 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + let red = &hex[0..2]; + let green = &hex[2..4]; + let blue = &hex[4..6]; + Some(format!("&H{alpha:02X}{blue}{green}{red}").to_ascii_uppercase()) +} + +/// Fold the user's overrides into the preset, field by field. +fn apply_caption_override( + mut style: AssCaptionStyle, + over: Option<&crate::models::CaptionStyleOverride>, +) -> AssCaptionStyle { + let Some(over) = over else { return style }; + + if let Some(font) = over.font_family.as_deref() { + style.font_family = font.to_string(); + } + if let Some(size) = over.font_size { + style.font_size = size; + } + if let Some(colour) = over.text_color.as_deref().and_then(|hex| ass_colour(hex, 0x00)) { + style.primary_colour = colour; + } + if let Some(colour) = over.outline_color.as_deref().and_then(|hex| ass_colour(hex, 0x00)) { + style.outline_colour = colour; + } + if let Some(colour) = over.box_color.as_deref().and_then(|hex| ass_colour(hex, 0x50)) { + style.back_colour = colour; + } + if let Some(border) = over.border_style { + style.border_style = border; + } + if let Some(outline) = over.outline { + style.outline = outline; + } + if let Some(bold) = over.bold { + style.bold = bold; + } + if let Some(position) = over.position { + // 0.0 = top, 1.0 = bottom; ASS margins are measured from the bottom. + style.margin_v = ((1.0 - position) * (ASS_CANVAS_HEIGHT / 2.0)).round() as i64; + } + if let Some(width) = over.max_width { + let margin = ((1.0 - width) / 2.0 * ASS_CANVAS_WIDTH).round() as i64; + style.margin_lr = margin.clamp(0, 540); + } + style +} + +/// Mirror the previous drawtext presets in ASS, using a 1080x1920 canvas so +/// libass scales them to whatever resolution the clip is encoded at. +fn ass_caption_style(name: &str) -> AssCaptionStyle { + // Defaults describe the `modern-box` preset. + let mut style = AssCaptionStyle { + font_family: caption_font_name().to_string(), + font_size: 81.0, + primary_colour: "&H00FFFFFF".to_string(), + outline_colour: "&H00000000".to_string(), + back_colour: "&H50000000".to_string(), + border_style: 3, + outline: 10.0, + shadow: 0.0, + bold: true, + margin_v: 458, + margin_lr: 40, + }; + + match name { + "classic-outline" => { + style.primary_colour = "&H0000FFFF".to_string(); // yellow + style.back_colour = "&H80000000".to_string(); + style.border_style = 1; + style.outline = 5.0; + style.shadow = 0.0; + style.margin_v = 592; + } + "minimal-shadow" => { + style.back_colour = "&H80000000".to_string(); + style.border_style = 1; + style.outline = 0.0; + style.shadow = 3.0; + style.margin_v = 496; + } + "vibrant-cyan" => { + style.primary_colour = "&H00FFFF00".to_string(); + style.back_colour = "&H80000000".to_string(); + style.border_style = 1; + style.outline = 0.0; + style.shadow = 3.0; + style.margin_v = 496; + } + "vibrant-yellow-box" => { + style.primary_colour = "&H00000000".to_string(); // black on yellow box + style.back_colour = "&H1F00FFFF".to_string(); + style.border_style = 3; + style.outline = 10.0; + style.shadow = 0.0; + style.margin_v = 458; + } + "vibrant-green" => { + style.primary_colour = "&H0014FF39".to_string(); + style.back_colour = "&H80000000".to_string(); + style.border_style = 1; + style.outline = 4.0; + style.shadow = 2.0; + style.margin_v = 496; + } + "vibrant-red" => { + style.primary_colour = "&H00303BFF".to_string(); + style.back_colour = "&H80000000".to_string(); + style.border_style = 1; + style.outline = 4.0; + style.shadow = 2.0; + style.margin_v = 496; + } + _ => {} + } + + style +} + +fn ass_escape_text(text: &str) -> String { + text.chars() + .filter(|c| !matches!(c, '{' | '}' | '\\' | '\n' | '\r')) + .collect() +} + +/// Build an ASS subtitle file for the clip. +/// +/// Replaces the previous "one drawtext filter per word pair" approach: ffmpeg +/// now evaluates a single `ass` filter per frame, which is several times faster +/// on long clips. +#[cfg(test)] +fn build_ass_captions( + words: &[TranscriptWord], + start_sec: f64, + end_sec: f64, + caption_style: &str, + over: Option<&crate::models::CaptionStyleOverride>, +) -> String { + let mut ass = ass_document_header(caption_style, over); + let uppercase = over.and_then(|value| value.uppercase).unwrap_or(true); + + let candidate_words: Vec<&TranscriptWord> = words + .iter() + .filter(|w| w.end > start_sec && w.start < end_sec) + .collect(); + + for chunk in candidate_words.chunks(2) { + if chunk.is_empty() { + continue; + } + let first = chunk[0]; + let last = chunk[chunk.len() - 1]; + let start_rel = (first.start - start_sec).max(0.0); + let end_rel = (last.end - start_sec).min(end_sec - start_sec).max(0.0); + if end_rel <= start_rel { + continue; + } + let text = chunk + .iter() + .map(|w| w.text.as_str()) + .collect::>() + .join(" "); + let text = if uppercase { text.to_uppercase() } else { text }; + push_ass_dialogue(&mut ass, start_rel, end_rel, &text); + } + + ass +} + +/// Build an ASS subtitle file from explicit caption cues. +/// +/// Cues are timed in absolute source seconds; they are filtered to the clip +/// window, clamped into it, and rendered uppercase like the transcript-derived +/// captions. +pub fn build_ass_captions_from_cues( + cues: &[CaptionCue], + start_sec: f64, + end_sec: f64, + style_name: &str, + over: Option<&crate::models::CaptionStyleOverride>, +) -> String { + let mut ass = ass_document_header(style_name, over); + let uppercase = over.and_then(|value| value.uppercase).unwrap_or(true); + + for cue in cues { + if cue.end <= start_sec || cue.start >= end_sec { + continue; + } + let text = cue.text.trim(); + if text.is_empty() { + continue; + } + let start_rel = (cue.start - start_sec).max(0.0); + let end_rel = cue.end.min(end_sec) - start_sec; + if end_rel <= start_rel { + continue; + } + let text = if uppercase { + text.to_uppercase() + } else { + text.to_string() + }; + push_ass_dialogue(&mut ass, start_rel, end_rel, &text); + } + + ass +} + +/// Script info + styles + event header shared by every ASS writer. +fn ass_document_header( + caption_style: &str, + over: Option<&crate::models::CaptionStyleOverride>, +) -> String { + let style = apply_caption_override(ass_caption_style(caption_style), over); + let bold = if style.bold { -1 } else { 0 }; + + let mut ass = String::new(); + ass.push_str("[Script Info]\n"); + ass.push_str("ScriptType: v4.00+\n"); + ass.push_str(&format!("PlayResX: {}\n", ASS_CANVAS_WIDTH as i64)); + ass.push_str(&format!("PlayResY: {}\n", ASS_CANVAS_HEIGHT as i64)); + ass.push_str("WrapStyle: 2\n"); + ass.push_str("ScaledBorderAndShadow: yes\n\n"); + + ass.push_str("[V4+ Styles]\n"); + ass.push_str( + "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, \ + BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, \ + BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n", + ); + ass.push_str(&format!( + "Style: Default,{font},{size},{primary},&H000000FF,{outline_c},{back},{bold},0,0,0,\ + 100,100,0,0,{border},{outline_w},{shadow},2,{margin_lr},{margin_lr},{margin_v},1\n\n", + font = style.font_family, + size = style.font_size.round() as i64, + primary = style.primary_colour, + outline_c = style.outline_colour, + back = style.back_colour, + border = style.border_style, + outline_w = style.outline, + shadow = style.shadow, + margin_lr = style.margin_lr, + margin_v = style.margin_v, + )); + + ass.push_str("[Events]\n"); + ass.push_str("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"); + ass +} + +fn push_ass_dialogue(ass: &mut String, start_rel: f64, end_rel: f64, text: &str) { + ass.push_str(&format!( + "Dialogue: 0,{},{},Default,,0,0,0,,{}\n", + format_ass_time(start_rel), + format_ass_time(end_rel), + ass_escape_text(text) + )); +} + +/// Legacy drawtext builder, kept only as a reference for the old caption +/// presets. Rendering now burns a single ASS file instead. +#[allow(dead_code)] fn build_drawtext_filters( words: &[TranscriptWord], start_sec: f64, @@ -1070,22 +2008,41 @@ mod tests { use super::*; use crate::models::TranscriptWord; + fn word(text: &str, start: f64, end: f64) -> TranscriptWord { + TranscriptWord { + text: text.to_string(), + start, + end, + speaker: Some("S1".to_string()), + } + } + + fn project(source_path: &str) -> Project { + Project { + id: "project-id".to_string(), + name: None, + source_path: source_path.to_string(), + source_duration: Some(120.0), + status: "ingest".to_string(), + transcription_mode: "local".to_string(), + caption_style: Some("modern-box".to_string()), + created_at: "2024-01-01T00:00:00Z".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + } + } + + #[test] + fn command_errors_include_the_underlying_cause() { + let error = anyhow::anyhow!("connection refused").context("calling endpoint"); + assert_eq!( + to_command_error(error), + "calling endpoint: connection refused" + ); + } + #[test] fn test_build_drawtext_filters_formatting() { - let words = vec![ - TranscriptWord { - text: "Hello".to_string(), - start: 0.0, - end: 1.0, - speaker: None, - }, - TranscriptWord { - text: "world".to_string(), - start: 1.0, - end: 2.0, - speaker: None, - }, - ]; + let words = vec![word("Hello", 0.0, 1.0), word("world", 1.0, 2.0)]; let result = build_drawtext_filters(&words, 0.0, 5.0, 1080, "classic-outline"); assert!(!result.is_empty()); @@ -1097,4 +2054,209 @@ mod tests { assert!(!result.contains("\\:")); } } + + #[test] + fn format_srt_time_uses_comma_milliseconds() { + assert_eq!(format_srt_time(0.0, 1.5), "00:00:00,000 --> 00:00:01,500"); + assert_eq!(format_srt_time(62.5, 3661.25), "00:01:02,500 --> 01:01:01,250"); + } + + #[test] + fn format_ass_time_uses_centiseconds() { + assert_eq!(format_ass_time(0.0), "0:00:00.00"); + assert_eq!(format_ass_time(62.5), "0:01:02.50"); + assert_eq!(format_ass_time(3661.5), "1:01:01.50"); + assert_eq!(format_ass_time(-5.0), "0:00:00.00"); + } + + #[test] + fn generate_srt_chunks_three_words_and_clips_to_window() { + let words = vec![ + word("alpha", 0.0, 0.5), + word("beta", 1.0, 1.5), + word("gamma", 2.0, 2.5), + word("delta", 3.0, 3.5), + word("outside", 10.0, 10.5), + ]; + + let srt = generate_srt(&words, 0.0, 4.0); + assert!(srt.starts_with("1\n00:00:00,000 --> 00:00:02,500\nalpha beta gamma\n\n")); + assert!( + srt.contains("2\n00:00:03,000 --> 00:00:03,500\ndelta\n\n"), + "unexpected srt: {srt:?}" + ); + assert!(!srt.contains("outside")); + } + + #[test] + fn generate_srt_is_empty_without_words_in_window() { + let words = vec![word("alpha", 10.0, 11.0)]; + assert!(generate_srt(&words, 0.0, 5.0).is_empty()); + } + + #[test] + fn ass_escape_text_strips_override_characters() { + assert_eq!(ass_escape_text("a{b}c\\d\ne\rf"), "abcdef"); + } + + #[test] + fn build_ass_captions_contains_header_and_uppercase_dialogue() { + let words = vec![word("hello", 0.0, 0.5), word("world", 0.5, 1.0)]; + let ass = build_ass_captions(&words, 0.0, 5.0, "classic-outline", None); + + assert!(ass.contains("[Script Info]")); + assert!(ass.contains("PlayResX: 1080")); + assert!(ass.contains("PlayResY: 1920")); + assert!(ass.contains("Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0,0,0,,HELLO WORLD")); + } + + #[test] + fn build_ass_captions_skips_degenerate_timings() { + let words = vec![word("zero", 1.0, 1.0)]; + let ass = build_ass_captions(&words, 0.0, 5.0, "modern-box", None); + assert!(!ass.contains("Dialogue:")); + } + + #[test] + fn build_ass_captions_from_cues_uppercases_and_clamps() { + let cues = vec![ + CaptionCue { + start: 0.5, + end: 2.0, + text: "first line".to_string(), + }, + CaptionCue { + start: 4.5, + end: 9.0, + text: "crosses the end".to_string(), + }, + ]; + + let ass = build_ass_captions_from_cues(&cues, 1.0, 5.0, "classic-outline", None); + + assert!(ass.contains("[Script Info]")); + assert!(ass.contains("Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0,0,0,,FIRST LINE")); + assert!(ass.contains("Dialogue: 0,0:00:03.50,0:00:04.00,Default,,0,0,0,,CROSSES THE END")); + } + + #[test] + fn build_ass_captions_from_cues_skips_cues_outside_window() { + let cues = vec![ + CaptionCue { + start: 0.0, + end: 1.0, + text: "before".to_string(), + }, + CaptionCue { + start: 10.0, + end: 11.0, + text: "after".to_string(), + }, + CaptionCue { + start: 2.0, + end: 3.0, + text: " ".to_string(), + }, + ]; + + let ass = build_ass_captions_from_cues(&cues, 5.0, 8.0, "modern-box", None); + + assert!(!ass.contains("Dialogue:")); + assert!(!ass.contains("BEFORE")); + assert!(!ass.contains("AFTER")); + } + + #[test] + fn ass_caption_style_falls_back_to_modern_box() { + let style = ass_caption_style("does-not-exist"); + assert_eq!(style.primary_colour, "&H00FFFFFF"); + assert_eq!(style.border_style, 3); + } + + #[test] + fn ass_colour_converts_rgb_to_ass_bgr_order() { + assert_eq!(ass_colour("#FF0000", 0x00).as_deref(), Some("&H000000FF")); + assert_eq!(ass_colour("00FF00", 0x50).as_deref(), Some("&H5000FF00")); + assert_eq!(ass_colour("#0000ff", 0x00).as_deref(), Some("&H00FF0000")); + assert_eq!(ass_colour("nope", 0x00), None); + } + + #[test] + fn caption_override_rewrites_font_colour_and_position() { + let over = crate::models::CaptionStyleOverride { + font_family: Some("Inter".to_string()), + font_size: Some(120.0), + text_color: Some("#FF0000".to_string()), + outline_color: Some("#00FF00".to_string()), + box_color: Some("#0000FF".to_string()), + border_style: Some(1), + outline: Some(2.0), + bold: Some(false), + uppercase: Some(false), + position: Some(0.0), + max_width: Some(0.5), + }; + + let header = ass_document_header("modern-box", Some(&over)); + assert!(header.contains("Default,Inter,120,&H000000FF"), "{header}"); + assert!(header.contains("&H0000FF00"), "{header}"); + assert!(header.contains("&H50FF0000"), "{header}"); + // Bold off, outline 1, position 0 (top) -> half canvas margin. + assert!(header.contains(",0,0,0,0,100,100,0,0,1,2,0,2,270,270,960,1"), "{header}"); + } + + #[test] + fn caption_override_can_keep_lowercase_text() { + let cues = vec![CaptionCue { + start: 1.0, + end: 2.0, + text: "Mixed Case".to_string(), + }]; + let over = crate::models::CaptionStyleOverride { + uppercase: Some(false), + ..Default::default() + }; + let ass = build_ass_captions_from_cues(&cues, 0.0, 5.0, "modern-box", Some(&over)); + assert!(ass.contains(",,Mixed Case"), "{ass}"); + assert!(!ass.contains("MIXED CASE"), "{ass}"); + } + + #[test] + fn project_output_slug_sanitizes_file_stem() { + assert_eq!( + project_output_slug(&project("/tmp/My Video - Final!.mp4")), + "My-Video---Final" + ); + assert_eq!(project_output_slug(&project("/tmp/episode_01.mkv")), "episode_01"); + } + + #[test] + fn project_output_slug_falls_back_to_project_id() { + assert_eq!(project_output_slug(&project("/")), "project-id"); + } + + #[test] + fn validate_media_extension_accepts_supported_types_case_insensitively() { + for path in ["clip.mp4", "clip.MOV", "audio.mp3", "audio.wav", "audio.m4a"] { + assert!(validate_media_extension(path).is_ok(), "{path} should be accepted"); + } + } + + #[test] + fn validate_media_extension_rejects_unknown_and_missing_extensions() { + let unsupported = validate_media_extension("clip.avi").unwrap_err().to_string(); + assert!(unsupported.contains("Unsupported file type .avi")); + + let missing = validate_media_extension("clip").unwrap_err().to_string(); + assert!(missing.contains("does not have an extension")); + } + + #[test] + fn demo_transcript_has_words_and_segments() { + let transcript = demo_transcript(); + assert!(!transcript.words.is_empty()); + assert!(!transcript.segments.is_empty()); + assert!(transcript.duration > 0.0); + assert_eq!(transcript.speakers, vec!["A".to_string()]); + } } diff --git a/src-tauri/src/llm.rs b/src-tauri/src/llm.rs index 990072a..8659390 100644 --- a/src-tauri/src/llm.rs +++ b/src-tauri/src/llm.rs @@ -1,9 +1,60 @@ use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; use serde_json::json; +use std::time::Duration; use crate::models::{CandidateDraft, NormalizedTranscript, TranscriptSegment}; +const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; + +fn candidate_prompt(segments: &str) -> String { + format!( + "You are an elite, world-class social media strategist with a track record of generating viral multi-million-view Shorts, TikToks, and Reels. \ +Your sole objective is to identify the ABSOLUTE BEST, most highly-engaging, and trend-setting short-form clip candidates from the provided transcript. \ +Do NOT pick random or mediocre segments. Be ruthless in your selection, but extract AS MANY highly viral moments as possible. \ +Every candidate must have an insanely strong, curiosity-inducing hook in the first 3 seconds to stop the scroll. \ +Clips should be 30-90 seconds long, completely self-contained, cut at clean boundaries, and deliver a massive payoff (a mind-blowing fact, hilarious joke, highly controversial opinion, or deep emotional insight). \ +Return up to 25 candidates as JSON matching exactly this schema: \ +{{\"candidates\":[{{\"start\":0.0,\"end\":0.0,\"score\":0.0,\"hook\":\"...\",\"rationale\":\"...\"}}]}} + +Transcript: +{segments}" + ) +} + +/// Accepts `http://host:port`, `http://host:port/v1`, or a full `.../chat/completions` URL. +pub fn normalize_chat_completions_url(base_url: &str) -> String { + let trimmed = base_url.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return format!("{DEFAULT_OPENAI_BASE_URL}/chat/completions"); + } + if trimmed.ends_with("/chat/completions") { + trimmed.to_string() + } else { + format!("{trimmed}/chat/completions") + } +} + +/// Every URL shape worth trying for a user supplied base URL, best guess first. +/// +/// A bare `http://localhost:1234` normalises to `/chat/completions`, but LM +/// Studio, vLLM, llama.cpp and Ollama all mount the route under `/v1`, so the +/// `/v1` form has to be tried first (or the request 404s). +pub fn chat_completion_urls(base_url: &str) -> Vec { + let trimmed = base_url.trim().trim_end_matches('/'); + let has_path = trimmed + .split_once("://") + .map(|(_, rest)| rest.contains('/')) + .unwrap_or(false); + if !trimmed.is_empty() && !has_path { + return vec![ + format!("{trimmed}/v1/chat/completions"), + format!("{trimmed}/chat/completions"), + ]; + } + vec![normalize_chat_completions_url(base_url)] +} + #[derive(Debug, Deserialize)] struct AnthropicMessage { content: Vec, @@ -186,17 +237,413 @@ Transcript: #[derive(Debug, Deserialize)] struct ChatCompletionResponse { + #[serde(default)] choices: Vec, } #[derive(Debug, Deserialize)] struct ChatCompletionChoice { - message: ChatCompletionMessage, + #[serde(default)] + message: Option, + #[serde(default)] + text: Option, } #[derive(Debug, Deserialize)] struct ChatCompletionMessage { - content: String, + #[serde(default)] + content: Option, + #[serde(default)] + reasoning_content: Option, +} + +fn extract_completion_text(res_body: &ChatCompletionResponse) -> Option { + let choice = res_body.choices.first()?; + if let Some(msg) = &choice.message { + if let Some(val) = &msg.content { + let extracted = match val { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Array(parts) => parts + .iter() + .filter_map(|p| p.get("text").and_then(|t| t.as_str())) + .collect::>() + .join(""), + serde_json::Value::Null => String::new(), + other => other.to_string(), + }; + if !extracted.trim().is_empty() { + return Some(extracted); + } + } + if let Some(reasoning) = msg.reasoning_content.as_ref().filter(|r| !r.trim().is_empty()) { + return Some(reasoning.clone()); + } + } + choice.text.clone().filter(|t| !t.trim().is_empty()) +} + +fn body_snippet(body: &str) -> String { + const MAX_CHARS: usize = 800; + let trimmed = body.trim(); + let snippet: String = trimmed.chars().take(MAX_CHARS).collect(); + if trimmed.chars().count() > MAX_CHARS { + format!("{snippet}…") + } else { + snippet + } +} + +/// Parses Server-Sent Events chunk payloads (object: `chat.completion.chunk`) +/// and concatenates the streamed deltas. Used as fallback for servers that +/// stream even when `stream: false` is requested. +fn parse_sse_stream_text(body: &str) -> Option { + let mut content = String::new(); + let mut reasoning = String::new(); + let mut saw_chunk = false; + for line in body.lines() { + let line = line.trim(); + let Some(payload) = line.strip_prefix("data:") else { + continue; + }; + let payload = payload.trim(); + if payload.is_empty() || payload == "[DONE]" { + continue; + } + let Ok(chunk) = serde_json::from_str::(payload) else { + continue; + }; + // Proxies interleave keep-alives and usage frames; ignore anything that is not a delta. + if chunk.get("object").and_then(|o| o.as_str()) != Some("chat.completion.chunk") { + continue; + } + saw_chunk = true; + if let Some(delta) = chunk + .pointer("/choices/0/delta/content") + .and_then(|c| c.as_str()) + { + content.push_str(delta); + } + if let Some(delta) = chunk + .pointer("/choices/0/delta/reasoning_content") + .and_then(|c| c.as_str()) + { + reasoning.push_str(delta); + } + if let Some(text) = chunk.pointer("/choices/0/text").and_then(|t| t.as_str()) { + content.push_str(text); + } + } + if !saw_chunk { + return None; + } + if !content.trim().is_empty() { + Some(content) + } else if !reasoning.trim().is_empty() { + Some(reasoning) + } else { + None + } +} + +/// Reads the raw body first so parse failures can report what the server actually sent +/// (error object, `content: null`, streamed chunks, HTML from a wrong base URL, ...). +async fn read_chat_completion_text( + response: reqwest::Response, + provider_label: &str, +) -> Result { + let body = response + .text() + .await + .with_context(|| format!("reading {provider_label} response body"))?; + let parsed: std::result::Result = serde_json::from_str(&body); + if let Ok(res_body) = &parsed { + if let Some(text) = extract_completion_text(res_body) { + return Ok(text); + } + } + // Servers that stream anyway (Cloudflare-friendly requests, `stream` defaults) land here. + if let Some(text) = parse_sse_stream_text(&body) { + return Ok(text); + } + match parsed { + Err(error) => Err(anyhow!( + "parsing {provider_label} response: {} ({error})", + body_snippet(&body) + )), + Ok(_) => Err(anyhow!( + "{provider_label} response did not include choices content: {}", + body_snippet(&body) + )), + } +} + +fn chat_completion_payload( + model: &str, + prompt: &str, + json_mode: bool, + stream: bool, +) -> serde_json::Value { + let mut payload = json!({ + "model": model, + "messages": [ + { + "role": "user", + "content": prompt, + } + ], + "temperature": 0.2, + "stream": stream, + }); + if json_mode { + payload["response_format"] = json!({ "type": "json_object" }); + } + payload +} + +async fn send_chat_completion( + client: &reqwest::Client, + url: &str, + api_key: Option<&str>, + model: &str, + prompt: &str, + json_mode: bool, + stream: bool, + provider_label: &str, +) -> Result { + let mut request = client + .post(url) + .json(&chat_completion_payload(model, prompt, json_mode, stream)); + if let Some(key) = api_key.map(str::trim).filter(|key| !key.is_empty()) { + request = request.header("Authorization", format!("Bearer {key}")); + } + request + .send() + .await + .with_context(|| format!("calling {provider_label}")) +} + +/// `StatusCode` renders non-canonical codes as `524 `. +fn format_status(status: reqwest::StatusCode) -> String { + match status.canonical_reason() { + Some(reason) => format!("{} {reason}", status.as_u16()), + None => status.as_u16().to_string(), + } +} + +fn status_hint(status: reqwest::StatusCode) -> Option<&'static str> { + match status.as_u16() { + 401 | 403 => Some("the API key was rejected by this endpoint."), + 404 => Some("check the Base URL path (most servers expect a `/v1` suffix)."), + 429 => Some("the provider is rate limiting; wait a moment and try again."), + 520 => Some("Cloudflare: the origin returned an unknown or empty response."), + 521 => Some("Cloudflare: the origin server is down."), + 522 => Some("Cloudflare: the connection to the origin server timed out."), + 523 => Some("Cloudflare: the origin server is unreachable."), + 524 => Some( + "Cloudflare gave up waiting ~100s for the origin. Retrying in streaming mode usually \ + fixes this; otherwise use a faster model or a shorter video.", + ), + 525 | 526 => Some("Cloudflare: TLS handshake with the origin server failed."), + _ => None, + } +} + +fn is_retryable_status(status: reqwest::StatusCode) -> bool { + matches!(status.as_u16(), 429 | 500 | 502 | 503 | 504 | 522 | 524) +} + +/// Long generations send no bytes for over a minute, which is exactly what proxies +/// such as Cloudflare abort with 524. Streaming keeps the connection busy. +fn should_escalate_to_stream(status: reqwest::StatusCode) -> bool { + matches!(status.as_u16(), 504 | 522 | 524) +} + +/// Endpoints that already answered 524 once; they need streaming from the start so the +/// next call does not burn another ~100s waiting for the proxy to give up. +static STREAMING_ENDPOINTS: std::sync::Mutex>> = + std::sync::Mutex::new(None); + +fn endpoint_needs_streaming(url: &str) -> bool { + let guard = STREAMING_ENDPOINTS + .lock() + .unwrap_or_else(|error| error.into_inner()); + guard + .as_ref() + .is_some_and(|endpoints| endpoints.contains(url)) +} + +fn remember_streaming_endpoint(url: &str) { + let mut guard = STREAMING_ENDPOINTS + .lock() + .unwrap_or_else(|error| error.into_inner()); + guard + .get_or_insert_with(std::collections::HashSet::new) + .insert(url.to_string()); +} + +/// Sends the request, degrading the payload shape and finally switching to streaming, +/// which is what unblocks proxies that abort long non-streaming calls. +async fn request_chat_completion( + client: &reqwest::Client, + url: &str, + api_key: Option<&str>, + model: &str, + prompt: &str, + provider_label: &str, +) -> Result { + let mut json_mode = true; + let mut stream = endpoint_needs_streaming(url); + let mut attempts = 0u32; + loop { + attempts += 1; + let response = send_chat_completion( + client, + url, + api_key, + model, + prompt, + json_mode, + stream, + provider_label, + ) + .await?; + let status = response.status(); + if status.is_success() { + return Ok(response); + } + if matches!(status.as_u16(), 400 | 422) && json_mode { + json_mode = false; + continue; + } + if should_escalate_to_stream(status) && !stream { + stream = true; + remember_streaming_endpoint(url); + continue; + } + if is_retryable_status(status) && attempts < 3 { + tokio::time::sleep(Duration::from_secs(2 * u64::from(attempts))).await; + continue; + } + return Ok(response); + } +} + +/// Calls any server exposing the OpenAI `POST /chat/completions` contract. +async fn detect_candidates_openai_style( + transcript: &NormalizedTranscript, + api_key: Option<&str>, + model: &str, + base_url: &str, + provider_label: &str, +) -> Result> { + let segments = compact_segments(&transcript.segments); + let prompt = candidate_prompt(&segments); + let urls = chat_completion_urls(base_url); + // Without a timeout a wrong host that accepts TCP but never answers hangs + // the whole pipeline with no feedback at all. + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(15)) + .timeout(Duration::from_secs(300)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + + let mut last_failure: Option = None; + let mut accepted: Option<(String, reqwest::Response)> = None; + + for url in &urls { + let response = + request_chat_completion(&client, url, api_key, model, &prompt, provider_label).await?; + + // Some OpenAI-compatible servers (LM Studio, llama.cpp, vLLM) reject `response_format`, so the helper retries without it. + // 404/405 means the route shape is wrong, not the payload: try the next one. + if matches!(response.status().as_u16(), 404 | 405) && urls.len() > 1 { + last_failure = Some(format!("{url} -> {}", format_status(response.status()))); + continue; + } + + accepted = Some((url.clone(), response)); + break; + } + + let Some((url, response)) = accepted else { + return Err(anyhow!( + "{provider_label}: no endpoint answered ({}). Check the Base URL.", + last_failure.unwrap_or_else(|| urls.join(", ")) + )); + }; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + let hint = status_hint(status) + .map(|hint| format!(" — {hint}")) + .unwrap_or_default(); + return Err(anyhow!( + "{provider_label} request failed ({}) at {url}: {}{hint}", + format_status(status), + body_snippet(&body) + )); + } + + let text = read_chat_completion_text(response, provider_label).await?; + + let min_duration = if transcript.duration < 60.0 { + (transcript.duration * 0.5).max(5.0) + } else { + 30.0 + }; + parse_candidate_json(&text, min_duration) +} + +pub async fn detect_candidates_with_openai_compatible( + transcript: &NormalizedTranscript, + api_key: Option<&str>, + model_name: Option<&str>, + base_url: Option<&str>, +) -> Result> { + let base = base_url + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| { + std::env::var("OPENAI_BASE_URL") + .ok() + .filter(|value| !value.trim().is_empty()) + }) + .ok_or_else(|| { + anyhow!( + "Set OPENAI_BASE_URL or provide a Base URL for the OpenAI-compatible endpoint." + ) + })?; + + let model = model_name + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| { + std::env::var("OPENAI_COMPATIBLE_MODEL") + .ok() + .filter(|value| !value.trim().is_empty()) + }) + .or_else(|| { + std::env::var("OPENAI_MODEL") + .ok() + .filter(|value| !value.trim().is_empty()) + }) + .ok_or_else(|| { + anyhow!( + "Set OPENAI_COMPATIBLE_MODEL or provide a model name for the OpenAI-compatible endpoint." + ) + })?; + + detect_candidates_openai_style( + transcript, + api_key, + &model, + &base, + "OpenAI-compatible endpoint", + ) + .await } pub async fn detect_candidates_with_openai( @@ -218,9 +665,12 @@ Transcript: ); let model = std::env::var("OPENAI_MODEL").unwrap_or_else(|_| "gpt-4o-mini".to_string()); + let base_url = + std::env::var("OPENAI_BASE_URL").unwrap_or_else(|_| DEFAULT_OPENAI_BASE_URL.to_string()); + let url = normalize_chat_completions_url(&base_url); let response = reqwest::Client::new() - .post("https://api.openai.com/v1/chat/completions") + .post(&url) .header("Authorization", format!("Bearer {api_key}")) .json(&json!({ "model": model, @@ -245,13 +695,7 @@ Transcript: return Err(anyhow!("OpenAI request failed ({status}): {body}")); } - let res_body: ChatCompletionResponse = - response.json().await.context("parsing OpenAI response")?; - let text = res_body - .choices - .first() - .map(|c| c.message.content.clone()) - .ok_or_else(|| anyhow!("OpenAI response did not include choices content"))?; + let text = read_chat_completion_text(response, "[OI]").await?; let min_duration = if transcript.duration < 60.0 { (transcript.duration * 0.5).max(5.0) @@ -313,15 +757,7 @@ Transcript: return Err(anyhow!("OpenRouter request failed ({status}): {body}")); } - let res_body: ChatCompletionResponse = response - .json() - .await - .context("parsing OpenRouter response")?; - let text = res_body - .choices - .first() - .map(|c| c.message.content.clone()) - .ok_or_else(|| anyhow!("OpenRouter response did not include choices content"))?; + let text = read_chat_completion_text(response, "OpenRouter").await?; let min_duration = if transcript.duration < 60.0 { (transcript.duration * 0.5).max(5.0) @@ -378,15 +814,7 @@ Transcript: return Err(anyhow!("Groq request failed ({status}): {body}")); } - let res_body: ChatCompletionResponse = response - .json() - .await - .context("parsing Groq response")?; - let text = res_body - .choices - .first() - .map(|c| c.message.content.clone()) - .ok_or_else(|| anyhow!("Groq response did not include choices content"))?; + let text = read_chat_completion_text(response, "Groq").await?; let min_duration = if transcript.duration < 60.0 { (transcript.duration * 0.5).max(5.0) @@ -566,6 +994,100 @@ fn compact_segments(segments: &[TranscriptSegment]) -> String { .join("\n") } +/// Returns the balanced `{...}` / `[...]` slice starting at the first byte of `text`, +/// skipping braces that appear inside string literals or escapes. +fn balanced_json_slice(text: &str) -> Option<&str> { + let bytes = text.as_bytes(); + let opener = *bytes.first()?; + let closer = match opener { + b'{' => b'}', + b'[' => b']', + _ => return None, + }; + let mut depth = 0usize; + let mut in_string = false; + let mut escaped = false; + for (offset, &byte) in bytes.iter().enumerate() { + if in_string { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if byte == b'"' { + in_string = false; + } + continue; + } + if byte == b'"' { + in_string = true; + } else if byte == opener { + depth += 1; + } else if byte == closer { + depth -= 1; + if depth == 0 { + return Some(&text[..offset + 1]); + } + } + } + None +} + +/// Parses every JSON value embedded in `text`, including ones wrapped in prose +/// (reasoning traces, "Here is the JSON:" preambles, stray markdown fences). +fn extract_json_values(text: &str) -> Vec { + let mut values: Vec = Vec::new(); + if let Ok(value) = serde_json::from_str::(text) { + values.push(value); + } + let bytes = text.as_bytes(); + for (start, &byte) in bytes.iter().enumerate() { + if byte != b'{' && byte != b'[' { + continue; + } + let Some(slice) = balanced_json_slice(&text[start..]) else { + continue; + }; + if let Ok(value) = serde_json::from_str::(slice) { + if !values.contains(&value) { + values.push(value); + } + } + } + values +} + +fn candidates_from_value(val: &serde_json::Value) -> Option> { + if val.is_array() { + return val.as_array().cloned(); + } + if !val.is_object() { + return None; + } + for key in &[ + "candidates", + "Candidates", + "moments", + "clips", + "segments", + "results", + ] { + if let Some(arr) = val.get(*key).and_then(|v| v.as_array()) { + return Some(arr.clone()); + } + } + if let Some(obj) = val.as_object() { + for value in obj.values() { + if let Some(arr) = value.as_array() { + return Some(arr.clone()); + } + } + } + if val.get("start").is_some() && val.get("end").is_some() { + return Some(vec![val.clone()]); + } + None +} + fn parse_candidate_json(text: &str, min_duration: f64) -> Result> { let trimmed = text .trim() @@ -574,53 +1096,50 @@ fn parse_candidate_json(text: &str, min_duration: f64) -> Result)> = None; + for value in &values { + let Some(arr) = candidates_from_value(value) else { + continue; + }; + let score = if !arr.is_empty() && arr.iter().all(|item| item.is_object()) { + 2 + } else if !arr.is_empty() { + 1 + } else { + 0 + }; + if best.as_ref().map_or(true, |(best_score, _)| score > *best_score) { + best = Some((score, arr)); } - if found_arr.is_none() { - if let Some(obj) = val.as_object() { - for (_key, value) in obj { - if let Some(arr) = value.as_array() { - found_arr = Some(arr.clone()); - break; - } - } - } + if score == 2 { + break; } - if found_arr.is_some() { - found_arr - } else if val.get("start").is_some() && val.get("end").is_some() { - Some(vec![val.clone()]) - } else { - None + } + + let Some((_, concrete_arr)) = best else { + if values.is_empty() { + let raw = if text.trim().is_empty() { + "".to_string() + } else { + body_snippet(text.trim()) + }; + return Err(anyhow!("parsing candidate JSON: {raw}")); } - } else { - None + return Err(anyhow!( + "model output does not contain a candidates array. Raw output: {}", + body_snippet(trimmed) + )); }; - let concrete_arr = candidates_arr.ok_or_else(|| { - anyhow!( - "Ollama output does not contain a candidates array. Raw output: {}", - trimmed - ) - })?; - let mut drafts = Vec::new(); for item in &concrete_arr { let start = match item.get("start") { @@ -720,3 +1239,407 @@ fn parse_candidate_json(text: &str, min_duration: f64) -> Result, text: &str) -> TranscriptSegment { + TranscriptSegment { + start, + end, + speaker: speaker.map(str::to_string), + text: text.to_string(), + } + } + + #[test] + fn normalizes_chat_completions_urls() { + assert_eq!( + normalize_chat_completions_url(""), + "https://api.openai.com/v1/chat/completions" + ); + assert_eq!( + normalize_chat_completions_url("http://localhost:1234/v1"), + "http://localhost:1234/v1/chat/completions" + ); + assert_eq!( + normalize_chat_completions_url("http://localhost:1234/v1/"), + "http://localhost:1234/v1/chat/completions" + ); + assert_eq!( + normalize_chat_completions_url("https://example.com/v1/chat/completions"), + "https://example.com/v1/chat/completions" + ); + assert_eq!( + normalize_chat_completions_url(" http://host:8080 "), + "http://host:8080/chat/completions" + ); + } + + #[test] + fn bare_host_tries_the_v1_route_first() { + assert_eq!( + chat_completion_urls("http://localhost:1234"), + vec![ + "http://localhost:1234/v1/chat/completions".to_string(), + "http://localhost:1234/chat/completions".to_string(), + ] + ); + assert_eq!( + chat_completion_urls("http://localhost:1234/v1"), + vec!["http://localhost:1234/v1/chat/completions".to_string()] + ); + assert_eq!( + chat_completion_urls("https://api.example.com/openai/v1"), + vec!["https://api.example.com/openai/v1/chat/completions".to_string()] + ); + } + + #[test] + fn compact_segments_formats_each_line() { + let segments = vec![ + segment(0.0, 1.5, Some("S1"), "hello"), + segment(1.5, 3.0, None, "world"), + ]; + assert_eq!( + compact_segments(&segments), + "[0.00-1.50] S1: hello\n[1.50-3.00] Speaker: world" + ); + } + + #[test] + fn parse_candidate_json_accepts_fenced_object_and_normalizes_scores() { + let text = r#"```json +{"candidates":[ + {"start":0,"end":40,"score":9,"hook":"A","rationale":"r"}, + {"start":50,"end":80,"score":0.95,"hook":"B","rationale":"r"}, + {"start":90,"end":95,"score":1,"hook":"C","rationale":"r"} +]} +```"#; + + let drafts = parse_candidate_json(text, 30.0).unwrap(); + assert_eq!(drafts.len(), 2); + assert_eq!(drafts[0].hook, "B"); + assert_eq!(drafts[0].score, 0.95); + assert_eq!(drafts[1].hook, "A"); + assert!((drafts[1].score - 0.9).abs() < f64::EPSILON); + } + + #[test] + fn parse_candidate_json_accepts_root_array() { + let text = r#"[{"start":0,"end":45,"score":0.5,"hook":"H","rationale":"R"}]"#; + let drafts = parse_candidate_json(text, 30.0).unwrap(); + assert_eq!(drafts.len(), 1); + assert_eq!(drafts[0].end, 45.0); + } + + #[test] + fn parse_candidate_json_accepts_single_candidate_object() { + let text = r#"{"start":10,"end":50,"score":0.7,"hook":"H","rationale":"R"}"#; + let drafts = parse_candidate_json(text, 30.0).unwrap(); + assert_eq!(drafts.len(), 1); + assert_eq!(drafts[0].start, 10.0); + } + + #[test] + fn parse_candidate_json_accepts_alternative_keys() { + let text = r#"{"moments":[{"start":0,"end":35,"score":0.4,"hook":"H","rationale":"R"}]}"#; + assert_eq!(parse_candidate_json(text, 30.0).unwrap().len(), 1); + } + + #[test] + fn parse_candidate_json_falls_back_to_shorter_clips() { + let text = r#"{"candidates":[{"start":0,"end":10,"score":0.5,"hook":"H","rationale":"R"}]}"#; + let drafts = parse_candidate_json(text, 30.0).unwrap(); + assert_eq!(drafts.len(), 1); + } + + #[test] + fn parse_candidate_json_drops_clips_without_hooks() { + let text = r#"{"candidates":[{"start":0,"end":40,"score":0.5,"hook":" ","rationale":"R"}]}"#; + assert!(parse_candidate_json(text, 30.0).unwrap().is_empty()); + } + + #[test] + fn parse_candidate_json_clamps_and_parses_loose_scores() { + let text = r#"{"candidates":[ + {"start":0,"end":40,"score":150,"hook":"big","rationale":""}, + {"start":0,"end":40,"score":-2,"hook":"neg","rationale":""}, + {"start":0,"end":40,"score":"8","hook":"str","rationale":""}, + {"start":"0","end":"40","hook":"missing","rationale":""} + ]}"#; + + let drafts = parse_candidate_json(text, 30.0).unwrap(); + let by_hook = |hook: &str| drafts.iter().find(|d| d.hook == hook).cloned().unwrap(); + assert_eq!(by_hook("big").score, 1.0); + assert_eq!(by_hook("neg").score, 0.0); + assert_eq!(by_hook("str").score, 0.8); + assert_eq!(by_hook("missing").score, 0.8); + assert_eq!(by_hook("missing").end, 40.0); + } + + #[test] + fn parse_candidate_json_truncates_to_ten_candidates() { + let candidates: Vec = (0..12) + .map(|i| format!(r#"{{"start":0,"end":40,"score":0.5,"hook":"h{i}","rationale":""}}"#)) + .collect(); + let text = format!(r#"{{"candidates":[{}]}}"#, candidates.join(",")); + assert_eq!(parse_candidate_json(&text, 30.0).unwrap().len(), 10); + } + + #[test] + fn parse_candidate_json_rejects_invalid_json() { + assert!(parse_candidate_json("not json at all", 30.0).is_err()); + assert!(parse_candidate_json(r#"{"unrelated":true}"#, 30.0).is_err()); + } + + #[test] + fn parse_candidate_json_rejects_empty_and_whitespace() { + for text in ["", " ", "\n\n\n", "```json```", "```json ```", "```"] { + let err = parse_candidate_json(text, 30.0).unwrap_err(); + assert!( + err.to_string().contains("parsing candidate JSON"), + "unexpected error for {text:?}: {err}" + ); + } + } + + #[test] + fn parse_candidate_json_reports_empty_response_clearly() { + let err = parse_candidate_json(" ", 30.0).unwrap_err(); + assert_eq!(err.to_string(), "parsing candidate JSON: "); + } + + #[test] + fn parse_candidate_json_reports_prose_without_json() { + let err = parse_candidate_json("I could not find any good clips.", 30.0).unwrap_err(); + let message = err.to_string(); + assert!(message.contains("parsing candidate JSON")); + assert!(message.contains("I could not find any good clips.")); + } + + #[test] + fn parse_candidate_json_recovers_json_after_prose_preamble() { + let text = concat!( + "Sure! Here are the best moments:\n\n", + r#"{"candidates":[{"start":0,"end":40,"score":0.8,"hook":"H","rationale":"R"}]}"#, + "\n\nLet me know if you need more." + ); + let drafts = parse_candidate_json(text, 30.0).unwrap(); + assert_eq!(drafts.len(), 1); + assert_eq!(drafts[0].hook, "H"); + } + + #[test] + fn parse_candidate_json_recovers_fenced_json_after_prose_preamble() { + let text = concat!( + "Here is the JSON you asked for:\n", + "```json\n", + r#"{"candidates":[{"start":10,"end":60,"score":0.6,"hook":"H","rationale":"R"}]}"#, + "\n```" + ); + let drafts = parse_candidate_json(text, 30.0).unwrap(); + assert_eq!(drafts.len(), 1); + assert_eq!(drafts[0].start, 10.0); + } + + #[test] + fn parse_candidate_json_ignores_stray_brackets_in_prose() { + let text = concat!( + "I ranked them [1] and [2] like this:\n", + r#"{"candidates":[{"start":0,"end":45,"score":0.5,"hook":"H","rationale":"R"}]}"# + ); + let drafts = parse_candidate_json(text, 30.0).unwrap(); + assert_eq!(drafts.len(), 1); + assert_eq!(drafts[0].end, 45.0); + } + + #[test] + fn parse_candidate_json_recovers_root_array_from_prose() { + let text = concat!( + "Analysis complete. Result: ", + r#"[{"start":5,"end":50,"score":0.7,"hook":"H","rationale":"R"}]"# + ); + let drafts = parse_candidate_json(text, 30.0).unwrap(); + assert_eq!(drafts.len(), 1); + assert_eq!(drafts[0].start, 5.0); + } + + #[test] + fn balanced_json_slice_handles_braces_inside_strings() { + let text = r#"{"hook":"a } brace [ and { too","end":1}"#; + assert_eq!(balanced_json_slice(text), Some(text)); + assert_eq!(balanced_json_slice("no json here"), None); + assert_eq!(balanced_json_slice(r#"{"unbalanced":true"#), None); + } + + #[test] + fn parse_candidate_json_keeps_reasoning_traces_out_of_the_error() { + // A reasoning model may emit its scratchpad before the JSON; the JSON must win. + let text = concat!( + "Okay, let me think. The user wants clips. {\"thinking\":true}\n", + r#"{"candidates":[{"start":0,"end":35,"score":0.9,"hook":"H","rationale":"R"}]}"# + ); + let drafts = parse_candidate_json(text, 30.0).unwrap(); + assert_eq!(drafts.len(), 1); + assert_eq!(drafts[0].hook, "H"); + } + + #[test] + fn parses_sse_streamed_chunks() { + let body = concat!( + "data: {\"object\":\"chat.completion.chunk\",\"choices\":[{\"delta\":{\"content\":\"Hel\"}}]}\n", + "data: {\"object\":\"chat.completion.chunk\",\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n", + "data: [DONE]\n" + ); + assert_eq!(parse_sse_stream_text(body).as_deref(), Some("Hello")); + } + + #[test] + fn rejects_non_chunk_sse_payloads_and_empty_streams() { + let not_chunks = "data: {\"object\":\"chat.completion\",\"choices\":[]}\n"; + assert_eq!(parse_sse_stream_text(not_chunks), None); + assert_eq!(parse_sse_stream_text("event: ping\n"), None); + } + + #[test] + fn extracts_text_from_chat_completion_variants() { + let string_content: ChatCompletionResponse = serde_json::from_value(json!({ + "choices": [{ "message": { "content": "plain" } }] + })) + .unwrap(); + assert_eq!(extract_completion_text(&string_content).as_deref(), Some("plain")); + + let parts_content: ChatCompletionResponse = serde_json::from_value(json!({ + "choices": [{ "message": { "content": [{ "text": "a" }, { "text": "b" }] } }] + })) + .unwrap(); + assert_eq!(extract_completion_text(&parts_content).as_deref(), Some("ab")); + + let reasoning: ChatCompletionResponse = serde_json::from_value(json!({ + "choices": [{ "message": { "content": null, "reasoning_content": "thinking" } }] + })) + .unwrap(); + assert_eq!(extract_completion_text(&reasoning).as_deref(), Some("thinking")); + + let legacy_text: ChatCompletionResponse = serde_json::from_value(json!({ + "choices": [{ "text": "legacy" }] + })) + .unwrap(); + assert_eq!(extract_completion_text(&legacy_text).as_deref(), Some("legacy")); + } + + #[test] + fn extract_completion_text_returns_none_without_content() { + let empty: ChatCompletionResponse = serde_json::from_value(json!({ "choices": [] })).unwrap(); + assert_eq!(extract_completion_text(&empty), None); + + let blank: ChatCompletionResponse = serde_json::from_value(json!({ + "choices": [{ "message": { "content": " " } }] + })) + .unwrap(); + assert_eq!(extract_completion_text(&blank), None); + } + + #[test] + fn body_snippet_truncates_long_bodies() { + let short = " short body "; + assert_eq!(body_snippet(short), "short body"); + + let long = "x".repeat(900); + let snippet = body_snippet(&long); + assert_eq!(snippet.chars().count(), 801); + assert!(snippet.ends_with('…')); + } + + #[test] + fn chat_completion_payload_toggles_json_mode() { + let with_json = chat_completion_payload("model", "prompt", true, false); + assert_eq!(with_json["response_format"]["type"], "json_object"); + assert_eq!(with_json["stream"], false); + + let without_json = chat_completion_payload("model", "prompt", false, false); + assert!(without_json.get("response_format").is_none()); + } + + #[test] + fn chat_completion_payload_can_request_streaming() { + let payload = chat_completion_payload("model", "prompt", true, true); + assert_eq!(payload["stream"], true); + assert_eq!(payload["response_format"]["type"], "json_object"); + } + + #[test] + fn format_status_drops_the_unknown_reason_suffix() { + let cloudflare = reqwest::StatusCode::from_u16(524).expect("524 is a valid code"); + assert_eq!(format_status(cloudflare), "524"); + assert_eq!(format_status(reqwest::StatusCode::NOT_FOUND), "404 Not Found"); + } + + #[test] + fn status_hint_explains_cloudflare_timeouts() { + let hint = status_hint(reqwest::StatusCode::from_u16(524).expect("valid")).unwrap_or(""); + assert!(hint.contains("Cloudflare")); + assert!(hint.contains("streaming")); + assert!(status_hint(reqwest::StatusCode::NOT_FOUND) + .unwrap_or("") + .contains("Base URL")); + assert!(status_hint(reqwest::StatusCode::OK).is_none()); + } + + #[test] + fn retries_and_stream_escalation_cover_proxy_timeouts() { + let timeout = reqwest::StatusCode::from_u16(524).expect("valid"); + let unavailable = reqwest::StatusCode::from_u16(503).expect("valid"); + assert!(should_escalate_to_stream(timeout)); + assert!(is_retryable_status(timeout)); + assert!(is_retryable_status(unavailable)); + assert!(!should_escalate_to_stream(unavailable)); + assert!(!is_retryable_status(reqwest::StatusCode::NOT_FOUND)); + assert!(!is_retryable_status(reqwest::StatusCode::OK)); + } + + #[test] + fn remembers_endpoints_that_need_streaming() { + let url = "https://streaming-reminder.invalid/v1/chat/completions"; + assert!(!endpoint_needs_streaming(url)); + remember_streaming_endpoint(url); + assert!(endpoint_needs_streaming(url)); + assert!(!endpoint_needs_streaming( + "https://other-reminder.invalid/v1/chat/completions" + )); + } + + #[test] + fn parse_sse_stream_text_skips_keepalive_frames() { + let body = concat!( + "event: ping\n", + "data: {\"object\":\"chat.completion.chunk\",\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n", + "data: {\"object\":\"usage\",\"total_tokens\":12}\n", + "data: [DONE]\n" + ); + assert_eq!(parse_sse_stream_text(body).as_deref(), Some("ok")); + } + + #[test] + fn parse_sse_stream_text_falls_back_to_reasoning_deltas() { + let body = concat!( + "data: {\"object\":\"chat.completion.chunk\",\"choices\":[{\"delta\":{\"reasoning_content\":\"think\"}}]}\n", + "data: {\"object\":\"chat.completion.chunk\",\"choices\":[{\"delta\":{\"reasoning_content\":\"ing\"}}]}\n", + "data: [DONE]\n" + ); + assert_eq!(parse_sse_stream_text(body).as_deref(), Some("thinking")); + } + + #[test] + fn parse_sse_stream_text_prefers_content_over_reasoning() { + let body = concat!( + "data: {\"object\":\"chat.completion.chunk\",\"choices\":[{\"delta\":{\"reasoning_content\":\"scratchpad\"}}]}\n", + "data: {\"object\":\"chat.completion.chunk\",\"choices\":[{\"delta\":{\"content\":\"answer\"}}]}\n", + "data: [DONE]\n" + ); + assert_eq!(parse_sse_stream_text(body).as_deref(), Some("answer")); + } +} diff --git a/src-tauri/src/media.rs b/src-tauri/src/media.rs index 659764d..46dc2ba 100644 --- a/src-tauri/src/media.rs +++ b/src-tauri/src/media.rs @@ -1,17 +1,112 @@ use std::{ + io::{BufRead, BufReader, Read}, path::{Path, PathBuf}, - process::Command, + process::{Command, Stdio}, }; use anyhow::{anyhow, Context, Result}; use serde_json::Value; +use crate::autoedit::RenderSegment; +use crate::hardware; use crate::models::MediaProbe; pub fn command_exists(name: &str) -> bool { Command::new(name).arg("-version").output().is_ok() } +/// Parse a `-progress pipe:1` key/value pair into a position in seconds. +/// +/// ffmpeg reports `out_time_us` and `out_time_ms`; despite the name, both are +/// expressed in microseconds on current builds, so both are divided by 1e6. +fn parse_ffmpeg_progress_time(key: &str, value: &str) -> Option { + match key { + "out_time_us" | "out_time_ms" => value.trim().parse::().ok().map(|us| us / 1_000_000.0), + "out_time" => parse_clock_timestamp(value.trim()), + _ => None, + } +} + +fn parse_clock_timestamp(token: &str) -> Option { + let mut parts = token.split(':'); + let hours: f64 = parts.next()?.parse().ok()?; + let minutes: f64 = parts.next()?.parse().ok()?; + let seconds: f64 = parts.next()?.parse().ok()?; + Some(hours * 3600.0 + minutes * 60.0 + seconds) +} + +/// Run ffmpeg while streaming `-progress pipe:1` output into `on_progress`. +/// +/// The callback is borrowed so a caller can retry the same job with a different +/// encoder (GPU first, CPU on failure) while keeping its progress reporting. +fn run_ffmpeg_with_progress( + args: &[String], + total_duration: Option, + working_dir: Option<&Path>, + on_progress: &mut dyn FnMut(f64), +) -> Result<()> { + let mut command = Command::new("ffmpeg"); + command.args(["-progress", "pipe:1", "-nostats"]).args(args); + if let Some(dir) = working_dir { + command.current_dir(dir); + } + + let mut child = command + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("running ffmpeg")?; + + // Drain stderr on a helper thread so a full pipe never deadlocks the render. + let stderr_handle = { + let mut stderr = child.stderr.take().context("capturing ffmpeg stderr")?; + std::thread::spawn(move || { + let mut buffer = String::new(); + let _ = stderr.read_to_string(&mut buffer); + buffer + }) + }; + + if let Some(stdout) = child.stdout.take() { + let reader = BufReader::new(stdout); + let mut last_percentage = 0.0_f64; + for line in reader.lines() { + let Ok(line) = line else { break }; + let Some((key, value)) = line.split_once('=') else { + continue; + }; + if key == "progress" && value.trim() == "end" { + if last_percentage < 100.0 { + last_percentage = 100.0; + on_progress(100.0); + } + continue; + } + let Some(total) = total_duration else { continue }; + if total <= 0.0 { + continue; + } + if let Some(position) = parse_ffmpeg_progress_time(key, value) { + let percentage = (position / total * 100.0).clamp(0.0, 99.0); + if percentage > last_percentage + 0.5 { + last_percentage = percentage; + on_progress(percentage); + } + } + } + } + + let status = child.wait().context("waiting for ffmpeg")?; + let stderr = stderr_handle.join().unwrap_or_default(); + + if !status.success() { + return Err(anyhow!("ffmpeg failed: {}", stderr.trim())); + } + + on_progress(100.0); + Ok(()) +} + pub fn probe_media(path: &str) -> Result { if !command_exists("ffprobe") { return Err(anyhow!("ffprobe is not installed or not available on PATH")); @@ -77,7 +172,12 @@ pub fn probe_media(path: &str) -> Result { }) } -pub fn extract_audio(source_path: &str, project_dir: &Path) -> Result { +/// Extract a 16 kHz mono WAV for transcription, reporting 0..=100 progress. +pub fn extract_audio( + source_path: &str, + project_dir: &Path, + mut on_progress: impl FnMut(f64), +) -> Result { if !command_exists("ffmpeg") { return Err(anyhow!("ffmpeg is not installed or not available on PATH")); } @@ -85,71 +185,683 @@ pub fn extract_audio(source_path: &str, project_dir: &Path) -> Result { std::fs::create_dir_all(project_dir)?; let output_path = project_dir.join("transcription_audio.wav"); + let total_duration = probe_media(source_path).ok().and_then(|probe| probe.duration_sec); + + let args: Vec = vec![ + "-y".into(), + "-i".into(), + source_path.to_string(), + "-vn".into(), + "-ac".into(), + "1".into(), + "-ar".into(), + "16000".into(), + output_path.to_string_lossy().to_string(), + ]; + + run_ffmpeg_with_progress(&args, total_duration, None, &mut on_progress)?; + Ok(output_path) +} + +/// Aspect ratio used when the caller does not specify one. +pub const DEFAULT_ASPECT_RATIO: &str = "9:16"; + +/// Canonicalize a user-supplied aspect ratio into one of the four supported +/// values, falling back to vertical video. +pub fn normalize_aspect_ratio(value: &str) -> &'static str { + match value.trim().to_ascii_lowercase().as_str() { + "1:1" | "1x1" | "square" => "1:1", + "16:9" => "16:9", + "4:5" => "4:5", + _ => DEFAULT_ASPECT_RATIO, + } +} + +/// Output resolution for a clip. Normalizing here keeps the encoder from +/// working on 4K frames when the source is larger than a phone screen. +pub fn clip_dimensions(aspect_ratio: &str) -> (i64, i64) { + match normalize_aspect_ratio(aspect_ratio) { + "1:1" => (1080, 1080), + "16:9" => (1920, 1080), + "4:5" => (1080, 1350), + _ => (1080, 1920), + } +} + +fn clamp_crop(value: f64) -> f64 { + if value.is_finite() { + value.clamp(0.0, 1.0) + } else { + 0.5 + } +} + +/// Build the crop + scale portion of the ffmpeg filter chain. +/// +/// The crop window keeps the requested aspect ratio inside the source frame +/// and slides inside the remaining slack according to `crop_x`/`crop_y` +/// (0.0 = left/top, 0.5 = centered, 1.0 = right/bottom). Single-quoted +/// expressions protect the commas from ffmpeg's filter parser. +pub fn crop_and_scale_filter(aspect_ratio: &str, crop_x: f64, crop_y: f64) -> String { + let ratio = normalize_aspect_ratio(aspect_ratio); + let a = match ratio { + "1:1" => 1.0, + "16:9" => 16.0 / 9.0, + "4:5" => 4.0 / 5.0, + _ => 9.0 / 16.0, + }; + let crop_x = clamp_crop(crop_x); + let crop_y = clamp_crop(crop_y); + let (width, height) = clip_dimensions(ratio); + format!( + "crop=w='2*trunc(min(iw,ih*{a})/2)':h='2*trunc(min(ih,iw/{a})/2)':\ + x='(iw-ow)*{crop_x}':y='(ih-oh)*{crop_y}',\ + scale={width}:{height}:flags=fast_bilinear" + ) +} + +/// Height cap for the editor preview proxy. Small enough to encode the whole +/// source in roughly a minute, large enough to judge the crop. +pub const PREVIEW_HEIGHT: i64 = 480; + +/// Downscale filter for the preview proxy, preserving aspect ratio and +/// keeping both dimensions even (required by VP8). +fn preview_scale_filter() -> String { + format!( + "scale=w='min(854,iw)':h='min({PREVIEW_HEIGHT},ih)':\ + force_original_aspect_ratio=decrease:force_divisible_by=2:\ + flags=fast_bilinear" + ) +} + +/// ffmpeg arguments that transcode the whole source into a WebM proxy. +/// +/// WebKitGTK can only decode what the installed GStreamer plugins provide, and +/// a stock Ubuntu install ships no H.264/AAC decoders, so a source MP4 simply +/// does not play inside the webview. A VP8/Opus WebM proxy does play with the +/// `vpx`, `matroska` and `opus` plugins that are present everywhere. +/// +/// The proxy covers the entire media instead of a single clip window so one +/// encode serves every candidate of a project and the editor can seek freely. +/// Settings trade quality for speed: `deadline realtime` with `cpu-used 16` +/// encodes around 8x realtime on a laptop CPU. +pub fn preview_proxy_args(source_path: &str, output_path: &Path) -> Vec { + vec![ + "-y".into(), + "-i".into(), + source_path.to_string(), + "-map".into(), + "0:v:0".into(), + "-map".into(), + "0:a:0?".into(), + "-vf".into(), + preview_scale_filter(), + "-r".into(), + "20".into(), + "-c:v".into(), + "libvpx".into(), + "-b:v".into(), + "550k".into(), + "-deadline".into(), + "realtime".into(), + "-cpu-used".into(), + "16".into(), + "-row-mt".into(), + "1".into(), + "-threads".into(), + "4".into(), + "-g".into(), + "40".into(), + "-c:a".into(), + "libopus".into(), + "-b:a".into(), + "48k".into(), + "-ac".into(), + "1".into(), + "-f".into(), + "webm".into(), + output_path.to_string_lossy().to_string(), + ] +} + +/// Transcode the whole source into a WebM preview proxy, reporting 0..=100 +/// progress against `duration_sec`. +pub fn render_preview_proxy( + source_path: &str, + output_path: &Path, + duration_sec: Option, + mut on_progress: impl FnMut(f64), +) -> Result { + if !command_exists("ffmpeg") { + return Err(anyhow!("ffmpeg is not installed or not available on PATH")); + } + + let output_path = absolute_path(output_path); + if let Some(parent) = output_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let duration = duration_sec.filter(|value| value.is_finite() && *value > 0.0); + let args = preview_proxy_args(source_path, &output_path); + run_ffmpeg_with_progress(&args, duration, None, &mut on_progress)?; + Ok(output_path) +} + +/// Width cap for candidate thumbnails in the clip list. +pub const THUMB_WIDTH: i64 = 360; + +/// ffmpeg arguments that grab one frame as a small JPEG thumbnail. +/// +/// `-ss` sits before `-i` so ffmpeg seeks by index instead of decoding from +/// the start; on a 12 minute source that turns a 4 second job into ~60 ms. +pub fn thumbnail_args(source_path: &str, time_sec: f64, output_path: &Path) -> Vec { + let timestamp = if time_sec.is_finite() && time_sec > 0.0 { + time_sec + } else { + 0.0 + }; + vec![ + "-y".into(), + "-ss".into(), + format!("{timestamp:.3}"), + "-i".into(), + source_path.to_string(), + "-frames:v".into(), + "1".into(), + "-vf".into(), + format!("scale=w='min({THUMB_WIDTH},iw)':h=-2:flags=fast_bilinear"), + "-q:v".into(), + "5".into(), + "-an".into(), + "-f".into(), + "image2".into(), + output_path.to_string_lossy().to_string(), + ] +} + +/// Render a single JPEG frame at `time_sec`, returning the written path. +pub fn render_thumbnail(source_path: &str, time_sec: f64, output_path: &Path) -> Result { + if !command_exists("ffmpeg") { + return Err(anyhow!("ffmpeg is not installed or not available on PATH")); + } + + let output_path = absolute_path(output_path); + if let Some(parent) = output_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let args = thumbnail_args(source_path, time_sec, &output_path); let output = Command::new("ffmpeg") - .args(["-y", "-i", source_path, "-vn", "-ac", "1", "-ar", "16000"]) - .arg(&output_path) + .args(&args) .output() - .context("running ffmpeg audio extraction")?; - + .context("running ffmpeg for a thumbnail")?; if !output.status.success() { return Err(anyhow!( - "ffmpeg audio extraction failed: {}", + "ffmpeg failed: {}", String::from_utf8_lossy(&output.stderr).trim() )); } - Ok(output_path) } +/// Options for rendering a single clip. +pub struct ClipRenderRequest<'a> { + pub source_path: &'a str, + pub start_sec: f64, + pub end_sec: f64, + pub output_path: &'a Path, + /// Whether the source has a video stream (probed once by the caller). + pub has_video: bool, + /// Whether the source has an audio stream (probed once by the caller). + pub has_audio: bool, + /// Optional ASS subtitle file burned into the video. + pub ass_path: Option<&'a Path>, + /// Target aspect ratio; see [`normalize_aspect_ratio`]. + pub aspect_ratio: &'a str, + /// Horizontal crop position inside the slack, 0.0..=1.0. + pub crop_x: f64, + /// Vertical crop position inside the slack, 0.0..=1.0. + pub crop_y: f64, + /// Auto-edit cut list. Empty keeps the single-pass render path. + pub segments: &'a [RenderSegment], +} + +fn absolute_path(path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map(|cwd| cwd.join(path)) + .unwrap_or_else(|_| path.to_path_buf()) + } +} + +/// Build the `ass` filter argument for a subtitle file. +/// +/// The file is referenced by its bare name and ffmpeg's working directory is +/// set to the file's folder, which avoids `:`/`\` path escaping problems on +/// every platform (Windows drive letters included). +fn ass_filter_arg(ass_path: &Path) -> Option { + let name = ass_path.file_name()?.to_string_lossy().to_string(); + if name.is_empty() { + return None; + } + let escaped: String = name + .chars() + .map(|c| match c { + ':' | '\'' | ',' | '[' | ']' | ';' | '\\' => format!("\\{c}"), + other => other.to_string(), + }) + .collect(); + Some(format!("ass=filename={escaped}")) +} + +/// Render a 9:16 clip, reporting clip-level 0..=100 progress. pub fn render_flat_clip( - source_path: &str, - start_sec: f64, - end_sec: f64, - output_path: &Path, - drawtext_filters: Option<&str>, + request: &ClipRenderRequest<'_>, + mut on_progress: impl FnMut(f64), ) -> Result { if !command_exists("ffmpeg") { return Err(anyhow!("ffmpeg is not installed or not available on PATH")); } + let output_path = absolute_path(request.output_path); if let Some(parent) = output_path.parent() { std::fs::create_dir_all(parent)?; } - let start = format!("{start_sec:.3}"); - let duration_sec = (end_sec - start_sec).max(0.1); - let duration = format!("{duration_sec:.3}"); + let duration_sec = if request.segments.is_empty() { + (request.end_sec - request.start_sec).max(0.1) + } else { + request + .segments + .iter() + .map(RenderSegment::output_len) + .sum::() + .max(0.1) + }; + + // Normalize the ffmpeg progress stream (absolute media time) to the + // 0..=100 window of this specific clip. `-ss` seeks before input + // decoding, so reported positions already start near zero here. + let mut on_clip_progress = move |percentage: f64| on_progress(percentage.clamp(0.0, 100.0)); + + // Use the GPU encoder when one was detected *and* validated at startup. + // A GPU that disappears (driver update, eGPU unplug, WSL passthrough lost) + // demotes the whole process to libx264 instead of failing the batch. + let encoder = hardware::active_video_encoder(); + match run_clip_ffmpeg( + request, + encoder, + &output_path, + duration_sec, + &mut on_clip_progress, + ) { + Ok(()) => Ok(output_path), + Err(error) if encoder.is_hardware() => { + eprintln!( + "[autoshorts] {} render failed ({}); retrying with libx264", + encoder.id(), + error + ); + hardware::mark_video_encoder_failed(encoder); + run_clip_ffmpeg( + request, + hardware::VideoEncoder::Libx264, + &output_path, + duration_sec, + &mut on_clip_progress, + )?; + Ok(output_path) + } + Err(error) => Err(error), + } +} + +/// Build and run the ffmpeg command for one clip with the given encoder. +fn run_clip_ffmpeg( + request: &ClipRenderRequest<'_>, + encoder: hardware::VideoEncoder, + output_path: &Path, + duration_sec: f64, + on_progress: &mut dyn FnMut(f64), +) -> Result<()> { + let plan = hardware::plan(encoder); + + let source_path = absolute_path(Path::new(request.source_path)); + let source_arg = source_path.to_string_lossy().to_string(); + let output_arg = output_path.to_string_lossy().to_string(); + let start = format!("{:.3}", request.start_sec); - let probe = probe_media(source_path).ok(); - let has_video = probe.map(|p| p.has_video).unwrap_or(false); + let segments = relative_segments(request); + let auto_edit = !segments.is_empty() + && (segments.len() > 1 + || (segments[0].speed - 1.0).abs() > 1e-3 + || segments[0].zoom > 1.0 + 1e-9); + + // The trim filters index into the *source* window, so with a cut list the + // input has to stay readable past the (shorter) output duration. + let input_duration = if auto_edit { + segments + .iter() + .map(|segment| segment.end) + .fold(0.0_f64, f64::max) + .max(0.1) + } else { + duration_sec + }; + let duration = format!("{input_duration:.3}"); let mut cmd = Command::new("ffmpeg"); - cmd.args(["-y", "-ss", &start, "-i", source_path, "-t", &duration]); + cmd.args(&plan.pre_input); + cmd.args(["-y", "-ss", &start, "-i", &source_arg, "-t", &duration]); - if has_video { - let mut filter = "crop=w='2*trunc(min(iw,ih*9/16)/2)':h='2*trunc(min(ih,iw*16/9)/2)'".to_string(); - if let Some(drawtext) = drawtext_filters { - if !drawtext.is_empty() { - filter = format!("{},{}", filter, drawtext); - } + let mut ass_dir: Option = None; + let mut ass_filter: Option = None; + if let Some(ass_path) = request.ass_path { + let ass_path = absolute_path(ass_path); + if let Some(parent) = ass_path.parent() { + ass_dir = Some(parent.to_path_buf()); + } + ass_filter = ass_filter_arg(&ass_path); + } + + if auto_edit { + // Cut list path: trim every kept span, retime the accelerated ones and + // concatenate them back into one continuous clip. Captions were already + // remapped onto this compressed timeline by the caller. + let graph = auto_edit_filter_graph(request, &segments, ass_filter.as_deref(), plan.filter_suffix); + if graph.is_empty() { + return Err(anyhow!("auto-edit produced an empty filter graph")); + } + cmd.args(["-filter_complex", &graph]); + if request.has_video { + cmd.args(["-map", "[vout]"]); + cmd.args(&plan.codec); + } else { + cmd.arg("-vn"); + } + if request.has_audio { + cmd.args(["-map", "[ca]"]); + } + } else if request.has_video { + let mut filters = vec![crop_and_scale_filter( + request.aspect_ratio, + request.crop_x, + request.crop_y, + )]; + + if let Some(filter) = ass_filter { + filters.push(filter); } - cmd.args(["-vf", &filter]); - cmd.args(["-c:v", "libx264", "-preset", "fast", "-crf", "18", "-pix_fmt", "yuv420p"]); + + let mut filter_chain = filters.join(","); + // VA-API needs frames uploaded to the device before encoding. + filter_chain.push_str(plan.filter_suffix); + + cmd.args(["-vf", &filter_chain]); + cmd.args(&plan.codec); } else { cmd.arg("-vn"); } - cmd.args(["-c:a", "aac", "-b:a", "192k"]); - cmd.arg(output_path); + cmd.args(["-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart"]); + cmd.arg(&output_arg); - let output = cmd.output().context("running ffmpeg clip render")?; + let args: Vec = cmd + .get_args() + .map(|arg| arg.to_string_lossy().to_string()) + .collect(); - if !output.status.success() { - return Err(anyhow!( - "ffmpeg clip render failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - )); + // `-progress` output is absolute media time, but `-ss` seeks before input + // decoding, so reported positions already start near zero for this clip. + run_ffmpeg_with_progress(&args, Some(duration_sec), ass_dir.as_deref(), on_progress) +} + +/// Rewrite plan segments into the `-ss`-relative timeline used by the filters. +fn relative_segments(request: &ClipRenderRequest<'_>) -> Vec { + let window = (request.end_sec - request.start_sec).max(0.1); + request + .segments + .iter() + .map(|segment| RenderSegment { + start: (segment.start - request.start_sec).clamp(0.0, window), + end: (segment.end - request.start_sec).clamp(0.0, window), + speed: segment.speed.clamp(0.5, 4.0), + zoom: segment.zoom.clamp(1.0, 1.4), + }) + .filter(|segment| segment.end - segment.start > 0.01) + .collect() +} + +/// Build the `-filter_complex` graph that replays an auto-edit cut list. +/// +/// Every kept span becomes a `trim`/`atrim` pair (plus `setpts`/`atempo` when +/// retimed), all spans are concatenated, and the crop/caption chain runs once on +/// the joined result so the framing stays identical across cuts. Punch-in zoom +/// is the exception: it must be baked per segment, before the concat. +fn auto_edit_filter_graph( + request: &ClipRenderRequest<'_>, + segments: &[RenderSegment], + ass_filter: Option<&str>, + filter_suffix: &str, +) -> String { + let count = segments.len(); + let punch_per_segment = request.has_video && segments.iter().any(|segment| segment.zoom > 1.0 + 1e-9); + let mut chains: Vec = Vec::new(); + let mut concat_inputs = String::new(); + + for (index, segment) in segments.iter().enumerate() { + let start = format!("{:.3}", segment.start); + let end = format!("{:.3}", segment.end); + let speed = segment.speed.max(0.5); + + if request.has_video { + let mut chain = format!( + "[0:v]trim=start={start}:end={end},setpts=(PTS-STARTPTS)/{speed:.4}" + ); + if punch_per_segment { + chain.push(','); + chain.push_str(&crop_and_scale_filter( + request.aspect_ratio, + request.crop_x, + request.crop_y, + )); + if segment.zoom > 1.0 + 1e-9 { + let (width, height) = clip_dimensions(request.aspect_ratio); + chain.push_str(&format!( + ",scale=iw*{:.4}:ih*{:.4}:flags=fast_bilinear,crop={width}:{height}", + segment.zoom, segment.zoom + )); + } + } + chain.push_str(&format!("[v{index}]")); + chains.push(chain); + concat_inputs.push_str(&format!("[v{index}]")); + } + + if request.has_audio { + let mut chain = format!("[0:a]atrim=start={start}:end={end},asetpts=PTS-STARTPTS"); + if (speed - 1.0).abs() > 1e-3 { + chain.push_str(&format!(",atempo={speed:.4}")); + } + chain.push_str(&format!("[a{index}]")); + chains.push(chain); + concat_inputs.push_str(&format!("[a{index}]")); + } } - Ok(output_path.to_path_buf()) + if chains.is_empty() { + return String::new(); + } + + let mut graph = chains.join(";"); + graph.push(';'); + match (request.has_video, request.has_audio) { + (true, true) => graph.push_str(&format!( + "{concat_inputs}concat=n={count}:v=1:a=1[cv][ca]" + )), + (true, false) => graph.push_str(&format!( + "{concat_inputs}concat=n={count}:v=1:a=0[cv]" + )), + (false, true) => graph.push_str(&format!( + "{concat_inputs}concat=n={count}:v=0:a=1[ca]" + )), + (false, false) => return String::new(), + } + + if request.has_video { + let mut filters: Vec = Vec::new(); + if !punch_per_segment { + filters.push(crop_and_scale_filter( + request.aspect_ratio, + request.crop_x, + request.crop_y, + )); + } + if let Some(filter) = ass_filter { + filters.push(filter.to_string()); + } + let suffix = filter_suffix.trim_start_matches(','); + if !suffix.is_empty() { + filters.push(suffix.to_string()); + } + if filters.is_empty() { + graph.push_str(";[cv]null[vout]"); + } else { + graph.push_str(&format!(";[cv]{}[vout]", filters.join(","))); + } + } + + graph +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_microsecond_progress_values() { + assert_eq!(parse_ffmpeg_progress_time("out_time_us", "1500000"), Some(1.5)); + assert_eq!(parse_ffmpeg_progress_time("out_time_ms", "250000"), Some(0.25)); + assert_eq!(parse_ffmpeg_progress_time("out_time_us", " 1000000 "), Some(1.0)); + } + + #[test] + fn parses_clock_progress_values() { + assert_eq!(parse_ffmpeg_progress_time("out_time", "00:01:02.5"), Some(62.5)); + assert_eq!(parse_ffmpeg_progress_time("out_time", "01:00:00"), Some(3600.0)); + } + + #[test] + fn ignores_unknown_or_invalid_progress_keys() { + assert_eq!(parse_ffmpeg_progress_time("speed", "1.2x"), None); + assert_eq!(parse_ffmpeg_progress_time("out_time_us", "not-a-number"), None); + assert_eq!(parse_ffmpeg_progress_time("out_time", "bad"), None); + } + + #[test] + fn parses_clock_timestamps() { + assert_eq!(parse_clock_timestamp("00:00:00.000"), Some(0.0)); + assert_eq!(parse_clock_timestamp("01:02:03"), Some(3723.0)); + assert_eq!(parse_clock_timestamp("02:03"), None); + } + + #[test] + fn escapes_ass_filter_special_characters() { + let arg = ass_filter_arg(Path::new("/tmp/a:b,c[d];e\\f.ass")).unwrap(); + assert_eq!(arg, "ass=filename=a\\:b\\,c\\[d\\]\\;e\\\\f.ass"); + } + + #[test] + fn ass_filter_arg_uses_bare_file_name() { + let arg = ass_filter_arg(Path::new("/home/user/project/captions.ass")).unwrap(); + assert_eq!(arg, "ass=filename=captions.ass"); + } + + #[test] + fn ass_filter_arg_returns_none_without_file_name() { + assert_eq!(ass_filter_arg(Path::new("/")), None); + } + + #[test] + fn absolute_path_keeps_absolute_paths() { + assert_eq!(absolute_path(Path::new("/tmp/clip.mp4")), PathBuf::from("/tmp/clip.mp4")); + } + + #[test] + fn absolute_path_resolves_relative_paths() { + assert!(absolute_path(Path::new("clip.mp4")).is_absolute()); + } + + #[test] + fn clip_dimensions_match_aspect_ratio_table() { + assert_eq!(clip_dimensions("9:16"), (1080, 1920)); + assert_eq!(clip_dimensions("1:1"), (1080, 1080)); + assert_eq!(clip_dimensions("16:9"), (1920, 1080)); + assert_eq!(clip_dimensions("4:5"), (1080, 1350)); + assert_eq!(clip_dimensions("unknown"), (1080, 1920)); + } + + #[test] + fn normalize_aspect_ratio_accepts_aliases_and_falls_back() { + assert_eq!(normalize_aspect_ratio("1:1"), "1:1"); + assert_eq!(normalize_aspect_ratio("1x1"), "1:1"); + assert_eq!(normalize_aspect_ratio("Square"), "1:1"); + assert_eq!(normalize_aspect_ratio(" 16:9 "), "16:9"); + assert_eq!(normalize_aspect_ratio("4:5"), "4:5"); + assert_eq!(normalize_aspect_ratio(""), DEFAULT_ASPECT_RATIO); + assert_eq!(normalize_aspect_ratio("3:2"), DEFAULT_ASPECT_RATIO); + } + + #[test] + fn crop_and_scale_filter_builds_landscape_chain() { + let filter = crop_and_scale_filter("16:9", 0.0, 1.0); + assert!(filter.starts_with("crop=w='2*trunc(min(iw,ih*"), "{filter}"); + assert!(filter.contains("x='(iw-ow)*0'"), "{filter}"); + assert!(filter.contains("y='(ih-oh)*1'"), "{filter}"); + assert!(filter.contains("scale=1920:1080:flags=fast_bilinear"), "{filter}"); + } + + #[test] + fn crop_and_scale_filter_clamps_positions() { + let filter = crop_and_scale_filter("9:16", -3.0, f64::NAN); + assert!(filter.contains("x='(iw-ow)*0'"), "{filter}"); + assert!(filter.contains("y='(ih-oh)*0.5'"), "{filter}"); + assert!(filter.contains("scale=1080:1920:flags=fast_bilinear"), "{filter}"); + } + + #[test] + fn preview_proxy_args_target_webm_vp8_opus() { + let args = preview_proxy_args("/media/source.mp4", Path::new("/tmp/preview.webm")); + let joined = args.join(" "); + // The proxy covers the whole media, so no trim flags are emitted. + assert!(!joined.contains("-ss "), "{joined}"); + assert!(!joined.contains("-t "), "{joined}"); + assert!(joined.contains("-map 0:a:0?"), "{joined}"); + assert!(joined.contains("-c:v libvpx"), "{joined}"); + assert!(joined.contains("-c:a libopus"), "{joined}"); + assert!(joined.contains("-f webm"), "{joined}"); + assert!(joined.contains("force_divisible_by=2"), "{joined}"); + assert!(args.last().unwrap().ends_with("preview.webm"), "{joined}"); + } + + #[test] + fn thumbnail_args_seek_before_input_and_scale_down() { + let args = thumbnail_args("/media/source.mp4", 12.5, Path::new("/tmp/thumb.jpg")); + let joined = args.join(" "); + let seek = args.iter().position(|arg| arg == "-ss").unwrap(); + let input = args.iter().position(|arg| arg == "-i").unwrap(); + assert!(seek < input, "{joined}"); + assert!(joined.contains("12.500"), "{joined}"); + assert!(joined.contains("-frames:v 1"), "{joined}"); + assert!(joined.contains("-an"), "{joined}"); + assert!(joined.contains("min(360,iw)"), "{joined}"); + assert!(args.last().unwrap().ends_with("thumb.jpg"), "{joined}"); + } + + #[test] + fn command_exists_rejects_missing_binary() { + assert!(!command_exists("autoshorts-definitely-missing-binary")); + } } diff --git a/src-tauri/src/media_server.rs b/src-tauri/src/media_server.rs new file mode 100644 index 0000000..131fa35 --- /dev/null +++ b/src-tauri/src/media_server.rs @@ -0,0 +1,351 @@ +//! Tiny loopback HTTP server that hands media files to the webview. +//! +//! `