From defcb81e6ebdfdcb4c56db0ededb9b7d25959289 Mon Sep 17 00:00:00 2001 From: Leonardo Bezerra Date: Mon, 26 Oct 2026 19:35:07 -0300 Subject: [PATCH 01/25] chore: commit pending branch work from configure-openai-pattern Snapshot of the work that was already sitting uncommitted on this branch, split out first so the test commits that follow land on a buildable tree. Backend: - llm: generic OpenAI-compatible provider (URL normalization, JSON-mode payload toggle, SSE chunk parsing, chat-completion text extraction). - media: ffmpeg/ffprobe probing, audio extraction, ASS caption rendering, progress reporting and path helpers. - transcription: local Whisper (Python) + Deepgram normalization, model catalog/download progress, segment building. - lib: caption styles (classic-outline, modern-box, vibrant-*), drawtext filter generation, SRT/ASS export, output slugging. - new modules: hardware (encoder/GPU detection), naming (output naming), progress (pipeline progress events). - db: schema migration for the new project fields. - models: EnvironmentStatus/caption fields. Tooling and docs: - scripts/build-all.mjs plus build:all/linux/windows/android/ios scripts. - release workflow: Android and iOS jobs. - .env.example: OPENAI_BASE_URL / OPENAI_COMPATIBLE_* entries. - pyproject.toml + poetry.lock for the Python (Whisper) side, bun.lock. - .gitignore: ignore artifacts/, VS Code settings, Tauri generated schemas. --- .env.example | 7 + .github/workflows/release.yml | 110 ++ .gitignore | 1 + .vscode/settings.json | 4 + README.md | 43 +- bun.lock | 291 +++ package.json | 7 +- poetry.lock | 1345 +++++++++++++ pyproject.toml | 20 + scripts/build-all.mjs | 417 ++++ src-tauri/gen/schemas/linux-schema.json | 2358 +++++++++++++++++++++++ src-tauri/src/db.rs | 4 + src-tauri/src/hardware.rs | 501 +++++ src-tauri/src/lib.rs | 434 ++++- src-tauri/src/llm.rs | 318 ++- src-tauri/src/media.rs | 293 ++- src-tauri/src/models.rs | 6 + src-tauri/src/naming.rs | 243 +++ src-tauri/src/progress.rs | 40 + src-tauri/src/transcription.rs | 447 ++++- src/styles.css | 215 ++- 21 files changed, 6941 insertions(+), 163 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 bun.lock create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 scripts/build-all.mjs create mode 100644 src-tauri/gen/schemas/linux-schema.json create mode 100644 src-tauri/src/hardware.rs create mode 100644 src-tauri/src/naming.rs create mode 100644 src-tauri/src/progress.rs 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..85d1341 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,18 @@ 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: release: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') permissions: contents: write strategy: @@ -46,3 +52,107 @@ jobs: releaseBody: 'See release notes for installation details.' releaseDraft: true prerelease: false + + android: + if: + github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + permissions: + contents: write + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android + + - 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 JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Install Android SDK + uses: android-actions/setup-android@v3 + + - name: Install Android NDK + run: | + sdkmanager --install "ndk;27.0.12077973" + echo "NDK_HOME=$ANDROID_SDK_ROOT/ndk/27.0.12077973" >> "$GITHUB_ENV" + + - name: Install node modules + run: npm ci + + - name: Init Android project + run: npx tauri android init --ci + + - name: Build Android bundles + run: npx tauri android build --apk --aab + + - name: Collect artifacts + run: | + mkdir -p artifacts/android + find src-tauri/gen/android/app/build/outputs -type f \ + \( -name '*.apk' -o -name '*.aab' \) -exec cp {} artifacts/android/ \; + ls -lh artifacts/android + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: android-bundles + path: artifacts/android/* + + ios: + if: + github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + permissions: + contents: write + runs-on: macos-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-ios,x86_64-apple-ios,aarch64-apple-ios-sim + + - name: Install node modules + run: npm ci + + - name: Init iOS project + run: npx tauri ios init --ci + + - name: Build iOS app (unsigned) + run: npx tauri ios build --no-sign + + - name: Collect artifacts + run: | + mkdir -p artifacts/ios + find src-tauri/gen/apple/build -maxdepth 4 -name '*.app' -type d -exec cp -R {} artifacts/ios/ \; + find src-tauri/gen/apple/build -maxdepth 4 -name '*.ipa' -type f -exec cp {} artifacts/ios/ \; + ls -lhR artifacts/ios || true + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: ios-app + path: artifacts/ios/** + diff --git a/.gitignore b/.gitignore index b6c81a6..f8aaacb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ src-tauri/target .env .DS_Store *.log +artifacts 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..762c7cb 100644 --- a/README.md +++ b/README.md @@ -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,33 @@ npm run tauri:build ``` The output installers will be built under `src-tauri/target/release/bundle/`. +### 4. Multi-Platform Build (Windows + Linux + Android + iOS) +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:android # .apk / .aab +npm run build:ios # .app / .ipa (macOS only) + +npm run build:all -- --only=linux,android # 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. | +| Android | `ANDROID_HOME` (or `~/Android/Sdk`) with NDK, JDK 17, Android Rust targets (added automatically). `tauri android init` runs on first build. | +| iOS | macOS + Xcode only. Cannot be produced from Linux/WSL — use a Mac or the `Release App` GitHub workflow. | + ## ❤️ Support AutoShorts diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..74ac613 --- /dev/null +++ b/bun.lock @@ -0,0 +1,291 @@ +{ + "lockfileVersion": 2, + "configVersion": 0, + "workspaces": { + "": { + "name": "autoshorts", + "dependencies": { + "@tauri-apps/api": "^2.5.0", + "@tauri-apps/plugin-dialog": "^2.2.2", + "lucide-react": "^0.475.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + }, + "devDependencies": { + "@tauri-apps/cli": "^2.5.0", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.3", + "vite": "^6.1.0", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="], + + "@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="], + + "@tauri-apps/cli": ["@tauri-apps/cli@2.11.3", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.3", "@tauri-apps/cli-darwin-x64": "2.11.3", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.3", "@tauri-apps/cli-linux-arm64-gnu": "2.11.3", "@tauri-apps/cli-linux-arm64-musl": "2.11.3", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.3", "@tauri-apps/cli-linux-x64-gnu": "2.11.3", "@tauri-apps/cli-linux-x64-musl": "2.11.3", "@tauri-apps/cli-win32-arm64-msvc": "2.11.3", "@tauri-apps/cli-win32-ia32-msvc": "2.11.3", "@tauri-apps/cli-win32-x64-msvc": "2.11.3" }, "bin": { "tauri": "tauri.js" } }, "sha512-EElQe8z8uD7Pi5++tJ/UfEwWuK08rd3oCDYdeIbJAb6pZRrxlqmoF5gh5H5YvzmUPhS4IRCaLSsQhvWkrfK+GQ=="], + + "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.11.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BxpaM8bsCoXs3wd4WKYhas/G1gs7+r7B+e4WnyRk2GEoVOouJB1hoL6E6YLXZDXbYci6VFdrNnobQwd2uVL4ew=="], + + "@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.11.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-DbZYuPB1ZEzcAHYeyCvo3ltzM27+aXwPloCrtexPnmgPgulYJm3TOq6aC4S+wPhSXteddg8zImtNkvx/gQzmwg=="], + + "@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.11.3", "", { "os": "linux", "cpu": "arm" }, "sha512-741NduqBmz1XkdU8yz3OI/kBZtqHbvxo9F9ytIeWYU69/Ba9dcZEbqOU++Dp0G/XU8vAI0TfTywEl+p+BbLvaA=="], + + "@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.11.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-RWAXT8pTqIczXcoic+LXlo6uEbAXGB0cgh6Pg7Y9xVnEbzryQ1JHtRGj9SxzrKSemBIDBH6Qc24kK2G69i8ofA=="], + + "@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.11.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-qomqYS+yAkd0gXMRmhguWXc7RfVN+XKKXaEwbf5QmKURwydLFOTldd6F8/WoZDSsBMrV8dpNxz0YneGLmobiSA=="], + + "@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.11.3", "", { "os": "linux", "cpu": "none" }, "sha512-jOCXbDqeDj5XcclsOBAaXjtTgwZCVg8zEZ+dbPUCoADOgljFgL0rOkYTc96vUYgOrYEfuHYihWMxIDGaD6GwJw=="], + + "@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.11.3", "", { "os": "linux", "cpu": "x64" }, "sha512-+u3HO/F3gHwL48t9gWN/urqZvpaEJzBFmTaq5eSIhvy8TOvnhb+LgJr3Q3BG+5JxuBrCUjqtOEz6gMttdJFSBA=="], + + "@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.11.3", "", { "os": "linux", "cpu": "x64" }, "sha512-spr5Jpr6KF/vehkLwJ0YmdGv8QwpWU+uw7J8bgijO0sox6ZCYsSNMbcsQjTqPi4xl+p0woIYpWXgChgHYpAc8g=="], + + "@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.11.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-abkoRQih5xBa3vz2spWaex0kP/MzVzVPQHom2f8jnCq46R/luOD6Uy85EMU9/bfzf6ZzdorWJsgO+OMX90Fx2w=="], + + "@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.11.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-Vy6AvzFm1G40hg3r+OYDB3jkuu7R4wnMzbQBKuun9v6Cgg8IierpLL7toMzrZKs/8NlG8Sg4x1iLFR52oknyHg=="], + + "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.3", "", { "os": "win32", "cpu": "x64" }, "sha512-GlciF75GdbseajOyib2aCHwE3BXIqZ1liGKWLFRvCdN5wm8h8hFssEVKQ/6E+2jsMLg9v7LCTb983YFnn0QSww=="], + + "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.1", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.38", "", { "bin": "dist/cli.cjs" }, "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw=="], + + "browserslist": ["browserslist@4.28.4", "", { "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", "electron-to-chromium": "^1.5.376", "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": "cli.js" }, "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001799", "", {}, "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.376", "", {}, "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": "bin/esbuild" }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": "bin/jsesc" }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "lucide-react": ["lucide-react@0.475.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-NJzvVu1HwFVeZ+Gwq2q00KygM1aBhy/ZrhY9FsAgJtpB+E4R7uxRk9M2iKvHa6/vNxZydIB59htha4c2vvwvVg=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.15", "", { "bin": "bin/nanoid.cjs" }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], + + "node-releases": ["node-releases@2.0.48", "", {}, "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + + "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": "bin/vite.js" }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + } +} diff --git a/package.json b/package.json index d23e205..751cca1 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,12 @@ "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:android": "node scripts/build-all.mjs --only=android", + "build:ios": "node scripts/build-all.mjs --only=ios" }, "dependencies": { "@tauri-apps/api": "^2.5.0", 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..4c1314a --- /dev/null +++ b/scripts/build-all.mjs @@ -0,0 +1,417 @@ +#!/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,android + * 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 + * - Android : Android SDK + NDK + JDK + rust targets android + * - iOS : 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 os from 'node:os'; +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'), + }; +})(); + +const androidSdk = () => + firstExisting([ + process.env.ANDROID_HOME, + process.env.ANDROID_SDK_ROOT, + path.join(os.homedir(), 'Android/Sdk'), + ]); + +const ANDROID_RUST_TARGETS = [ + 'aarch64-linux-android', + 'armv7-linux-androideabi', + 'i686-linux-android', + 'x86_64-linux-android', +]; + +function installedRustTargets() { + const out = capture('rustup', ['target', 'list', '--installed']); + return out ? out.split('\n').map((l) => l.trim()) : []; +} + +/* ------------------------------------------------------------ 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. + const scriptPath = path.join(staging, 'build-autoshorts.cmd'); + const scriptWin = path.join(stagingWin, 'build-autoshorts.cmd'); + const bundleFlag = bundles ? ` -- --bundles ${bundles}` : ''; + fs.writeFileSync( + scriptPath, + [ + '@echo off', + 'setlocal', + 'cd /d "%~dp0"', + `set "PATH=${[nodeWinDir, cargoBinWin].filter(Boolean).join(';')};%PATH%"`, + // `npm ci` só é necessário quando as dependências (e o binário do CLI + // do Tauri) ainda não existem no staging do Windows. + 'if not exist "node_modules\\.bin\\tauri.cmd" (', + ' echo [build] instalando dependencias no Windows...', + ' call npm ci --no-audit --no-fund', + ' if errorlevel 1 exit /b 1', + ')', + `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: 'android', + label: 'Android (.apk / .aab)', + reason() { + const sdk = androidSdk(); + if (!sdk) return 'Android SDK não encontrado (defina ANDROID_HOME ou instale ~/Android/Sdk)'; + if (!fs.existsSync(path.join(sdk, 'ndk'))) return `NDK não encontrado em ${sdk}/ndk`; + if (!which('java')) return 'JDK não encontrado (instale openjdk-17)'; + return null; + }, + build() { + const missingTargets = ANDROID_RUST_TARGETS.filter((t) => !installedRustTargets().includes(t)); + if (missingTargets.length) { + const res = run('rustup', ['target', 'add', ...missingTargets]); + if (res.status !== 0) throw new Error('`rustup target add` para Android falhou'); + } + if (!fs.existsSync(path.join(ROOT, 'src-tauri/gen/android'))) { + const res = run('npx', ['tauri', 'android', 'init']); + if (res.status !== 0) throw new Error('`tauri android init` falhou'); + } + const res = run('npx', ['tauri', 'android', 'build']); + if (res.status !== 0) throw new Error('`tauri android build` falhou'); + collect( + 'android', + [path.join(ROOT, 'src-tauri/gen/android/app/build/outputs')], + ['.apk', '.aab'], + ); + }, + }, + { + id: 'ios', + label: 'iOS (.app / .ipa)', + reason() { + if (!IS_MAC) return 'iOS exige macOS + Xcode — impossível compilar a partir de Linux/WSL'; + if (!which('xcodebuild')) return 'Xcode não instalado'; + return null; + }, + build() { + if (!fs.existsSync(path.join(ROOT, 'src-tauri/gen/apple'))) { + const res = run('npx', ['tauri', 'ios', 'init']); + if (res.status !== 0) throw new Error('`tauri ios init` falhou'); + } + const res = run('npx', ['tauri', 'ios', 'build']); + if (res.status !== 0) throw new Error('`tauri ios build` falhou'); + collect('ios', [path.join(ROOT, 'src-tauri/gen/apple/build')], ['.ipa', '.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/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/db.rs b/src-tauri/src/db.rs index c487928..8f3be34 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -99,6 +99,10 @@ impl Database { )?; let _ = conn.execute("ALTER TABLE projects ADD COLUMN name TEXT", []); let _ = conn.execute("ALTER TABLE projects ADD COLUMN caption_style 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(()) } 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..a068eef 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,7 +1,10 @@ mod db; +pub mod hardware; mod llm; mod media; mod models; +pub mod naming; +mod progress; mod transcription; use std::path::PathBuf; @@ -55,13 +58,32 @@ async fn environment_status(state: tauri::State<'_, AppState>) -> Result 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 +336,7 @@ fn get_project_detail( #[tauri::command] fn probe_project( + app: tauri::AppHandle, state: tauri::State<'_, AppState>, project_id: String, ) -> Result { @@ -321,16 +344,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 +364,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 +403,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)? @@ -378,18 +422,39 @@ async fn transcribe_project( 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()); } + 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 +489,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 +561,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 +595,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) } @@ -529,6 +613,7 @@ fn set_selected_clip_count( #[tauri::command] async fn render_flat_clip_for_candidate( + app: tauri::AppHandle, state: tauri::State<'_, AppState>, candidate_id: String, ) -> Result { @@ -542,79 +627,114 @@ async fn render_flat_clip_for_candidate( 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. + let file_name = naming::clip_file_name( + &project_output_slug(&project), + candidate.rank, + &candidate.hook, + candidate.start_sec, + candidate.end_sec, + ); + naming::unique_path(&clips_dir, &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; - 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 - }; + // Probe once and reuse the result for the whole render. + let has_video = media::probe_media(&project.source_path) + .map(|probe| probe.has_video) + .unwrap_or(true); if let Ok(Some(transcript_record)) = db.latest_transcript(&project.id) { if let Ok(normalized) = serde_json::from_str::(&transcript_record.raw_json) { + let project_data_dir = data_dir.join("projects").join(&project.id); + let _ = std::fs::create_dir_all(&project_data_dir); + + // Plain-text sidecar kept for the UI / upload metadata. 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 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 now + // evaluates a single `ass` filter per frame instead of dozens. let style = project.caption_style.as_deref().unwrap_or("modern-box"); - let drawtext = build_drawtext_filters( + let ass_content = build_ass_captions( &normalized.words, candidate.start_sec, candidate.end_sec, - cropped_width, style, ); - if !drawtext.is_empty() { - drawtext_filters = Some(drawtext); + 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 request = media::ClipRenderRequest { + source_path: &project.source_path, + start_sec: candidate.start_sec, + end_sec: candidate.end_sec, + output_path: &output_path, + has_video, + ass_path: caption_ass_path.as_deref(), + }; + + 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 +744,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 +780,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(); @@ -677,6 +799,8 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ environment_status, + list_whisper_models, + download_whisper_model, pull_ollama_model, install_ollama, create_project_from_path, @@ -734,8 +858,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 +891,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 +903,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 { @@ -919,6 +1057,191 @@ 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" + } +} + +struct AssCaptionStyle { + primary_colour: &'static str, + outline_colour: &'static str, + back_colour: &'static str, + border_style: u8, + outline: f64, + shadow: f64, + margin_v: i64, +} + +/// 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 { + match name { + "classic-outline" => AssCaptionStyle { + primary_colour: "&H0000FFFF", // yellow + outline_colour: "&H00000000", + back_colour: "&H80000000", + border_style: 1, + outline: 5.0, + shadow: 0.0, + margin_v: 592, + }, + "minimal-shadow" => AssCaptionStyle { + primary_colour: "&H00FFFFFF", + outline_colour: "&H00000000", + back_colour: "&H80000000", + border_style: 1, + outline: 0.0, + shadow: 3.0, + margin_v: 496, + }, + "vibrant-cyan" => AssCaptionStyle { + primary_colour: "&H00FFFF00", + outline_colour: "&H00000000", + back_colour: "&H80000000", + border_style: 1, + outline: 0.0, + shadow: 3.0, + margin_v: 496, + }, + "vibrant-yellow-box" => AssCaptionStyle { + primary_colour: "&H00000000", // black text on yellow box + outline_colour: "&H00000000", + back_colour: "&H1F00FFFF", + border_style: 3, + outline: 10.0, + shadow: 0.0, + margin_v: 458, + }, + "vibrant-green" => AssCaptionStyle { + primary_colour: "&H0014FF39", + outline_colour: "&H00000000", + back_colour: "&H80000000", + border_style: 1, + outline: 4.0, + shadow: 2.0, + margin_v: 496, + }, + "vibrant-red" => AssCaptionStyle { + primary_colour: "&H00303BFF", + outline_colour: "&H00000000", + back_colour: "&H80000000", + border_style: 1, + outline: 4.0, + shadow: 2.0, + margin_v: 496, + }, + _ => AssCaptionStyle { + primary_colour: "&H00FFFFFF", + outline_colour: "&H00000000", + back_colour: "&H50000000", + border_style: 3, + outline: 10.0, + shadow: 0.0, + margin_v: 458, + }, + } +} + +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. +fn build_ass_captions( + words: &[TranscriptWord], + start_sec: f64, + end_sec: f64, + caption_style: &str, +) -> String { + let style = ass_caption_style(caption_style); + let font = caption_font_name(); + + let mut ass = String::new(); + ass.push_str("[Script Info]\n"); + ass.push_str("ScriptType: v4.00+\n"); + ass.push_str("PlayResX: 1080\n"); + ass.push_str("PlayResY: 1920\n"); + 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},81,{primary},&H000000FF,{outline_c},{back},-1,0,0,0,100,100,0,0,\ + {border},{outline_w},{shadow},2,40,40,{margin_v},1\n\n", + 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_v = style.margin_v, + )); + + ass.push_str("[Events]\n"); + ass.push_str("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"); + + 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.to_uppercase()) + .collect::>() + .join(" "); + 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) + )); + } + + ass +} + +/// 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, @@ -1068,7 +1391,6 @@ fn build_drawtext_filters( #[cfg(test)] mod tests { use super::*; - use crate::models::TranscriptWord; #[test] fn test_build_drawtext_filters_formatting() { @@ -1088,13 +1410,13 @@ mod tests { ]; let result = build_drawtext_filters(&words, 0.0, 5.0, 1080, "classic-outline"); + assert!(!result.is_empty()); assert!(result.contains("drawtext=")); assert!(result.contains("text='HELLO WORLD'")); - if result.contains("fontfile=") { assert!(result.contains("fontfile='")); - assert!(!result.contains("\\:")); } + assert!(!result.contains("\\:")); } } diff --git a/src-tauri/src/llm.rs b/src-tauri/src/llm.rs index 990072a..75b2200 100644 --- a/src-tauri/src/llm.rs +++ b/src-tauri/src/llm.rs @@ -4,6 +4,36 @@ use serde_json::json; 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") + } +} + #[derive(Debug, Deserialize)] struct AnthropicMessage { content: Vec, @@ -186,17 +216,268 @@ 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 collected = String::new(); + for line in body.lines() { + let line = line.trim(); + if !line.starts_with("data:") { + continue; + } + let payload = line["data:".len()..].trim(); + if payload.is_empty() || payload == "[DONE]" { + continue; + } + let chunk: serde_json::Value = serde_json::from_str(payload).ok()?; + if chunk.get("object").and_then(|o| o.as_str()) != Some("chat.completion.chunk") { + return None; + } + if let Some(delta) = chunk + .pointer("/choices/0/delta/content") + .and_then(|c| c.as_str()) + { + collected.push_str(delta); + } + if let Some(text) = chunk.pointer("/choices/0/text").and_then(|t| t.as_str()) { + collected.push_str(text); + } + } + if collected.trim().is_empty() { + None + } else { + Some(collected) + } +} + +/// 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 res_body: ChatCompletionResponse = serde_json::from_str(&body).with_context(|| { + format!( + "parsing {provider_label} response: {}", + body_snippet(&body) + ) + })?; + if let Some(text) = extract_completion_text(&res_body) { + return Ok(text); + } + if let Some(text) = parse_sse_stream_text(&body) { + return Ok(text); + } + Err(anyhow!( + "{provider_label} response did not include choices content: {}", + body_snippet(&body) + )) +} + +fn chat_completion_payload(model: &str, prompt: &str, json_mode: bool) -> serde_json::Value { + let mut payload = json!({ + "model": model, + "messages": [ + { + "role": "user", + "content": prompt, + } + ], + "temperature": 0.2, + // Some [OI]-compatible servers (LM Studio, vLLM) default to streaming; force a full response. + "stream": false, + }); + 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, + provider_label: &str, +) -> Result { + let mut request = client + .post(url) + .json(&chat_completion_payload(model, prompt, json_mode)); + 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}")) +} + +/// 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 url = normalize_chat_completions_url(base_url); + let client = reqwest::Client::new(); + + let mut response = send_chat_completion( + &client, + &url, + api_key, + model, + &prompt, + true, + provider_label, + ) + .await?; + + // Some OpenAI-compatible servers (LM Studio, llama.cpp, vLLM) reject `response_format`. + if matches!(response.status().as_u16(), 400 | 422) { + response = send_chat_completion( + &client, + &url, + api_key, + model, + &prompt, + false, + provider_label, + ) + .await?; + } + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(anyhow!("{provider_label} request failed ({status}): {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 +499,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 +529,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 +591,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 +648,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) diff --git a/src-tauri/src/media.rs b/src-tauri/src/media.rs index 659764d..089ad31 100644 --- a/src-tauri/src/media.rs +++ b/src-tauri/src/media.rs @@ -1,17 +1,111 @@ 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::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 +171,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 +184,181 @@ 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 output = Command::new("ffmpeg") - .args(["-y", "-i", source_path, "-vn", "-ac", "1", "-ar", "16000"]) - .arg(&output_path) - .output() - .context("running ffmpeg audio extraction")?; + let total_duration = probe_media(source_path).ok().and_then(|probe| probe.duration_sec); - if !output.status.success() { - return Err(anyhow!( - "ffmpeg audio extraction failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - )); - } + 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) } +/// Options for rendering a single vertical 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, + /// Optional ASS subtitle file burned into the video. + pub ass_path: Option<&'a Path>, +} + +/// Output resolution for every clip. Normalizing here keeps the encoder from +/// working on 4K frames when the source is larger than a phone screen. +const CLIP_WIDTH: i64 = 1080; +const CLIP_HEIGHT: i64 = 1920; + +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 = (request.end_sec - request.start_sec).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 probe = probe_media(source_path).ok(); - let has_video = probe.map(|p| p.has_video).unwrap_or(false); + 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 duration = format!("{duration_sec:.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; + + if request.has_video { + let mut filters = vec![ + "crop=w='2*trunc(min(iw,ih*9/16)/2)':h='2*trunc(min(ih,iw*16/9)/2)'".to_string(), + format!("scale={CLIP_WIDTH}:{CLIP_HEIGHT}:flags=fast_bilinear"), + ]; + + 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()); + } + if let Some(filter) = ass_filter_arg(&ass_path) { + 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); - - let output = cmd.output().context("running ffmpeg clip render")?; + cmd.args(["-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart"]); + cmd.arg(&output_arg); - if !output.status.success() { - return Err(anyhow!( - "ffmpeg clip render failed: {}", - String::from_utf8_lossy(&output.stderr).trim() - )); - } + let args: Vec = cmd + .get_args() + .map(|arg| arg.to_string_lossy().to_string()) + .collect(); - Ok(output_path.to_path_buf()) + // `-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) } diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 2f43705..6e48398 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -13,10 +13,16 @@ pub struct EnvironmentStatus { pub has_openai_key: bool, pub has_openrouter_key: bool, pub has_groq_key: bool, + pub has_openai_compatible_key: bool, + pub openai_base_url: Option, pub llm_provider: String, pub has_local_whisper_model: bool, pub has_ollama: bool, pub has_ytdlp: bool, + /// True when a hardware video encoder was detected and probed successfully. + pub gpu_accel: bool, + /// Human-readable name of the encoder actually used for clip rendering. + pub video_encoder: String, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/naming.rs b/src-tauri/src/naming.rs new file mode 100644 index 0000000..3348337 --- /dev/null +++ b/src-tauri/src/naming.rs @@ -0,0 +1,243 @@ +//! Human-readable names for rendered clips. +//! +//! Clips used to be written as `clip-03_flat.mp4`, which is useless once the +//! files leave the app. Names are now built from the project name, the clip +//! rank and the hook line, then sanitized so they stay valid on Linux, macOS +//! and Windows (no reserved characters, no trailing dots or spaces). + +use std::path::{Path, PathBuf}; + +/// Longest hook fragment kept in a file name. Enough to be descriptive while +/// staying well below the 255-byte limit of common file systems. +const MAX_HOOK_LEN: usize = 60; + +/// Replace Latin-1 accented characters with their ASCII base so Portuguese +/// and Spanish titles keep their words instead of collapsing to separators. +fn deaccent(ch: char) -> Option { + const TABLE: &[(char, &str)] = &[ + ('a', "áàâãäå"), + ('e', "éèêë"), + ('i', "íìîï"), + ('o', "óòôõö"), + ('u', "úùûü"), + ('c', "ç"), + ('n', "ñ"), + ('y', "ýÿ"), + ('s', "śš"), + ('z', "źž"), + ]; + + let lowered = ch.to_lowercase().next().unwrap_or(ch); + for (base, accented) in TABLE { + if accented.contains(lowered) { + return Some(if ch.is_uppercase() { + base.to_ascii_uppercase() + } else { + *base + }); + } + } + None +} + +/// Turn arbitrary text into a filesystem-safe slug. +/// +/// Keeps `a-z`, `A-Z`, `0-9` and `_`; every other character collapses into a +/// single `-`. Returns an empty string when nothing usable is left. +pub fn slugify(input: &str, max_len: usize) -> String { + let mut out = String::with_capacity(input.len()); + let mut last_was_sep = false; + + for ch in input.chars() { + if ch.is_ascii_alphanumeric() || ch == '_' { + out.push(ch); + last_was_sep = false; + continue; + } + + match deaccent(ch) { + Some(mapped) => { + out.push(mapped); + last_was_sep = false; + } + None => { + if !last_was_sep && !out.is_empty() { + out.push('-'); + last_was_sep = true; + } + } + } + } + + let trimmed = out.trim_matches('-').to_string(); + if trimmed.chars().count() <= max_len { + return trimmed; + } + + // Cut on a word boundary when possible so names stay readable. + let cut: String = trimmed.chars().take(max_len).collect(); + let boundary = cut.rsplit_once('-').map(|(head, _)| head.to_string()); + match boundary { + Some(head) if head.chars().count() >= max_len / 2 => head.trim_matches('-').to_string(), + _ => cut.trim_matches('-').to_string(), + } +} + +/// Format a position inside the source video as `2m03s`. +pub fn timecode(seconds: f64) -> String { + let total = if seconds.is_finite() && seconds > 0.0 { + seconds.round() as u64 + } else { + 0 + }; + format!("{}m{:02}s", total / 60, total % 60) +} + +/// Build the file name for a clip: `__.mp4`. +/// +/// Falls back to the clip time range when the hook has no usable characters, +/// so two clips of the same project never collide by accident. +pub fn clip_file_name( + project_slug: &str, + rank: i64, + hook: &str, + start_sec: f64, + end_sec: f64, +) -> String { + let project = slugify(project_slug, 40); + let project = if project.is_empty() { + "clip".to_string() + } else { + project + }; + + let hook_slug = slugify(hook, MAX_HOOK_LEN); + let suffix = if hook_slug.is_empty() { + format!("{}-{}", timecode(start_sec), timecode(end_sec)) + } else { + hook_slug + }; + + format!("{project}_{:02}_{suffix}.mp4", rank.max(1)) +} + +/// Return `dir/file_name`, appending `-2`, `-3`, … when the file exists. +/// +/// Re-cutting a clip overwrites its previous render only when the name still +/// matches; a renamed hook therefore produces a new file instead of silently +/// replacing the old one. +pub fn unique_path(dir: &Path, file_name: &str) -> PathBuf { + let candidate = dir.join(file_name); + if !candidate.exists() { + return candidate; + } + + let path = Path::new(file_name); + let stem = path + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "clip".to_string()); + let extension = path + .extension() + .map(|e| e.to_string_lossy().to_string()) + .unwrap_or_else(|| "mp4".to_string()); + + for index in 2..1000 { + let next = dir.join(format!("{stem}-{index}.{extension}")); + if !next.exists() { + return next; + } + } + candidate +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slugify_keeps_alphanumerics_and_separators() { + assert_eq!(slugify("My Video - Final!", 60), "My-Video-Final"); + assert_eq!(slugify("episode_01", 60), "episode_01"); + } + + #[test] + fn slugify_collapses_repeated_separators() { + assert_eq!(slugify("a /// b", 60), "a-b"); + } + + #[test] + fn slugify_transliterates_accents() { + assert_eq!(slugify("Não é possível", 60), "Nao-e-possivel"); + assert_eq!(slugify("AÇÃO Çedilha", 60), "ACAO-Cedilha"); + } + + #[test] + fn slugify_returns_empty_for_symbol_only_input() { + assert_eq!(slugify("!!! ???", 60), ""); + assert_eq!(slugify("", 60), ""); + } + + #[test] + fn slugify_truncates_on_word_boundary() { + let long = "this is a very long hook that keeps going and going"; + let slug = slugify(long, 20); + assert!(slug.chars().count() <= 20, "{slug}"); + assert!(!slug.ends_with('-'), "{slug}"); + } + + #[test] + fn timecode_formats_minutes_and_seconds() { + assert_eq!(timecode(0.0), "0m00s"); + assert_eq!(timecode(123.4), "2m03s"); + assert_eq!(timecode(f64::NAN), "0m00s"); + } + + #[test] + fn clip_file_name_uses_project_rank_and_hook() { + let name = clip_file_name("My Video", 3, "Nobody talks about this!", 12.0, 45.0); + assert_eq!(name, "My-Video_03_Nobody-talks-about-this.mp4"); + } + + #[test] + fn clip_file_name_falls_back_to_time_range() { + let name = clip_file_name("proj", 1, "!!!", 12.0, 45.0); + assert_eq!(name, "proj_01_0m12s-0m45s.mp4"); + } + + #[test] + fn clip_file_name_survives_empty_project_slug() { + let name = clip_file_name("", 2, "hook", 0.0, 30.0); + assert_eq!(name, "clip_02_hook.mp4"); + } + + #[test] + fn unique_path_returns_plain_name_when_free() { + let dir = std::env::temp_dir().join(format!("autoshorts-naming-{}", uuid_like())); + std::fs::create_dir_all(&dir).unwrap(); + let path = unique_path(&dir, "clip.mp4"); + assert_eq!(path, dir.join("clip.mp4")); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn unique_path_appends_counter_when_taken() { + let dir = std::env::temp_dir().join(format!("autoshorts-naming-{}", uuid_like())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("clip.mp4"), b"x").unwrap(); + let path = unique_path(&dir, "clip.mp4"); + assert_eq!(path, dir.join("clip-2.mp4")); + std::fs::write(dir.join("clip-2.mp4"), b"x").unwrap(); + assert_eq!(unique_path(&dir, "clip.mp4"), dir.join("clip-3.mp4")); + std::fs::remove_dir_all(&dir).unwrap(); + } + + fn uuid_like() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("{nanos}") + } +} diff --git a/src-tauri/src/progress.rs b/src-tauri/src/progress.rs new file mode 100644 index 0000000..86f1403 --- /dev/null +++ b/src-tauri/src/progress.rs @@ -0,0 +1,40 @@ +//! Unified progress reporting for every long-running pipeline step. +//! +//! Backend commands emit a single `pipeline-progress` event consumed by one +//! global progress bar in the UI. `percentage` is `None` when the step cannot +//! be measured (cloud uploads, LLM calls), which the UI renders as an +//! indeterminate bar. + +use serde::Serialize; +use tauri::{AppHandle, Emitter}; + +pub const PIPELINE_PROGRESS_EVENT: &str = "pipeline-progress"; + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProgressPayload { + /// Machine-readable stage id: import, extract, transcribe, moments, cut, youtube. + pub stage: String, + /// Human-readable description shown next to the bar. + pub label: String, + /// 0..=100, or `None` for indeterminate progress. + pub percentage: Option, +} + +/// Emit a progress update. Failures are ignored: progress reporting must never +/// abort the actual work. +pub fn emit(app: &AppHandle, stage: &str, label: impl Into, percentage: Option) { + let _ = app.emit( + PIPELINE_PROGRESS_EVENT, + ProgressPayload { + stage: stage.to_string(), + label: label.into(), + percentage: percentage.map(|value| value.clamp(0.0, 100.0)), + }, + ); +} + +/// Convenience helper for marking a stage as finished. +pub fn finish(app: &AppHandle, stage: &str, label: impl Into) { + emit(app, stage, label, Some(100.0)); +} diff --git a/src-tauri/src/transcription.rs b/src-tauri/src/transcription.rs index 84fa8f8..4d32207 100644 --- a/src-tauri/src/transcription.rs +++ b/src-tauri/src/transcription.rs @@ -1,10 +1,244 @@ use std::collections::BTreeSet; +use std::io::{BufRead, BufReader, Read}; +use std::path::PathBuf; +use std::process::Stdio; use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; use serde_json::Value; +use tauri::Emitter; use crate::models::{NormalizedTranscript, TranscriptSegment, TranscriptWord}; +/// Whisper checkpoints supported by the download UI: (id, label, approx size in MB). +pub const WHISPER_MODELS: &[(&str, &str, u64)] = &[ + ("tiny", "Tiny", 75), + ("base", "Base", 142), + ("small", "Small", 466), + ("medium", "Medium", 1500), + ("large-v3", "Large v3", 3090), +]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WhisperModelInfo { + pub id: String, + pub label: String, + pub size_mb: u64, + pub downloaded: bool, + pub path: Option, +} + +/// Directories where the Python `openai-whisper` package stores its checkpoints. +pub fn whisper_cache_dirs() -> Vec { + let mut dirs: Vec = Vec::new(); + + if let Some(home) = dirs::home_dir() { + dirs.push(home.join(".cache").join("whisper")); + } + if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") { + if !xdg.trim().is_empty() { + dirs.push(PathBuf::from(xdg).join("whisper")); + } + } + if let Some(cache) = dirs::cache_dir() { + dirs.push(cache.join("whisper")); + } + + dirs.dedup(); + dirs +} + +pub fn whisper_model_path(model: &str) -> PathBuf { + let file = format!("{model}.pt"); + for dir in whisper_cache_dirs() { + let candidate = dir.join(&file); + if candidate.is_file() { + return candidate; + } + } + whisper_cache_dirs() + .into_iter() + .next() + .unwrap_or_else(|| PathBuf::from(".").join("whisper")) + .join(file) +} + +pub fn whisper_model_downloaded(model: &str) -> bool { + let path = whisper_model_path(model); + std::fs::metadata(&path) + .map(|meta| meta.is_file() && meta.len() > 0) + .unwrap_or(false) +} + +pub fn list_whisper_models() -> Vec { + WHISPER_MODELS + .iter() + .map(|(id, label, size_mb)| { + let downloaded = whisper_model_downloaded(id); + WhisperModelInfo { + id: (*id).to_string(), + label: (*label).to_string(), + size_mb: *size_mb, + downloaded, + path: downloaded.then(|| whisper_model_path(id).to_string_lossy().to_string()), + } + }) + .collect() +} + +fn parse_percentage(line: &str) -> Option { + let percent_index = line.find('%')?; + let digits: String = line[..percent_index] + .chars() + .rev() + .take_while(|c| c.is_ascii_digit() || *c == '.') + .collect::>() + .into_iter() + .rev() + .collect(); + digits.parse::().ok() +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct WhisperDownloadPayload { + status: String, + percentage: Option, +} + +/// Downloads a Whisper checkpoint by letting the Python package fetch it into its cache, +/// streaming progress back to the UI through the `whisper-model-progress` event. +pub async fn download_whisper_model(app: tauri::AppHandle, model: String) -> Result<()> { + let model = model.trim().to_string(); + if model.is_empty() { + return Err(anyhow!("No Whisper model was selected")); + } + + if whisper_model_downloaded(&model) { + let _ = app.emit( + "whisper-model-progress", + WhisperDownloadPayload { + status: "Model already downloaded".to_string(), + percentage: Some(100.0), + }, + ); + return Ok(()); + } + + if !whisper_python_exists() { + return Err(anyhow!( + "Python package 'whisper' is not installed. Run 'pip3 install -U openai-whisper' first." + )); + } + + let python = find_python_command().ok_or_else(|| anyhow!("Python interpreter not found"))?; + let label = WHISPER_MODELS + .iter() + .find(|(id, _, _)| *id == model) + .map(|(_, label, _)| (*label).to_string()) + .unwrap_or_else(|| model.clone()); + + let _ = app.emit( + "whisper-model-progress", + WhisperDownloadPayload { + status: format!("Preparing {label} model..."), + percentage: Some(0.0), + }, + ); + + let script = "import sys\nimport whisper\nwhisper.load_model(sys.argv[1])\nprint('AUTOSHORTS_MODEL_READY')\n"; + + let app_handle = app.clone(); + let model_for_task = model.clone(); + let label_for_task = label.clone(); + + tokio::task::spawn_blocking(move || -> Result<()> { + let mut child = std::process::Command::new(&python) + .args(["-c", script, &model_for_task]) + .env("PYTHONUNBUFFERED", "1") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("starting python whisper download")?; + + let mut stderr = child.stderr.take().context("capturing whisper stderr")?; + let mut buffer: Vec = Vec::new(); + let mut byte = [0u8; 1]; + let mut last_percentage = 0.0_f64; + let mut tail: Vec = Vec::new(); + + loop { + match stderr.read(&mut byte) { + Ok(0) => break, + Ok(_) => { + if byte[0] == b'\r' || byte[0] == b'\n' { + if !buffer.is_empty() { + let line = String::from_utf8_lossy(&buffer).trim().to_string(); + buffer.clear(); + if line.is_empty() { + continue; + } + if let Some(percentage) = parse_percentage(&line) { + if percentage > last_percentage { + last_percentage = percentage; + let _ = app_handle.emit( + "whisper-model-progress", + WhisperDownloadPayload { + status: format!( + "Downloading {label_for_task} model: {percentage:.0}%" + ), + percentage: Some(percentage), + }, + ); + } + } else { + tail.push(line); + if tail.len() > 20 { + tail.remove(0); + } + } + } + } else { + buffer.push(byte[0]); + } + } + Err(_) => break, + } + } + + let status = child.wait().context("waiting for whisper download")?; + if !status.success() { + return Err(anyhow!( + "Whisper download failed: {}", + tail.join(" | ") + )); + } + + if !whisper_model_downloaded(&model_for_task) { + return Err(anyhow!( + "Whisper finished but the checkpoint for '{}' was not found in {}", + model_for_task, + whisper_model_path(&model_for_task).to_string_lossy() + )); + } + + Ok(()) + }) + .await + .context("spawn_blocking failed")??; + + let _ = app.emit( + "whisper-model-progress", + WhisperDownloadPayload { + status: format!("{label} model ready"), + percentage: Some(100.0), + }, + ); + + Ok(()) +} + pub async fn transcribe_deepgram(audio_path: &str, api_key: &str) -> Result { let bytes = tokio::fs::read(audio_path) .await @@ -247,9 +481,98 @@ fn normalize_whisper_raw_json(raw: serde_json::Value) -> Result Result { +/// Callback used to report transcription progress as a 0..=100 percentage. +pub type ProgressCallback = Box; + +/// Parse `HH:MM:SS.mmm` (or `MM:SS.mmm`) into seconds. +fn parse_timestamp_token(token: &str) -> Option { + let parts: Vec<&str> = token.trim().split(':').collect(); + match parts.as_slice() { + [hours, minutes, seconds] => Some( + hours.parse::().ok()? * 3600.0 + + minutes.parse::().ok()? * 60.0 + + seconds.parse::().ok()?, + ), + [minutes, seconds] => Some( + minutes.parse::().ok()? * 60.0 + seconds.parse::().ok()?, + ), + [seconds] => seconds.parse::().ok(), + _ => None, + } +} + +/// Whisper prints `[00:00:00.000 --> 00:00:05.000] text` for every decoded +/// segment, which gives us a real progress signal without extra tooling. +fn parse_segment_end_sec(line: &str) -> Option { + let arrow = line.find("--> ")?; + let rest = line[arrow + 4..].trim_start(); + let end = rest.split(']').next()?.trim(); + parse_timestamp_token(end) +} + +/// Run a whisper process while streaming its stdout, mapping decoded segment +/// timestamps onto a 0..=100 progress value. +fn run_streaming( + mut cmd: std::process::Command, + total_duration: Option, + on_progress: &mut Option, +) -> Result<(std::process::ExitStatus, String, String)> { + let mut child = cmd + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("starting whisper process")?; + + let stderr_handle = { + let mut stderr = child.stderr.take().context("capturing whisper stderr")?; + std::thread::spawn(move || { + let mut buffer = String::new(); + let _ = stderr.read_to_string(&mut buffer); + buffer + }) + }; + + let mut stdout_buffer = String::new(); + if let Some(stdout) = child.stdout.take() { + let mut last_percentage = 0.0_f64; + for line in BufReader::new(stdout).lines() { + let Ok(line) = line else { break }; + stdout_buffer.push_str(&line); + stdout_buffer.push('\n'); + + if let (Some(total), Some(callback)) = (total_duration, on_progress.as_mut()) { + if total > 0.0 { + if let Some(end) = parse_segment_end_sec(&line) { + let percentage = (end / total * 100.0).clamp(0.0, 99.0); + if percentage > last_percentage + 0.5 { + last_percentage = percentage; + callback(percentage); + } + } + } + } + } + } + + let status = child.wait().context("waiting for whisper process")?; + let stderr_buffer = stderr_handle.join().unwrap_or_default(); + Ok((status, stdout_buffer, stderr_buffer)) +} + +pub async fn transcribe_local( + audio_path: &str, + model_dir: &str, + model_size: &str, + total_duration: Option, + on_progress: Option, +) -> Result { let audio_path = audio_path.to_string(); - let model_path = model_path.to_string(); + let model_dir = model_dir.to_string(); + let model_size = if model_size.trim().is_empty() { + "base".to_string() + } else { + model_size.trim().to_string() + }; if whisper_cli_exists() { let audio_path_buf = std::path::Path::new(&audio_path); @@ -261,20 +584,50 @@ pub async fn transcribe_local(audio_path: &str, model_path: &str) -> Result| { + let mut cmd = std::process::Command::new("whisper"); + cmd.arg(&audio_path) + .args(["--model", &cli_model]) + .args(["--output_format", "json"]) + .args(["--output_dir", &audio_dir_str]) + .args(["--word_timestamps", "True"]) + .args(["--verbose", "True"]); + if let Some(device) = device { + cmd.args(["--device", device]); + } + // Keep stdout unbuffered so segment timestamps arrive as they are decoded. + cmd.env("PYTHONUNBUFFERED", "1"); + cmd + }; + + let (mut status, mut stdout, mut stderr) = run_streaming( + build((device != "cpu").then_some(device)), + total_duration, + &mut on_progress, + )?; + + if !status.success() && device != "cpu" { + // The `whisper` executable may run under a different interpreter + // than the one we probed, so let it pick its own device. + eprintln!( + "[autoshorts] whisper --device {device} failed; retrying with the default device" + ); + let (retry_status, retry_stdout, retry_stderr) = + run_streaming(build(None), total_duration, &mut on_progress)?; + status = retry_status; + stdout = retry_stdout; + stderr = retry_stderr; + } + + if !status.success() { return Err(anyhow!("Whisper CLI failed:\nStderr: {}\nStdout: {}", stderr, stdout)); } @@ -298,30 +651,46 @@ pub async fn transcribe_local(audio_path: &str, model_path: &str) -> Result [model_name]") + print("Usage: transcribe.py [model_name] [device]") sys.exit(1) audio_path = sys.argv[1] output_json_path = sys.argv[2] model_name = sys.argv[3] if len(sys.argv) > 3 else "base" + device = sys.argv[4] if len(sys.argv) > 4 else "" - # Load model. Automatically uses MPS on Apple Silicon if PyTorch supports it. - model = whisper.load_model(model_name) + # Load model. Uses the requested GPU device when one was detected + # (CUDA / Apple MPS), otherwise the default CPU path. + model = load_model(model_name, device) - # Transcribe with word-level timestamps - result = model.transcribe(audio_path, word_timestamps=True) + # Transcribe with word-level timestamps. verbose=True streams each decoded + # segment ("[start --> end] text") to stdout, which the Rust side parses + # into progress updates. + result = model.transcribe(audio_path, word_timestamps=True, verbose=True) normalized = { "language": result.get("language", "en"), @@ -361,19 +730,33 @@ if __name__ == "__main__": let script_path_str = script_path.to_string_lossy().to_string(); let output_json_path_str = output_json_path.to_string_lossy().to_string(); + // Prefer the checkpoint already downloaded by the app so transcription stays offline. + let python_model = if whisper_model_downloaded(&model_size) { + whisper_model_path(&model_size) + .to_string_lossy() + .to_string() + } else { + model_size.clone() + }; + tokio::task::spawn_blocking(move || { + let mut on_progress = on_progress; let python_bin = find_python_command().unwrap_or_else(|| "python".to_string()); - let output = std::process::Command::new(&python_bin) - .arg(&script_path_str) + // Probed once per process inside this blocking task: importing + // torch takes a moment, so it must not run on the async runtime. + let device = crate::hardware::whisper_device_for(Some(&python_bin)); + let mut cmd = std::process::Command::new(&python_bin); + cmd.arg(&script_path_str) .arg(&audio_path) .arg(&output_json_path_str) - .arg("base") // default model size - .output() - .context("executing python transcribe.py")?; + .arg(&python_model) + .arg(device); + // Keep stdout unbuffered so whisper's segment prints stream live. + cmd.env("PYTHONUNBUFFERED", "1"); + + let (status, stdout, stderr) = run_streaming(cmd, total_duration, &mut on_progress)?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + if !status.success() { return Err(anyhow!("Python transcription script failed:\nStderr: {}\nStdout: {}", stderr, stdout)); } diff --git a/src/styles.css b/src/styles.css index a856eef..70f8762 100644 --- a/src/styles.css +++ b/src/styles.css @@ -804,13 +804,69 @@ button:disabled { background: var(--accent-primary); } -.status-indicators { +/* Global pipeline progress bar (all long-running backend stages) */ +.pipeline-progress { display: flex; align-items: center; - gap: 14px; + gap: 10px; + min-width: 0; + color: var(--text-secondary); } -.status-indicators .indicator { +.pipeline-progress-label { + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; + max-width: 46vw; + overflow: hidden; + text-overflow: ellipsis; +} + +.pipeline-progress-value { + font-variant-numeric: tabular-nums; + color: var(--accent-primary); + font-weight: 600; +} + +.pipeline-progress-track { + position: relative; + width: 220px; + height: 4px; + border-radius: 999px; + background: var(--border-color); + overflow: hidden; + flex-shrink: 0; +} + +.pipeline-progress-fill { + height: 100%; + width: 0; + border-radius: 999px; + background: var(--accent-primary); + transition: width 0.2s ease; +} + +.pipeline-progress-track.indeterminate .pipeline-progress-fill { + width: 35%; + transition: none; + animation: pipeline-progress-slide 1.1s ease-in-out infinite; +} + +@keyframes pipeline-progress-slide { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(300%); + } +} + +.status-indicators { + display: flex; + align-items: center; + gap: 14px; +}.status-indicators .indicator { display: flex; align-items: center; gap: 5px; @@ -1714,3 +1770,156 @@ button:disabled { font-weight: 700; color: var(--accent-primary); } + +/* Local Whisper model picker */ +.settings-whisper { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border-color); +} + +.whisper-models { + display: flex; + flex-direction: column; + gap: 10px; +} + +.whisper-models-header { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 12.5px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.whisper-refresh { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + font-size: 11.5px; + text-transform: none; + letter-spacing: normal; + color: var(--text-secondary); + background: transparent; + border: 1px solid var(--border-color); + border-radius: 8px; + cursor: pointer; +} + +.whisper-refresh:hover { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.2); +} + +.whisper-model-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.whisper-model-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--border-color); + border-radius: 10px; +} + +.whisper-model-row.active { + border-color: var(--accent-primary); + background: rgba(81, 207, 179, 0.08); +} + +.whisper-model-label { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + flex: 1; + min-width: 0; +} + +.whisper-model-label input { + accent-color: var(--accent-primary); + cursor: pointer; +} + +.whisper-model-name { + font-size: 13.5px; + font-weight: 600; + color: var(--foreground); +} + +.whisper-model-size { + font-size: 11.5px; + color: var(--text-muted); +} + +.whisper-model-action { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.whisper-model-status { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 12px; + color: var(--text-secondary); +} + +.whisper-model-status.downloaded { + color: var(--accent-primary); +} + +.whisper-download-btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 10px; + font-size: 12px; + font-weight: 600; + color: var(--foreground); + background: transparent; + border: 1px solid var(--border-color); + border-radius: 8px; + cursor: pointer; +} + +.whisper-download-btn:hover:not(:disabled) { + background: rgba(81, 207, 179, 0.1); + border-color: var(--accent-primary); +} + +.whisper-download-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.whisper-empty, +.whisper-hint { + margin: 0; + font-size: 12px; + color: var(--text-muted); + line-height: 1.5; +} + +.whisper-hint { + margin-top: 10px; + color: #f6c177; +} + +.whisper-download-progress { + display: flex; + flex-direction: column; + gap: 6px; +} From f0d7b7ce058e83dbb04c8e36fe735ed6dbe0d516 Mon Sep 17 00:00:00 2001 From: Leonardo Bezerra Date: Mon, 26 Oct 2026 19:35:43 -0300 Subject: [PATCH 02/25] chore(test): add Vitest, Testing Library and jsdom Sets up the frontend test harness: - devDependencies: vitest, jsdom, @testing-library/react, @testing-library/jest-dom. - vite.config.ts: `test` block (jsdom environment, globals, setup file, `src/**/*.{test,spec}.{ts,tsx}` as the test glob, CSS disabled). - src/test/setup.ts: loads jest-dom matchers and unmounts React trees after every test so suites cannot leak DOM between cases. - npm scripts: `test` (single run), `test:watch`, and `test:rust` (forwards to cargo test in src-tauri). --- package-lock.json | 1050 ++++++++++++++++++++++++++++++++++++++++++++- package.json | 11 +- src/test/setup.ts | 7 + vite.config.ts | 8 + 4 files changed, 1065 insertions(+), 11 deletions(-) create mode 100644 src/test/setup.ts diff --git a/package-lock.json b/package-lock.json index 69cc5aa..2aee930 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,11 +16,75 @@ }, "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" + } + }, + "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 +321,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 +379,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 +974,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 +1025,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 +1635,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 +1773,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 +1839,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 +1935,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 +2000,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 +2017,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,8 +2045,22 @@ "dev": true, "license": "MIT" }, - "node_modules/debug": { - "version": "4.4.3", + "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", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, @@ -1583,6 +2077,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 +2109,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 +2181,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 +2244,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 +2281,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 +2376,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 +2450,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 +2485,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 +2526,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 +2573,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 +2591,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 +2660,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 +2699,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 +2756,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 +2816,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 +2932,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 751cca1..e97f282 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,10 @@ "build:linux": "node scripts/build-all.mjs --only=linux", "build:windows": "node scripts/build-all.mjs --only=windows", "build:android": "node scripts/build-all.mjs --only=android", - "build:ios": "node scripts/build-all.mjs --only=ios" + "build:ios": "node scripts/build-all.mjs --only=ios", + "test": "vitest run", + "test:watch": "vitest", + "test:rust": "cargo test --manifest-path src-tauri/Cargo.toml" }, "dependencies": { "@tauri-apps/api": "^2.5.0", @@ -24,10 +27,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/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..e262193 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,7 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +afterEach(() => { + cleanup(); +}); diff --git a/vite.config.ts b/vite.config.ts index 5fe1563..48e28aa 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,3 +1,4 @@ +/// import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; @@ -12,4 +13,11 @@ export default defineConfig({ ignored: ["**/src-tauri/**"], }, }, + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./src/test/setup.ts"], + include: ["src/**/*.{test,spec}.{ts,tsx}"], + css: false, + }, }); From f4c80a0c0ada904aa9cc2c9ef1271350730ebe33 Mon Sep 17 00:00:00 2001 From: Leonardo Bezerra Date: Mon, 26 Oct 2026 19:36:06 -0300 Subject: [PATCH 03/25] refactor(frontend): split App and shared UI helpers into modules main.tsx was a single 2.2k-line file that mounted the root, so nothing in it could be imported by a test. Split it into importable pieces: - src/App.tsx: the whole component tree, exported as the default `App`. - src/main.tsx: only creates the React root and renders ``. - src/lib/format.ts: pure helpers `formatModelSize`, `fileName`, `formatTime`, plus new `formatTimeRange` and `formatPercent`. `formatTime` now clamps negative/NaN/Infinity input to 0 instead of rendering `NaN:NaN`. - src/components/common.tsx: `StatusPill`, `PipelineStep` and `EmptyState`. No behavior change for the running app; the duplicated local copies in main.tsx were removed in favor of the imports. --- src/App.tsx | 2243 +++++++++++++++++++++++++++++++++++++ src/components/common.tsx | 40 + src/lib/format.ts | 51 + src/main.tsx | 1763 +---------------------------- 4 files changed, 2336 insertions(+), 1761 deletions(-) create mode 100644 src/App.tsx create mode 100644 src/components/common.tsx create mode 100644 src/lib/format.ts diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..9012b31 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,2243 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { open } from "@tauri-apps/plugin-dialog"; +import { listen } from "@tauri-apps/api/event"; +import { + AudioLines, + BadgeCheck, + Captions, + Check, + ChevronRight, + Clapperboard, + Download, + FileVideo, + Loader2, + Play, + RefreshCw, + Scissors, + SlidersHorizontal, + Sparkles, + Wand2, + Copy, + Database, + Cloud, + Youtube, + AlertTriangle, +} from "lucide-react"; +import { EmptyState, PipelineStep } from "./components/common"; +import { fileName, formatEncoderLabel, formatModelSize, formatTime } from "./lib/format"; +import "./styles.css"; + +type EnvironmentStatus = { + dataDir: string; + hasFfmpeg: boolean; + hasFfprobe: boolean; + hasDeepgramKey: boolean; + hasAnthropicKey: boolean; + hasDeepseekKey: boolean; + hasGeminiKey: boolean; + hasOpenaiKey: boolean; + hasOpenrouterKey: boolean; + hasGroqKey: boolean; + hasOpenaiCompatibleKey: boolean; + openaiBaseUrl: string | null; + llmProvider: string; + hasLocalWhisperModel: boolean; + hasOllama: boolean; + hasYtdlp: boolean; + gpuAccel: boolean; + videoEncoder: string; +}; + +type Project = { + id: string; + name: string | null; + sourcePath: string; + sourceDuration: number | null; + status: string; + transcriptionMode: string; + captionStyle?: string | null; + createdAt: string; + updatedAt: string; +}; + +type Transcript = { + id: string; + projectId: string; + engine: string; + rawJson: string; + language: string | null; + createdAt: string; +}; + +type Candidate = { + id: string; + projectId: string; + startSec: number; + endSec: number; + score: number; + hook: string; + rationale: string; + rank: number; + selected: boolean; +}; + +type Clip = { + id: string; + candidateId: string; + status: string; + outputPath: string | null; + faceTrackJson: string | null; + captionAssPath: string | null; + renderLog: string | null; +}; + +type ProjectDetail = { + project: Project; + transcript: Transcript | null; + candidates: Candidate[]; + clips: Clip[]; +}; + +type NormalizedTranscript = { + language: string; + duration: number; + speakers: string[]; + segments: Array<{ + start: number; + end: number; + speaker: string | null; + text: string; + }>; +}; + +type BusyState = + | "idle" + | "import" + | "transcribe" + | "demoTranscript" + | "moments" + | "clipCount" + | "cut"; + +type WhisperModelInfo = { + id: string; + label: string; + sizeMb: number; + downloaded: boolean; + path: string | null; +}; + +type WhisperDownloadState = { + model: string; + status: string; + percentage: number; +}; + +type PipelineProgressState = { + stage: string; + label: string; + percentage: number | null; +}; + +function App() { + const [environment, setEnvironment] = useState(null); + const [projects, setProjects] = useState([]); + const [detail, setDetail] = useState(null); + const [busy, setBusy] = useState("idle"); + const [error, setError] = useState(null); + const [showSettings, setShowSettings] = useState(false); + const [renderingCandidateId, setRenderingCandidateId] = useState(null); + const [showStyleModal, setShowStyleModal] = useState(false); + const [selectedStyle, setSelectedStyle] = useState("modern-box"); + const [mediaPathToImport, setMediaPathToImport] = useState(null); + + const [youtubeModalOpen, setYoutubeModalOpen] = useState(false); + const [youtubeUrl, setYoutubeUrl] = useState(""); + const [youtubeStatus, setYoutubeStatus] = useState<"idle" | "checking" | "warning" | "downloading">("idle"); + const [youtubeWarningLicense, setYoutubeWarningLicense] = useState(null); + + // Persistence logic from localStorage + const [isOnboarded, setIsOnboarded] = useState(null); + const [transcriptionEngine, setTranscriptionEngine] = useState<"deepgram" | "local">(() => { + return (localStorage.getItem("autoshorts_transcription_engine") as "deepgram" | "local") || "local"; + }); + type LlmEngine = "claude" | "deepseek" | "local" | "gemini" | "openai" | "openai-compatible" | "openrouter" | "groq"; + const [llmEngine, setLlmEngine] = useState(() => { + return (localStorage.getItem("autoshorts_llm_engine") as LlmEngine) || "local"; + }); + const [localLlmModel, setLocalLlmModel] = useState(() => { + return localStorage.getItem("autoshorts_local_llm_model") || "llama3.2"; + }); + const [deepgramKey, setDeepgramKey] = useState(() => { + return localStorage.getItem("autoshorts_deepgram_key") || ""; + }); + const [anthropicKey, setAnthropicKey] = useState(() => { + return localStorage.getItem("autoshorts_anthropic_key") || ""; + }); + const [deepseekKey, setDeepseekKey] = useState(() => { + return localStorage.getItem("autoshorts_deepseek_key") || ""; + }); + const [deepseekModel, setDeepseekModel] = useState(() => { + return localStorage.getItem("autoshorts_deepseek_model") || ""; + }); + const [geminiKey, setGeminiKey] = useState(() => { + return localStorage.getItem("autoshorts_gemini_key") || ""; + }); + const [openaiKey, setOpenaiKey] = useState(() => { + return localStorage.getItem("autoshorts_openai_key") || ""; + }); + const [openrouterKey, setOpenrouterKey] = useState(() => { + return localStorage.getItem("autoshorts_openrouter_key") || ""; + }); + const [groqKey, setGroqKey] = useState(() => { + return localStorage.getItem("autoshorts_groq_key") || ""; + }); + const [openrouterModel, setOpenrouterModel] = useState(() => { + return localStorage.getItem("autoshorts_openrouter_model") || ""; + }); + const [openaiCompatibleKey, setOpenaiCompatibleKey] = useState(() => { + return localStorage.getItem("autoshorts_openai_compatible_key") || ""; + }); + const [openaiCompatibleBaseUrl, setOpenaiCompatibleBaseUrl] = useState(() => { + return localStorage.getItem("autoshorts_openai_compatible_base_url") || ""; + }); + const [openaiCompatibleModel, setOpenaiCompatibleModel] = useState(() => { + return localStorage.getItem("autoshorts_openai_compatible_model") || ""; + }); + + const [downloadingModelName, setDownloadingModelName] = useState(null); + const [modelDownloadStatus, setModelDownloadStatus] = useState(""); + const [modelDownloadProgress, setModelDownloadProgress] = useState(0); + + const [whisperModel, setWhisperModel] = useState(() => { + return localStorage.getItem("autoshorts_whisper_model") || "base"; + }); + const [whisperModels, setWhisperModels] = useState([]); + const [whisperDownload, setWhisperDownload] = useState(null); + const [pipelineProgress, setPipelineProgress] = useState(null); + const pipelineHideTimer = useRef(null); + + // Single global progress bar fed by the backend `pipeline-progress` event, + // emitted by every long-running command (import, extract, transcribe, + // moments, cut, youtube). + useEffect(() => { + let unlisten: (() => void) | undefined; + let disposed = false; + + void listen("pipeline-progress", (event) => { + const payload = event.payload; + if (pipelineHideTimer.current !== null) { + window.clearTimeout(pipelineHideTimer.current); + pipelineHideTimer.current = null; + } + setPipelineProgress(payload); + if (payload.percentage !== null && payload.percentage >= 100) { + pipelineHideTimer.current = window.setTimeout(() => { + setPipelineProgress(null); + pipelineHideTimer.current = null; + }, 1200); + } + }).then((fn) => { + if (disposed) { + fn(); + return; + } + unlisten = fn; + }); + + return () => { + disposed = true; + unlisten?.(); + if (pipelineHideTimer.current !== null) { + window.clearTimeout(pipelineHideTimer.current); + } + }; + }, []); + + // The bar is only cleared by a 100% event, so a command that failed before + // finishing (missing output folder, ffmpeg error, panic) used to leave a + // frozen "Rendering clip #1… 0%". Clear it whenever the command settles. + function clearPipelineProgress(stage?: string) { + if (pipelineHideTimer.current !== null) { + window.clearTimeout(pipelineHideTimer.current); + pipelineHideTimer.current = null; + } + setPipelineProgress((current) => { + if (!current) return current; + if (stage && current.stage !== stage) return current; + return null; + }); + } + + const transcript = useMemo(() => { + if (!detail?.transcript) return null; + try { + return JSON.parse(detail.transcript.rawJson) as NormalizedTranscript; + } catch { + return null; + } + }, [detail?.transcript]); + + const selectedCount = detail?.candidates.filter((candidate) => candidate.selected).length ?? 0; + const clipByCandidate = useMemo(() => { + return new Map(detail?.clips.map((clip) => [clip.candidateId, clip]) ?? []); + }, [detail?.clips]); + const selectedCandidates = detail?.candidates.filter((candidate) => candidate.selected) ?? []; + const selectedCutCount = selectedCandidates.filter((candidate) => { + const clip = clipByCandidate.get(candidate.id); + return clip?.status === "done" && Boolean(clip.outputPath); + }).length; + const selectedCaptionsCount = selectedCandidates.filter((candidate) => { + const clip = clipByCandidate.get(candidate.id); + return clip?.status === "done" && Boolean(clip.captionAssPath); + }).length; + const canUseCloudKey = environment?.hasDeepgramKey || deepgramKey.trim().length > 0; + const canUseClaude = environment?.hasAnthropicKey || anthropicKey.trim().length > 0; + const canUseDeepseek = environment?.hasDeepseekKey || deepseekKey.trim().length > 0; + const canUseGemini = environment?.hasGeminiKey || geminiKey.trim().length > 0; + const canUseOpenai = environment?.hasOpenaiKey || openaiKey.trim().length > 0; + const canUseOpenrouter = environment?.hasOpenrouterKey || openrouterKey.trim().length > 0; + const canUseGroq = environment?.hasGroqKey || groqKey.trim().length > 0; + // Custom endpoints only need a base URL; the API key is optional for local servers. + const canUseOpenaiCompatible = openaiCompatibleBaseUrl.trim().length > 0 || Boolean(environment?.openaiBaseUrl); + + const canTranscribe = transcriptionEngine === "local" + ? Boolean(environment?.hasLocalWhisperModel) + : canUseCloudKey; + + const canUseActiveLlm = llmEngine === "local" + ? Boolean(environment?.hasOllama) + : llmEngine === "claude" + ? canUseClaude + : llmEngine === "deepseek" + ? canUseDeepseek + : llmEngine === "gemini" + ? canUseGemini + : llmEngine === "openai" + ? canUseOpenai + : llmEngine === "openrouter" + ? canUseOpenrouter + : llmEngine === "openai-compatible" + ? canUseOpenaiCompatible + : llmEngine === "groq" + ? canUseGroq + : false; + + useEffect(() => { + void refresh(); + const value = localStorage.getItem("autoshorts_onboarded"); + if (value === "true") { + setIsOnboarded(true); + } else { + setIsOnboarded(false); + } + }, []); + + useEffect(() => { + localStorage.setItem("autoshorts_transcription_engine", transcriptionEngine); + }, [transcriptionEngine]); + + useEffect(() => { + localStorage.setItem("autoshorts_llm_engine", llmEngine); + }, [llmEngine]); + + useEffect(() => { + localStorage.setItem("autoshorts_local_llm_model", localLlmModel); + }, [localLlmModel]); + + useEffect(() => { + localStorage.setItem("autoshorts_deepgram_key", deepgramKey); + }, [deepgramKey]); + + useEffect(() => { + localStorage.setItem("autoshorts_anthropic_key", anthropicKey); + }, [anthropicKey]); + + useEffect(() => { + localStorage.setItem("autoshorts_deepseek_key", deepseekKey); + }, [deepseekKey]); + + useEffect(() => { + localStorage.setItem("autoshorts_deepseek_model", deepseekModel); + }, [deepseekModel]); + + useEffect(() => { + localStorage.setItem("autoshorts_gemini_key", geminiKey); + }, [geminiKey]); + + useEffect(() => { + localStorage.setItem("autoshorts_openai_key", openaiKey); + }, [openaiKey]); + + useEffect(() => { + localStorage.setItem("autoshorts_openrouter_key", openrouterKey); + }, [openrouterKey]); + + useEffect(() => { + localStorage.setItem("autoshorts_groq_key", groqKey); + }, [groqKey]); + + useEffect(() => { + localStorage.setItem("autoshorts_openrouter_model", openrouterModel); + }, [openrouterModel]); + + useEffect(() => { + localStorage.setItem("autoshorts_openai_compatible_key", openaiCompatibleKey); + }, [openaiCompatibleKey]); + + useEffect(() => { + localStorage.setItem("autoshorts_openai_compatible_base_url", openaiCompatibleBaseUrl); + }, [openaiCompatibleBaseUrl]); + + useEffect(() => { + localStorage.setItem("autoshorts_openai_compatible_model", openaiCompatibleModel); + }, [openaiCompatibleModel]); + + useEffect(() => { + localStorage.setItem("autoshorts_whisper_model", whisperModel); + }, [whisperModel]); + + useEffect(() => { + void loadWhisperModels(); + }, []); + + const pullModelDirectly = async (modelName: string) => { + setDownloadingModelName(modelName); + setModelDownloadProgress(0); + setModelDownloadStatus("Connecting to Ollama..."); + try { + const unlisten = await listen<{ + status: string; + completed?: number; + total?: number; + percentage?: number; + }>("ollama-pull-progress", (event) => { + const payload = event.payload; + setModelDownloadStatus(payload.status); + if (payload.percentage !== undefined && payload.percentage !== null) { + setModelDownloadProgress(Math.round(payload.percentage)); + } + }); + + await invoke("pull_ollama_model", { modelName }); + unlisten(); + setModelDownloadStatus("Download complete!"); + setModelDownloadProgress(100); + setTimeout(() => setDownloadingModelName(null), 500); + } catch (err) { + alert("Failed to download model: " + String(err)); + setDownloadingModelName(null); + } + }; + + async function loadWhisperModels() { + try { + const models = await invoke("list_whisper_models"); + setWhisperModels(models); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + + async function downloadWhisperModel(modelId: string) { + setError(null); + setWhisperDownload({ model: modelId, status: "Preparing download...", percentage: 0 }); + let unlisten: (() => void) | undefined; + try { + unlisten = await listen<{ status: string; percentage?: number | null }>( + "whisper-model-progress", + (event) => { + const payload = event.payload; + setWhisperDownload((previous) => ({ + model: modelId, + status: payload.status, + percentage: + payload.percentage !== undefined && payload.percentage !== null + ? Math.round(payload.percentage) + : previous?.percentage ?? 0, + })); + }, + ); + + await invoke("download_whisper_model", { modelName: modelId }); + setWhisperModel(modelId); + await loadWhisperModels(); + setWhisperDownload({ model: modelId, status: "Download complete!", percentage: 100 }); + setTimeout(() => setWhisperDownload(null), 1200); + } catch (err) { + setWhisperDownload(null); + alert("Failed to download Whisper model: " + String(err)); + } finally { + unlisten?.(); + } + } + + async function refresh(nextProjectId?: string) { + setError(null); + const [env, projectList] = await Promise.all([ + invoke("environment_status"), + invoke("list_projects"), + ]); + setEnvironment(env); + setProjects(projectList); + + if (nextProjectId) { + const nextDetail = await invoke("get_project_detail", { projectId: nextProjectId }); + setDetail(nextDetail); + } else { + setDetail(null); + } + } + + async function run(action: BusyState, task: () => Promise) { + setBusy(action); + setError(null); + try { + await task(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy("idle"); + clearPipelineProgress(); + } + } + + async function importMedia() { + const selected = await open({ + multiple: false, + filters: [ + { + name: "Media", + extensions: ["mp4", "mov", "mp3", "wav", "m4a"], + }, + ], + }); + if (typeof selected !== "string") return; + setMediaPathToImport(selected); + setShowStyleModal(true); + } + + async function handleYoutubeImport() { + if (!youtubeUrl) return; + setYoutubeStatus("checking"); + setError(null); + try { + const result = await invoke<{isSafe: boolean; license: string | null}>("check_youtube_copyright", { url: youtubeUrl }); + if (!result.isSafe) { + setYoutubeWarningLicense(result.license || "Unknown / Not specified"); + setYoutubeStatus("warning"); + return; + } + await executeYoutubeDownload(); + } catch (err: any) { + setError(err.toString()); + setYoutubeStatus("idle"); + } + } + + async function executeYoutubeDownload() { + setYoutubeStatus("downloading"); + setError(null); + try { + const downloadedPath = await invoke("download_youtube_video", { url: youtubeUrl }); + setYoutubeModalOpen(false); + setYoutubeUrl(""); + setYoutubeStatus("idle"); + setMediaPathToImport(downloadedPath); + setShowStyleModal(true); + } catch (err: any) { + setError(err.toString()); + setYoutubeStatus("idle"); + } + } + + async function confirmImport(style: string) { + if (!mediaPathToImport) return; + const selected = mediaPathToImport; + setMediaPathToImport(null); + setShowStyleModal(false); + + let newProjectId: string | null = null; + await run("import", async () => { + const project = await invoke("create_project_from_path", { + path: selected, + transcriptionMode: transcriptionEngine === "local" ? "local" : "cloud", + captionStyle: style, + }); + newProjectId = project.id; + await refresh(project.id); + }); + + if (newProjectId) { + await runAutoPipeline(newProjectId); + } + } + + async function runAutoPipeline(projectId: string) { + setError(null); + const env = await invoke("environment_status"); + + if (transcriptionEngine === "local") { + if (!env.hasLocalWhisperModel) { + setError("Import successful. Local Whisper is not installed. Run 'pip3 install -U openai-whisper' (or install whisper-cli) to start transcription."); + return; + } + } else { + const hasDG = env.hasDeepgramKey || deepgramKey.trim().length > 0; + if (!hasDG) { + setError("Import successful. Deepgram key is missing. Please add it to start transcription."); + return; + } + } + + if (llmEngine === "local") { + if (!env.hasOllama) { + setError("Import successful. Local Ollama server is not running at http://localhost:11434. Please start it to find viral moments."); + return; + } + } else { + const activeKey = + llmEngine === "claude" ? anthropicKey : + llmEngine === "deepseek" ? deepseekKey : + llmEngine === "gemini" ? geminiKey : + llmEngine === "openai" ? openaiKey : + llmEngine === "openai-compatible" ? openaiCompatibleKey : + llmEngine === "openrouter" ? openrouterKey : + llmEngine === "groq" ? groqKey : ""; + const hasActiveKey = + llmEngine === "openai-compatible" ? (Boolean(env.openaiBaseUrl) || openaiCompatibleBaseUrl.trim().length > 0) : + llmEngine === "claude" ? (env.hasAnthropicKey || activeKey.trim().length > 0) : + llmEngine === "deepseek" ? (env.hasDeepseekKey || activeKey.trim().length > 0) : + llmEngine === "gemini" ? (env.hasGeminiKey || activeKey.trim().length > 0) : + llmEngine === "openai" ? (env.hasOpenaiKey || activeKey.trim().length > 0) : + llmEngine === "openrouter" ? (env.hasOpenrouterKey || activeKey.trim().length > 0) : + llmEngine === "groq" ? (env.hasGroqKey || activeKey.trim().length > 0) : false; + if (!hasActiveKey) { + const engineName = + llmEngine === "claude" ? "Claude" : + llmEngine === "deepseek" ? "DeepSeek" : + llmEngine === "gemini" ? "Gemini" : + llmEngine === "openai" ? "OpenAI" : + llmEngine === "openai-compatible" ? "OpenAI-compatible" : + llmEngine === "openrouter" ? "OpenRouter" : + llmEngine === "groq" ? "Groq" : "LLM"; + setError(`Transcription complete. ${engineName} configuration is missing. Please add it in settings to analyze viral moments.`); + return; + } + } + + // 1. Transcription + try { + setBusy("transcribe"); + await invoke("transcribe_project", { + projectId, + provider: transcriptionEngine, + apiKey: transcriptionEngine === "deepgram" ? (deepgramKey.trim() || null) : null, + whisperModel: transcriptionEngine === "local" ? whisperModel : null, + }); + await refresh(projectId); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setBusy("idle"); + return; + } + + // 2. LLM Moments + try { + setBusy("moments"); + const activeKey = + llmEngine === "claude" ? anthropicKey.trim() : + llmEngine === "deepseek" ? deepseekKey.trim() : + llmEngine === "gemini" ? geminiKey.trim() : + llmEngine === "openai" ? openaiKey.trim() : + llmEngine === "openai-compatible" ? openaiCompatibleKey.trim() : + llmEngine === "openrouter" ? openrouterKey.trim() : + llmEngine === "groq" ? groqKey.trim() : ""; + await invoke("generate_candidates", { + projectId, + apiKey: activeKey || null, + provider: llmEngine, + modelName: llmEngine === "local" + ? localLlmModel.trim() + : llmEngine === "deepseek" + ? (deepseekModel.trim() || null) + : llmEngine === "openrouter" + ? (openrouterModel.trim() || null) + : llmEngine === "openai-compatible" + ? (openaiCompatibleModel.trim() || null) + : null, + baseUrl: llmEngine === "openai-compatible" ? (openaiCompatibleBaseUrl.trim() || null) : null, + allowDemo: false, + }); + await refresh(projectId); + } catch (err) { + const errMsg = String(err); + if (llmEngine === "local" && (errMsg.includes("not found") || errMsg.includes("404"))) { + if (window.confirm(`Ollama model "${localLlmModel}" is not downloaded. Would you like to download it now?`)) { + setTimeout(() => { + void pullModelDirectly(localLlmModel).then(() => { + void refresh(projectId); + }); + }, 100); + return; + } + } + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy("idle"); + } + } + + async function renameProject(projectId: string) { + const project = projects.find((p) => p.id === projectId); + if (!project) return; + const currentName = project.name || fileName(project.sourcePath); + const newName = window.prompt("Rename Project:", currentName); + if (newName === null) return; + const trimmed = newName.trim(); + if (!trimmed) return; + + try { + await invoke("rename_project", { projectId, name: trimmed }); + await refresh(detail?.project.id); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + + async function deleteProject(projectId: string) { + const project = projects.find((p) => p.id === projectId); + if (!project) return; + const name = project.name || fileName(project.sourcePath); + if (!window.confirm(`Are you sure you want to delete the project "${name}"?`)) return; + + try { + await invoke("delete_project", { projectId }); + const nextActiveId = detail?.project.id === projectId ? null : detail?.project.id; + await refresh(nextActiveId ?? undefined); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + + async function selectProject(projectId: string) { + await run("idle", async () => { + const nextDetail = await invoke("get_project_detail", { projectId }); + setDetail(nextDetail); + }); + } + + async function transcribe() { + if (!detail) return; + await run("transcribe", async () => { + await invoke("transcribe_project", { + projectId: detail.project.id, + provider: transcriptionEngine, + apiKey: transcriptionEngine === "deepgram" ? (deepgramKey.trim() || null) : null, + whisperModel: transcriptionEngine === "local" ? whisperModel : null, + }); + await refresh(detail.project.id); + }); + } + + async function moments(allowDemo: boolean) { + if (!detail) return; + await run("moments", async () => { + const activeKey = + llmEngine === "claude" ? anthropicKey.trim() : + llmEngine === "deepseek" ? deepseekKey.trim() : + llmEngine === "gemini" ? geminiKey.trim() : + llmEngine === "openai" ? openaiKey.trim() : + llmEngine === "openai-compatible" ? openaiCompatibleKey.trim() : + llmEngine === "openrouter" ? openrouterKey.trim() : + llmEngine === "groq" ? groqKey.trim() : ""; + try { + await invoke("generate_candidates", { + projectId: detail.project.id, + apiKey: activeKey || null, + provider: llmEngine, + modelName: llmEngine === "local" + ? localLlmModel.trim() + : llmEngine === "deepseek" + ? (deepseekModel.trim() || null) + : llmEngine === "openrouter" + ? (openrouterModel.trim() || null) + : llmEngine === "openai-compatible" + ? (openaiCompatibleModel.trim() || null) + : null, + baseUrl: llmEngine === "openai-compatible" ? (openaiCompatibleBaseUrl.trim() || null) : null, + allowDemo, + }); + await refresh(detail.project.id); + } catch (err) { + const errMsg = String(err); + if (llmEngine === "local" && (errMsg.includes("not found") || errMsg.includes("404"))) { + if (window.confirm(`Ollama model "${localLlmModel}" is not downloaded. Would you like to download it now?`)) { + setTimeout(() => { + void pullModelDirectly(localLlmModel).then(() => { + void refresh(detail.project.id); + }); + }, 100); + return; + } + } + throw err; + } + }); + } + + async function updateClipCount(count: number) { + if (!detail) return; + await run("clipCount", async () => { + const candidates = await invoke("set_selected_clip_count", { + projectId: detail.project.id, + count, + }); + setDetail({ ...detail, candidates }); + }); + } + + async function cutCandidate(candidateId: string) { + if (!detail) return; + setRenderingCandidateId(candidateId); + setBusy("cut"); + setError(null); + try { + await invoke("render_flat_clip_for_candidate", { candidateId }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setRenderingCandidateId(null); + setBusy("idle"); + clearPipelineProgress("cut"); + await refresh(detail.project.id); + } + } + + async function cutSelected() { + if (!detail) return; + setBusy("cut"); + setError(null); + const queue = [...selectedCandidates]; + const failures: string[] = []; + // Two concurrent renders: enough to hide ffmpeg startup/seek overhead + // without starving the CPU-bound x264 encoder. + const worker = async () => { + while (queue.length > 0) { + const candidate = queue.shift(); + if (!candidate) break; + setRenderingCandidateId(candidate.id); + try { + await invoke("render_flat_clip_for_candidate", { candidateId: candidate.id }); + } catch (err) { + failures.push(err instanceof Error ? err.message : String(err)); + } + } + }; + try { + const workers = Math.min(2, queue.length); + await Promise.all(Array.from({ length: workers }, () => worker())); + if (failures.length > 0) { + setError(failures.join("\n")); + } + } finally { + setRenderingCandidateId(null); + setBusy("idle"); + clearPipelineProgress("cut"); + await refresh(detail.project.id); + } + } + + if (isOnboarded === null) { + return ( +
+ +
+ ); + } + + if (isOnboarded === false) { + return ( + setIsOnboarded(true)} + setTranscriptionEngine={setTranscriptionEngine} + setLlmEngine={setLlmEngine} + setLocalLlmModel={setLocalLlmModel} + setDeepgramKey={setDeepgramKey} + setAnthropicKey={setAnthropicKey} + setDeepseekKey={setDeepseekKey} + setGroqKey={setGroqKey} + setOpenaiCompatibleKey={setOpenaiCompatibleKey} + setOpenaiCompatibleBaseUrl={setOpenaiCompatibleBaseUrl} + setOpenaiCompatibleModel={setOpenaiCompatibleModel} + deepgramKey={deepgramKey} + anthropicKey={anthropicKey} + deepseekKey={deepseekKey} + groqKey={groqKey} + openaiCompatibleKey={openaiCompatibleKey} + openaiCompatibleBaseUrl={openaiCompatibleBaseUrl} + openaiCompatibleModel={openaiCompatibleModel} + whisperModels={whisperModels} + whisperModel={whisperModel} + setWhisperModel={setWhisperModel} + downloadWhisperModel={downloadWhisperModel} + loadWhisperModels={loadWhisperModels} + whisperDownload={whisperDownload} + refreshEnv={() => refresh()} + /> + ); + } + + return ( +
+
+ + +
+ {detail ? ( + <> +
+
+
{detail.project.status}
+

{detail.project.name || fileName(detail.project.sourcePath)}

+
+
+ + +
+
+ + {showSettings && ( +
+
+ + {transcriptionEngine === "deepgram" && ( + + )} + {llmEngine === "claude" && ( + + )} + {llmEngine === "deepseek" && ( + <> + + + + )} + {llmEngine === "gemini" && ( + + )} + {llmEngine === "openai" && ( + + )} + {llmEngine === "openrouter" && ( + <> + + + + )} + {llmEngine === "groq" && ( + + )} + {llmEngine === "openai-compatible" && ( + <> + + + + + )} + + {llmEngine === "local" && ( + + )} +
+ {transcriptionEngine === "local" && ( +
+ + {!environment?.hasLocalWhisperModel && ( +

+ ⚠️ Python package 'openai-whisper' not detected. Run 'pip3 install -U openai-whisper' + before downloading models. +

+ )} +
+ )} +
+ +
+
+ )} + + {error &&
{error}
} + +
+ } label="Transcript" done={Boolean(detail.transcript)} /> + } label="Moments" done={detail.candidates.length > 0} /> + } label="Cut" done={selectedCount > 0 && selectedCutCount === selectedCount} /> + } label="Captions" done={selectedCount > 0 && selectedCaptionsCount === selectedCount} /> + } label="Export" done={selectedCount > 0 && selectedCutCount === selectedCount} /> +
+ +
+
+
+
+

Transcript

+

{transcript ? `${transcript.segments.length} segments` : "No transcript"}

+
+
+ +
+
+ + {!canTranscribe && ( +
+ {transcriptionEngine === "local" + ? `⚠️ Local Whisper (Python package 'openai-whisper') is not installed. Run 'pip3 install openai-whisper' in your terminal.` + : "⚠️ Deepgram API Key is missing. Transcribing will not work. Please add your key in API Settings."} +
+ )} + +
+ {transcript?.segments.map((segment, index) => ( +
+ {formatTime(segment.start)} +

{segment.text}

+
+ )) ?? } label="Transcript pending" />} +
+
+ +
+
+
+

Clip Candidates

+

{detail.candidates.length ? `${selectedCount} selected` : "No candidates"}

+
+
+ + +
+
+ + {!canUseActiveLlm && ( +
+ {llmEngine === "local" + ? "⚠️ Ollama local server is not running at http://localhost:11434. Moment detection will not work." + : llmEngine === "openai-compatible" + ? "⚠️ Base URL is missing. Viral moment identification will not work. Please add the endpoint URL in API Settings." + : `⚠️ ${llmEngine === "claude" ? "Claude" : + llmEngine === "deepseek" ? "DeepSeek" : + llmEngine === "gemini" ? "Gemini" : + llmEngine === "openai" ? "OpenAI" : + llmEngine === "openrouter" ? "OpenRouter" : + llmEngine === "groq" ? "Groq" : "LLM" + } API Key is missing. Viral moment identification will not work. Please add your key in API Settings.`} +
+ )} + + {detail.candidates.length > 0 && ( +
+ + void updateClipCount(Number(event.target.value))} + /> + {selectedCount} +
+ )} + +
+ {detail.candidates.map((candidate) => { + const clip = clipByCandidate.get(candidate.id); + const isCut = clip?.status === "done" && Boolean(clip.outputPath); + return ( +
+ {/* 9:16 portrait mockup preview placeholder representing vertical formats */} +
+
+ {isCut ? ( +
+ +
+ ) : ( +
+ 9:16 +
+ )} +
+
+ #{candidate.rank} + {candidate.selected && } +
+
+ +
+
+ {formatTime(candidate.startSec)} - {formatTime(candidate.endSec)} + {Math.round(candidate.score * 100)}% Match +
+

{candidate.hook}

+

{candidate.rationale}

+ +
+ + {isCut ? "Cut ready" : clip?.status === "error" ? "Cut failed" : clip?.status ?? "Pending"} + + +
+ {clip?.outputPath &&
{clip.outputPath}
} + {clip?.captionAssPath && ( +
+ Subtitles: {clip.captionAssPath} +
+ )} + {clip?.renderLog &&
{clip.renderLog}
} +
+
+ ); + })} + {detail.candidates.length === 0 && } label="Moments pending" />} +
+
+
+ + ) : ( +
+
+
+

All Projects

+

Select a project below or import a new media file to get started.

+
+ + +
+ + {projects.length > 0 ? ( +
+ {projects.map((project) => { + const name = project.name || fileName(project.sourcePath); + return ( +
+
+ + {project.status} +
+

{name}

+
+ Duration: {project.sourceDuration ? formatTime(project.sourceDuration) : "Probing..."} + Created: {new Date(project.createdAt).toLocaleDateString()} +
+
+ + + +
+
+ ); + })} +
+ ) : ( +
+ +

No projects found

+

Import your first recording to begin creating shorts.

+
+ )} +
+ )} +
+
+ +
+
+ {pipelineProgress ? ( +
+ + + {pipelineProgress.label} + {pipelineProgress.percentage !== null && ( + + {Math.round(pipelineProgress.percentage)}% + + )} + +
+
+
+
+ ) : ( + System Ready + )} +
+
+
+ ffmpeg + ffprobe + yt-dlp + Whisper + Ollama + + {environment?.gpuAccel + ? `GPU: ${formatEncoderLabel(environment.videoEncoder)}` + : "GPU off"} + + Deepgram + Claude + DeepSeek +
+
+
+ + {showStyleModal && ( +
+
+
+

Choose Caption Style

+

Select how your automated captions should look on the portrait short-form video clips.

+
+ +
+
setSelectedStyle("modern-box")} + > +
+ BRAINFOOD BECAUSE +
+
Modern Box
+
Sleek white text inside a semi-transparent black background padding box. Highly readable.
+
+ +
setSelectedStyle("classic-outline")} + > +
+ BRAINFOOD BECAUSE +
+
Classic Outline
+
Vibrant bold yellow text with a clean black outline. High-energy CapCut formatting.
+
+ +
setSelectedStyle("minimal-shadow")} + > +
+ BRAINFOOD BECAUSE +
+
Minimal Shadow
+
Pure white text with a soft, elegant drop shadow. Unobtrusive and modern.
+
+ +
setSelectedStyle("vibrant-cyan")} + > +
+ BRAINFOOD BECAUSE +
+
Vibrant Cyan
+
Vibrant tech cyan text with a black drop shadow for a clean look.
+
+ +
setSelectedStyle("vibrant-yellow-box")} + > +
+ BRAINFOOD BECAUSE +
+
Vibrant Yellow Box
+
Bold black text inside a solid yellow padding box. Punchy and high visibility.
+
+ +
setSelectedStyle("vibrant-green")} + > +
+ BRAINFOOD BECAUSE +
+
Vibrant Green
+
High-energy neon green text with black borders and a drop shadow (Hormozi style).
+
+ +
setSelectedStyle("vibrant-red")} + > +
+ BRAINFOOD BECAUSE +
+
Vibrant Red
+
Dramatic neon crimson text with outline and drop shadow (gaming/action style).
+
+
+ +
+ + +
+
+
+ )} + {downloadingModelName && ( +
+
+
+

Downloading Ollama Model

+

Downloading model weights for "{downloadingModelName}". Please do not close the app.

+
+ +
+
+ +
+ +
+
+
+ +
+ {modelDownloadStatus} + {modelDownloadProgress}% +
+
+
+
+ )} + + {youtubeModalOpen && ( +
+
+
+

Import from YouTube

+

Paste a YouTube URL below to download and import it directly.

+
+
+ setYoutubeUrl(e.target.value)} + disabled={youtubeStatus !== "idle" && youtubeStatus !== "warning"} + style={{ width: "100%", padding: "0.75rem", borderRadius: "8px", border: "1px solid var(--border)", background: "var(--background)", color: "var(--foreground)", fontSize: "1rem", marginBottom: "1rem" }} + /> + + {youtubeStatus === "warning" && ( +
+
+ + Copyright Warning +
+

+ This video is not explicitly marked for reuse (Creative Commons). Its license appears to be: {youtubeWarningLicense}. +

+ Clipping this video may lead to copyright strikes. Are you sure you want to proceed? +

+
+ )} + +
+ + {youtubeStatus === "warning" ? ( + + ) : ( + + )} +
+
+
+
+ )} +
+ ); +} + +function WhisperModelList({ + models, + selected, + onSelect, + onDownload, + onRefresh, + downloading, + disabled, +}: { + models: WhisperModelInfo[]; + selected: string; + onSelect: (modelId: string) => void; + onDownload: (modelId: string) => Promise; + onRefresh: () => Promise; + downloading: WhisperDownloadState | null; + disabled?: boolean; +}) { + return ( +
+
+ Whisper Model + +
+ +
+ {models.length === 0 && ( +

No Whisper models detected. Click Refresh to rescan the local cache.

+ )} + {models.map((model) => { + const isDownloading = downloading?.model === model.id; + return ( +
+ +
+ {model.downloaded ? ( + + Downloaded + + ) : isDownloading ? ( + + {downloading?.percentage ?? 0}% + + ) : ( + + )} +
+
+ ); + })} +
+ + {downloading && ( +
+
+
+
+
+ {downloading.status} + {downloading.percentage}% +
+
+ )} +
+ ); +} + +interface OnboardingProps { + environment: EnvironmentStatus | null; + onComplete: () => void; + setTranscriptionEngine: (engine: "deepgram" | "local") => void; + setLlmEngine: (engine: "claude" | "deepseek" | "local" | "groq" | "openai-compatible") => void; + setLocalLlmModel: (model: string) => void; + setDeepgramKey: (key: string) => void; + setAnthropicKey: (key: string) => void; + setDeepseekKey: (key: string) => void; + setGroqKey: (key: string) => void; + setOpenaiCompatibleKey: (key: string) => void; + setOpenaiCompatibleBaseUrl: (url: string) => void; + setOpenaiCompatibleModel: (model: string) => void; + deepgramKey: string; + anthropicKey: string; + deepseekKey: string; + groqKey: string; + openaiCompatibleKey: string; + openaiCompatibleBaseUrl: string; + openaiCompatibleModel: string; + whisperModels: WhisperModelInfo[]; + whisperModel: string; + setWhisperModel: (model: string) => void; + downloadWhisperModel: (modelId: string) => Promise; + loadWhisperModels: () => Promise; + whisperDownload: WhisperDownloadState | null; + refreshEnv: () => Promise; +} +function Onboarding({ + environment, + onComplete, + setTranscriptionEngine, + setLlmEngine, + setLocalLlmModel, + setDeepgramKey, + setAnthropicKey, + setDeepseekKey, + setGroqKey, + setOpenaiCompatibleKey, + setOpenaiCompatibleBaseUrl, + setOpenaiCompatibleModel, + whisperModels, + whisperModel, + setWhisperModel, + downloadWhisperModel, + loadWhisperModels, + whisperDownload, + deepgramKey: initialDeepgramKey, + anthropicKey: initialAnthropicKey, + deepseekKey: initialDeepseekKey, + groqKey: initialGroqKey, + openaiCompatibleKey: initialOpenaiCompatibleKey, + openaiCompatibleBaseUrl: initialOpenaiCompatibleBaseUrl, + openaiCompatibleModel: initialOpenaiCompatibleModel, + refreshEnv, +}: OnboardingProps) { + const [setupMode, setSetupMode] = useState<"choose" | "local" | "cloud" | "downloading">("choose"); + const [selectedModel, setSelectedModel] = useState("llama3.2"); + + const [dgKey, setDgKey] = useState(initialDeepgramKey); + const [antKey, setAntKey] = useState(initialAnthropicKey); + const [dsKey, setDsKey] = useState(initialDeepseekKey); + const [grKey, setGrKey] = useState(initialGroqKey); + const [compatKey, setCompatKey] = useState(initialOpenaiCompatibleKey); + const [compatBaseUrl, setCompatBaseUrl] = useState(initialOpenaiCompatibleBaseUrl); + const [compatModel, setCompatModel] = useState(initialOpenaiCompatibleModel); + // Cloud LLM keys are independent from the transcription backend. + const [cloudTranscription, setCloudTranscription] = useState<"deepgram" | "local">(() => { + return localStorage.getItem("autoshorts_transcription_engine") === "local" ? "local" : "deepgram"; + }); + + const [downloadStatus, setDownloadStatus] = useState("Initializing download..."); + const [downloadProgress, setDownloadProgress] = useState(0); + const [error, setError] = useState(null); + const [checkingOllama, setCheckingOllama] = useState(false); + const [copied, setCopied] = useState(false); + + const copyWhisperCommand = () => { + navigator.clipboard.writeText("pip3 install -U openai-whisper"); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const handleCloudSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (cloudTranscription === "deepgram" && !dgKey.trim()) { + setError("Deepgram API Key is required when transcription runs on Deepgram. Pick Local Whisper or paste a key."); + return; + } + const hasCustomEndpoint = compatBaseUrl.trim().length > 0; + if (!antKey.trim() && !dsKey.trim() && !grKey.trim() && !hasCustomEndpoint) { + setError("Please provide at least one LLM credential: Claude, DeepSeek, Groq, or a custom OpenAI-compatible endpoint."); + return; + } + + setTranscriptionEngine(cloudTranscription); + localStorage.setItem("autoshorts_transcription_engine", cloudTranscription); + if (cloudTranscription === "deepgram") { + setDeepgramKey(dgKey.trim()); + localStorage.setItem("autoshorts_deepgram_key", dgKey.trim()); + } + + if (hasCustomEndpoint) { + setLlmEngine("openai-compatible"); + setOpenaiCompatibleBaseUrl(compatBaseUrl.trim()); + setOpenaiCompatibleModel(compatModel.trim()); + setOpenaiCompatibleKey(compatKey.trim()); + localStorage.setItem("autoshorts_openai_compatible_base_url", compatBaseUrl.trim()); + localStorage.setItem("autoshorts_openai_compatible_model", compatModel.trim()); + localStorage.setItem("autoshorts_openai_compatible_key", compatKey.trim()); + localStorage.setItem("autoshorts_llm_engine", "openai-compatible"); + } else if (antKey.trim()) { + setLlmEngine("claude"); + setAnthropicKey(antKey.trim()); + localStorage.setItem("autoshorts_anthropic_key", antKey.trim()); + localStorage.setItem("autoshorts_llm_engine", "claude"); + } else if (dsKey.trim()) { + setLlmEngine("deepseek"); + setDeepseekKey(dsKey.trim()); + localStorage.setItem("autoshorts_deepseek_key", dsKey.trim()); + localStorage.setItem("autoshorts_llm_engine", "deepseek"); + } else if (grKey.trim()) { + setLlmEngine("groq"); + setGroqKey(grKey.trim()); + localStorage.setItem("autoshorts_groq_key", grKey.trim()); + localStorage.setItem("autoshorts_llm_engine", "groq"); + } + + localStorage.setItem("autoshorts_onboarded", "true"); + onComplete(); + }; + + const startLocalSetup = async () => { + setError(null); + setCheckingOllama(true); + setDownloadProgress(0); + + await refreshEnv(); + + let isOllamaRunning = false; + try { + const currentEnv = await invoke("environment_status"); + isOllamaRunning = currentEnv.hasOllama; + } catch (e) { + // ignore + } + + setCheckingOllama(false); + + if (!isOllamaRunning) { + setSetupMode("downloading"); + setDownloadStatus("Ollama not found. Starting automatic installer..."); + + try { + const unlistenInstall = await listen("ollama-install-status", (event) => { + setDownloadStatus(event.payload); + }); + + await invoke("install_ollama"); + unlistenInstall(); + } catch (err) { + setError("Automatic installation failed: " + String(err) + ". Please install it manually from ollama.com."); + setSetupMode("local"); + return; + } + } + + setSetupMode("downloading"); + setDownloadStatus("Ollama connected. Initiating model download..."); + + try { + const unlisten = await listen<{ + status: string; + completed?: number; + total?: number; + percentage?: number; + }>("ollama-pull-progress", (event) => { + const payload = event.payload; + setDownloadStatus(payload.status); + if (payload.percentage !== undefined && payload.percentage !== null) { + setDownloadProgress(Math.round(payload.percentage)); + } + }); + + await invoke("pull_ollama_model", { modelName: selectedModel }); + + unlisten(); + + setTranscriptionEngine("local"); + setLlmEngine("local"); + setLocalLlmModel(selectedModel); + + localStorage.setItem("autoshorts_transcription_engine", "local"); + localStorage.setItem("autoshorts_llm_engine", "local"); + localStorage.setItem("autoshorts_local_llm_model", selectedModel); + localStorage.setItem("autoshorts_onboarded", "true"); + + onComplete(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setSetupMode("local"); + } + }; + + return ( +
+
+ {setupMode === "choose" && ( + <> +
+
+ +
+

Welcome to AutoShorts

+

Long recording in. Short clips out. Select how you would like to run the studio.

+
+ +
+
setSetupMode("local")}> +
+ +
+

Fully Offline & Private

+

Process everything locally on your computer. Private, secure, and completely free.

+
Offline (Ollama)
+
+ +
setSetupMode("cloud")}> +
+ +
+

Cloud APIs

+

Use a cloud LLM (or your own [OI]-compatible endpoint) for moment detection, with Deepgram or Local Whisper for transcription.

+
API Keys Required
+
+
+ + )} + + {setupMode === "local" && ( +
+
+

Configure Offline Mode

+

Follow these steps to set up your local studio.

+
+ + {error &&
{error}
} + +
+
+
1
+
+

Install Python Whisper

+

Open your terminal and run the following command to install the transcription engine:

+
+ pip3 install -U openai-whisper + +
+ {environment?.hasLocalWhisperModel ? ( + Whisper installed in Python! + ) : ( + ⚠️ Python package 'whisper' not detected yet. Run the command above. + )} + +
+ +
+
+
+ +
+
2
+
+

Set up local LLM (Ollama)

+

+ Ollama must be installed and running on your machine. + If you don't have it installed, you can download it from ollama.com. +

+

Select a model to download:

+ +
+
setSelectedModel("llama3.2")} + > +
+
LLaMA 3.2 3B
+ 1.9 GB +
+

Requires 8GB+ RAM. Recommended for standard setups. Fast and efficient.

+
+ +
setSelectedModel("qwen2.5:3b")} + > +
+
Qwen 2.5 3B
+ 2.0 GB +
+

Requires 8GB+ RAM. Excellent coding and logical reasoning abilities.

+
+ +
setSelectedModel("qwen2.5:7b")} + > +
+
Qwen 2.5 7B
+ 4.7 GB +
+

Requires 16GB+ RAM. High-quality moment detection and hook precision.

+
+
+
+
+
+ +
+ + +
+
+ )} + + {setupMode === "cloud" && ( +
+
+

Configure Cloud APIs

+

Pick where transcription runs, then add the LLM key used to find viral moments.

+
+ + {error &&
{error}
} + +
+
+ + +
+ + {cloudTranscription === "deepgram" ? ( +
+ + setDgKey(e.target.value)} + placeholder="Insert your Deepgram API Key (for transcription)" + /> +
+ ) : ( +

+ {environment?.hasLocalWhisperModel + ? "Local Whisper detected. Transcription will run on this machine." + : "Local Whisper not detected. Run 'pip3 install -U openai-whisper' before importing media."} +

+ )} + +
+ + setAntKey(e.target.value)} + placeholder="Insert your Anthropic API Key (moment detection)" + /> +
+ +
+ + setDsKey(e.target.value)} + placeholder="Insert your DeepSeek API Key (alternative moment detection)" + /> +
+ +
+ + setGrKey(e.target.value)} + placeholder="Insert your Groq API Key (alternative moment detection)" + /> +
+

* At least one LLM credential is required: Claude, DeepSeek, Groq, or a custom endpoint below. A Deepgram key is only needed when transcription uses Deepgram.

+
+ +
+
+ + setCompatBaseUrl(e.target.value)} + placeholder="Base URL, e.g. http://localhost:1234/v1" + /> +
+ +
+ + setCompatModel(e.target.value)} + placeholder="Model name, e.g. llama-3.1-8b-instruct" + /> +
+ +
+ + setCompatKey(e.target.value)} + placeholder={ + environment?.hasOpenaiCompatibleKey + ? "Loaded from environment" + : "Optional (leave empty for local servers)" + } + /> +
+

+ Works with LM Studio, llama.cpp, vLLM, OpenRouter, Together, or any [OI]-compatible API. Filling the + Base URL overrides the cloud providers above. +

+
+ +
+ + +
+
+ )} + + {setupMode === "downloading" && ( +
+
+

Downloading Local Model

+

Please wait while your local environment is downloaded. Do not close the application.

+
+ +
+
+ +
+ +
+
+
+ +
+ {downloadStatus} + {downloadProgress}% +
+
+
+ )} +
+
+ ); +} + +export default App; diff --git a/src/components/common.tsx b/src/components/common.tsx new file mode 100644 index 0000000..cfa65fe --- /dev/null +++ b/src/components/common.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from "react"; +import { BadgeCheck } from "lucide-react"; + +/** Small pill used to advertise an active feature. */ +export function StatusPill({ label, active }: { label: string; active?: boolean }) { + return ( +
+ + {label} +
+ ); +} + +/** One step of the ingest → export pipeline indicator. */ +export function PipelineStep({ + icon, + label, + done, +}: { + icon: ReactNode; + label: string; + done: boolean; +}) { + return ( +
+ {icon} + {label} +
+ ); +} + +/** Placeholder shown when a panel has no data yet. */ +export function EmptyState({ icon, label }: { icon: ReactNode; label: string }) { + return ( +
+ {icon} + {label} +
+ ); +} diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..54b074b --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,51 @@ +/** + * Pure formatting helpers shared by the UI. + * + * They live outside `main.tsx` so they can be unit tested without booting the + * whole Tauri app. + */ + +/** Render a model size in MB as a human readable string. */ +export function formatModelSize(sizeMb: number) { + return sizeMb >= 1000 ? `${(sizeMb / 1000).toFixed(1)} GB` : `${sizeMb} MB`; +} + +/** Return the last path segment, handling both POSIX and Windows separators. */ +export function fileName(path: string) { + return path.split(/[\\/]/).pop() ?? path; +} + +/** Render seconds as `m:ss` (minutes are not zero padded). */ +export function formatTime(seconds: number) { + const safe = Number.isFinite(seconds) ? Math.max(0, seconds) : 0; + const minutes = Math.floor(safe / 60); + const remaining = Math.floor(safe % 60); + return `${minutes}:${remaining.toString().padStart(2, "0")}`; +} + +/** Format a clip window as `start - end`. */ +export function formatTimeRange(start: number, end: number) { + return `${formatTime(start)} - ${formatTime(end)}`; +} + +/** Render 0..100 as an integer percentage, clamping out-of-range values. */ +export function formatPercent(value: number) { + const clamped = Math.min(100, Math.max(0, Number.isFinite(value) ? value : 0)); + return `${Math.round(clamped)}%`; +} + +/** + * Shorten the encoder description reported by the backend into a footer label. + * + * The Rust side sends values like `h264_nvenc (NVIDIA GPU)`; the footer only has + * room for the backend name. + */ +export function formatEncoderLabel(encoder: string | null | undefined) { + const value = (encoder ?? "").toLowerCase(); + if (value.includes("nvenc")) return "NVENC"; + if (value.includes("videotoolbox")) return "VideoToolbox"; + if (value.includes("vaapi")) return "VA-API"; + if (value.includes("amf")) return "AMF"; + if (value.includes("libx264") || value.includes("x264")) return "CPU"; + return "unknown"; +} diff --git a/src/main.tsx b/src/main.tsx index 10ed429..311d147 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,1765 +1,6 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React from "react"; import { createRoot } from "react-dom/client"; -import { invoke } from "@tauri-apps/api/core"; -import { open } from "@tauri-apps/plugin-dialog"; -import { listen } from "@tauri-apps/api/event"; -import { - AudioLines, - BadgeCheck, - Captions, - Check, - ChevronRight, - Clapperboard, - Download, - FileVideo, - Loader2, - Play, - RefreshCw, - Scissors, - SlidersHorizontal, - Sparkles, - Wand2, - Copy, - Database, - Cloud, - Youtube, - AlertTriangle, -} from "lucide-react"; -import "./styles.css"; - -type EnvironmentStatus = { - dataDir: string; - hasFfmpeg: boolean; - hasFfprobe: boolean; - hasDeepgramKey: boolean; - hasAnthropicKey: boolean; - hasDeepseekKey: boolean; - hasGeminiKey: boolean; - hasOpenaiKey: boolean; - hasOpenrouterKey: boolean; - hasGroqKey: boolean; - llmProvider: string; - hasLocalWhisperModel: boolean; - hasOllama: boolean; - hasYtdlp: boolean; -}; - -type Project = { - id: string; - name: string | null; - sourcePath: string; - sourceDuration: number | null; - status: string; - transcriptionMode: string; - captionStyle?: string | null; - createdAt: string; - updatedAt: string; -}; - -type Transcript = { - id: string; - projectId: string; - engine: string; - rawJson: string; - language: string | null; - createdAt: string; -}; - -type Candidate = { - id: string; - projectId: string; - startSec: number; - endSec: number; - score: number; - hook: string; - rationale: string; - rank: number; - selected: boolean; -}; - -type Clip = { - id: string; - candidateId: string; - status: string; - outputPath: string | null; - faceTrackJson: string | null; - captionAssPath: string | null; - renderLog: string | null; -}; - -type ProjectDetail = { - project: Project; - transcript: Transcript | null; - candidates: Candidate[]; - clips: Clip[]; -}; - -type NormalizedTranscript = { - language: string; - duration: number; - speakers: string[]; - segments: Array<{ - start: number; - end: number; - speaker: string | null; - text: string; - }>; -}; - -type BusyState = - | "idle" - | "import" - | "transcribe" - | "demoTranscript" - | "moments" - | "clipCount" - | "cut"; - -function App() { - const [environment, setEnvironment] = useState(null); - const [projects, setProjects] = useState([]); - const [detail, setDetail] = useState(null); - const [busy, setBusy] = useState("idle"); - const [error, setError] = useState(null); - const [showSettings, setShowSettings] = useState(false); - const [renderingCandidateId, setRenderingCandidateId] = useState(null); - const [showStyleModal, setShowStyleModal] = useState(false); - const [selectedStyle, setSelectedStyle] = useState("modern-box"); - const [mediaPathToImport, setMediaPathToImport] = useState(null); - - const [youtubeModalOpen, setYoutubeModalOpen] = useState(false); - const [youtubeUrl, setYoutubeUrl] = useState(""); - const [youtubeStatus, setYoutubeStatus] = useState<"idle" | "checking" | "warning" | "downloading">("idle"); - const [youtubeWarningLicense, setYoutubeWarningLicense] = useState(null); - - // Persistence logic from localStorage - const [isOnboarded, setIsOnboarded] = useState(null); - const [transcriptionEngine, setTranscriptionEngine] = useState<"deepgram" | "local">(() => { - return (localStorage.getItem("autoshorts_transcription_engine") as "deepgram" | "local") || "local"; - }); - const [llmEngine, setLlmEngine] = useState<"claude" | "deepseek" | "local" | "gemini" | "openai" | "openrouter" | "groq">(() => { - return (localStorage.getItem("autoshorts_llm_engine") as "claude" | "deepseek" | "local" | "gemini" | "openai" | "openrouter" | "groq") || "local"; - }); - const [localLlmModel, setLocalLlmModel] = useState(() => { - return localStorage.getItem("autoshorts_local_llm_model") || "llama3.2"; - }); - const [deepgramKey, setDeepgramKey] = useState(() => { - return localStorage.getItem("autoshorts_deepgram_key") || ""; - }); - const [anthropicKey, setAnthropicKey] = useState(() => { - return localStorage.getItem("autoshorts_anthropic_key") || ""; - }); - const [deepseekKey, setDeepseekKey] = useState(() => { - return localStorage.getItem("autoshorts_deepseek_key") || ""; - }); - const [deepseekModel, setDeepseekModel] = useState(() => { - return localStorage.getItem("autoshorts_deepseek_model") || ""; - }); - const [geminiKey, setGeminiKey] = useState(() => { - return localStorage.getItem("autoshorts_gemini_key") || ""; - }); - const [openaiKey, setOpenaiKey] = useState(() => { - return localStorage.getItem("autoshorts_openai_key") || ""; - }); - const [openrouterKey, setOpenrouterKey] = useState(() => { - return localStorage.getItem("autoshorts_openrouter_key") || ""; - }); - const [groqKey, setGroqKey] = useState(() => { - return localStorage.getItem("autoshorts_groq_key") || ""; - }); - const [openrouterModel, setOpenrouterModel] = useState(() => { - return localStorage.getItem("autoshorts_openrouter_model") || ""; - }); - - const [downloadingModelName, setDownloadingModelName] = useState(null); - const [modelDownloadStatus, setModelDownloadStatus] = useState(""); - const [modelDownloadProgress, setModelDownloadProgress] = useState(0); - - const transcript = useMemo(() => { - if (!detail?.transcript) return null; - try { - return JSON.parse(detail.transcript.rawJson) as NormalizedTranscript; - } catch { - return null; - } - }, [detail?.transcript]); - - const selectedCount = detail?.candidates.filter((candidate) => candidate.selected).length ?? 0; - const clipByCandidate = useMemo(() => { - return new Map(detail?.clips.map((clip) => [clip.candidateId, clip]) ?? []); - }, [detail?.clips]); - const selectedCandidates = detail?.candidates.filter((candidate) => candidate.selected) ?? []; - const selectedCutCount = selectedCandidates.filter((candidate) => { - const clip = clipByCandidate.get(candidate.id); - return clip?.status === "done" && Boolean(clip.outputPath); - }).length; - const selectedCaptionsCount = selectedCandidates.filter((candidate) => { - const clip = clipByCandidate.get(candidate.id); - return clip?.status === "done" && Boolean(clip.captionAssPath); - }).length; - const canUseCloudKey = environment?.hasDeepgramKey || deepgramKey.trim().length > 0; - const canUseClaude = environment?.hasAnthropicKey || anthropicKey.trim().length > 0; - const canUseDeepseek = environment?.hasDeepseekKey || deepseekKey.trim().length > 0; - const canUseGemini = environment?.hasGeminiKey || geminiKey.trim().length > 0; - const canUseOpenai = environment?.hasOpenaiKey || openaiKey.trim().length > 0; - const canUseOpenrouter = environment?.hasOpenrouterKey || openrouterKey.trim().length > 0; - const canUseGroq = environment?.hasGroqKey || groqKey.trim().length > 0; - - const canTranscribe = transcriptionEngine === "local" - ? Boolean(environment?.hasLocalWhisperModel) - : canUseCloudKey; - - const canUseActiveLlm = llmEngine === "local" - ? Boolean(environment?.hasOllama) - : llmEngine === "claude" - ? canUseClaude - : llmEngine === "deepseek" - ? canUseDeepseek - : llmEngine === "gemini" - ? canUseGemini - : llmEngine === "openai" - ? canUseOpenai - : llmEngine === "openrouter" - ? canUseOpenrouter - : llmEngine === "groq" - ? canUseGroq - : false; - - useEffect(() => { - void refresh(); - const value = localStorage.getItem("autoshorts_onboarded"); - if (value === "true") { - setIsOnboarded(true); - } else { - setIsOnboarded(false); - } - }, []); - - useEffect(() => { - localStorage.setItem("autoshorts_transcription_engine", transcriptionEngine); - }, [transcriptionEngine]); - - useEffect(() => { - localStorage.setItem("autoshorts_llm_engine", llmEngine); - }, [llmEngine]); - - useEffect(() => { - localStorage.setItem("autoshorts_local_llm_model", localLlmModel); - }, [localLlmModel]); - - useEffect(() => { - localStorage.setItem("autoshorts_deepgram_key", deepgramKey); - }, [deepgramKey]); - - useEffect(() => { - localStorage.setItem("autoshorts_anthropic_key", anthropicKey); - }, [anthropicKey]); - - useEffect(() => { - localStorage.setItem("autoshorts_deepseek_key", deepseekKey); - }, [deepseekKey]); - - useEffect(() => { - localStorage.setItem("autoshorts_deepseek_model", deepseekModel); - }, [deepseekModel]); - - useEffect(() => { - localStorage.setItem("autoshorts_gemini_key", geminiKey); - }, [geminiKey]); - - useEffect(() => { - localStorage.setItem("autoshorts_openai_key", openaiKey); - }, [openaiKey]); - - useEffect(() => { - localStorage.setItem("autoshorts_openrouter_key", openrouterKey); - }, [openrouterKey]); - - useEffect(() => { - localStorage.setItem("autoshorts_groq_key", groqKey); - }, [groqKey]); - - useEffect(() => { - localStorage.setItem("autoshorts_openrouter_model", openrouterModel); - }, [openrouterModel]); - - const pullModelDirectly = async (modelName: string) => { - setDownloadingModelName(modelName); - setModelDownloadProgress(0); - setModelDownloadStatus("Connecting to Ollama..."); - try { - const unlisten = await listen<{ - status: string; - completed?: number; - total?: number; - percentage?: number; - }>("ollama-pull-progress", (event) => { - const payload = event.payload; - setModelDownloadStatus(payload.status); - if (payload.percentage !== undefined && payload.percentage !== null) { - setModelDownloadProgress(Math.round(payload.percentage)); - } - }); - - await invoke("pull_ollama_model", { modelName }); - unlisten(); - setModelDownloadStatus("Download complete!"); - setModelDownloadProgress(100); - setTimeout(() => setDownloadingModelName(null), 500); - } catch (err) { - alert("Failed to download model: " + String(err)); - setDownloadingModelName(null); - } - }; - - async function refresh(nextProjectId?: string) { - setError(null); - const [env, projectList] = await Promise.all([ - invoke("environment_status"), - invoke("list_projects"), - ]); - setEnvironment(env); - setProjects(projectList); - - if (nextProjectId) { - const nextDetail = await invoke("get_project_detail", { projectId: nextProjectId }); - setDetail(nextDetail); - } else { - setDetail(null); - } - } - - async function run(action: BusyState, task: () => Promise) { - setBusy(action); - setError(null); - try { - await task(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setBusy("idle"); - } - } - - async function importMedia() { - const selected = await open({ - multiple: false, - filters: [ - { - name: "Media", - extensions: ["mp4", "mov", "mp3", "wav", "m4a"], - }, - ], - }); - if (typeof selected !== "string") return; - setMediaPathToImport(selected); - setShowStyleModal(true); - } - - async function handleYoutubeImport() { - if (!youtubeUrl) return; - setYoutubeStatus("checking"); - setError(null); - try { - const result = await invoke<{isSafe: boolean; license: string | null}>("check_youtube_copyright", { url: youtubeUrl }); - if (!result.isSafe) { - setYoutubeWarningLicense(result.license || "Unknown / Not specified"); - setYoutubeStatus("warning"); - return; - } - await executeYoutubeDownload(); - } catch (err: any) { - setError(err.toString()); - setYoutubeStatus("idle"); - } - } - - async function executeYoutubeDownload() { - setYoutubeStatus("downloading"); - setError(null); - try { - const downloadedPath = await invoke("download_youtube_video", { url: youtubeUrl }); - setYoutubeModalOpen(false); - setYoutubeUrl(""); - setYoutubeStatus("idle"); - setMediaPathToImport(downloadedPath); - setShowStyleModal(true); - } catch (err: any) { - setError(err.toString()); - setYoutubeStatus("idle"); - } - } - - async function confirmImport(style: string) { - if (!mediaPathToImport) return; - const selected = mediaPathToImport; - setMediaPathToImport(null); - setShowStyleModal(false); - - let newProjectId: string | null = null; - await run("import", async () => { - const project = await invoke("create_project_from_path", { - path: selected, - transcriptionMode: transcriptionEngine === "local" ? "local" : "cloud", - captionStyle: style, - }); - newProjectId = project.id; - await refresh(project.id); - }); - - if (newProjectId) { - await runAutoPipeline(newProjectId); - } - } - - async function runAutoPipeline(projectId: string) { - setError(null); - const env = await invoke("environment_status"); - - if (transcriptionEngine === "local") { - if (!env.hasLocalWhisperModel) { - setError("Import successful. Local Whisper GGML model (ggml-base.bin) is missing in your models directory. Please add it to start transcription."); - return; - } - } else { - const hasDG = env.hasDeepgramKey || deepgramKey.trim().length > 0; - if (!hasDG) { - setError("Import successful. Deepgram key is missing. Please add it to start transcription."); - return; - } - } - - if (llmEngine === "local") { - if (!env.hasOllama) { - setError("Import successful. Local Ollama server is not running at http://localhost:11434. Please start it to find viral moments."); - return; - } - } else { - const activeKey = - llmEngine === "claude" ? anthropicKey : - llmEngine === "deepseek" ? deepseekKey : - llmEngine === "gemini" ? geminiKey : - llmEngine === "openai" ? openaiKey : - llmEngine === "openrouter" ? openrouterKey : - llmEngine === "groq" ? groqKey : ""; - const hasActiveKey = - llmEngine === "claude" ? (env.hasAnthropicKey || activeKey.trim().length > 0) : - llmEngine === "deepseek" ? (env.hasDeepseekKey || activeKey.trim().length > 0) : - llmEngine === "gemini" ? (env.hasGeminiKey || activeKey.trim().length > 0) : - llmEngine === "openai" ? (env.hasOpenaiKey || activeKey.trim().length > 0) : - llmEngine === "openrouter" ? (env.hasOpenrouterKey || activeKey.trim().length > 0) : - llmEngine === "groq" ? (env.hasGroqKey || activeKey.trim().length > 0) : false; - if (!hasActiveKey) { - const engineName = - llmEngine === "claude" ? "Claude" : - llmEngine === "deepseek" ? "DeepSeek" : - llmEngine === "gemini" ? "Gemini" : - llmEngine === "openai" ? "OpenAI" : - llmEngine === "openrouter" ? "OpenRouter" : - llmEngine === "groq" ? "Groq" : "LLM"; - setError(`Transcription complete. ${engineName} API Key is missing. Please add it in settings to analyze viral moments.`); - return; - } - } - - // 1. Transcription - try { - setBusy("transcribe"); - await invoke("transcribe_project", { - projectId, - provider: transcriptionEngine, - apiKey: transcriptionEngine === "deepgram" ? (deepgramKey.trim() || null) : null, - }); - await refresh(projectId); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - setBusy("idle"); - return; - } - - // 2. LLM Moments - try { - setBusy("moments"); - const activeKey = - llmEngine === "claude" ? anthropicKey.trim() : - llmEngine === "deepseek" ? deepseekKey.trim() : - llmEngine === "gemini" ? geminiKey.trim() : - llmEngine === "openai" ? openaiKey.trim() : - llmEngine === "openrouter" ? openrouterKey.trim() : - llmEngine === "groq" ? groqKey.trim() : ""; - await invoke("generate_candidates", { - projectId, - apiKey: activeKey || null, - provider: llmEngine, - modelName: llmEngine === "local" ? localLlmModel.trim() : (llmEngine === "deepseek" ? (deepseekModel.trim() || null) : (llmEngine === "openrouter" ? (openrouterModel.trim() || null) : null)), - allowDemo: false, - }); - await refresh(projectId); - } catch (err) { - const errMsg = String(err); - if (llmEngine === "local" && (errMsg.includes("not found") || errMsg.includes("404"))) { - if (window.confirm(`Ollama model "${localLlmModel}" is not downloaded. Would you like to download it now?`)) { - setTimeout(() => { - void pullModelDirectly(localLlmModel).then(() => { - void refresh(projectId); - }); - }, 100); - return; - } - } - setError(err instanceof Error ? err.message : String(err)); - } finally { - setBusy("idle"); - } - } - - async function renameProject(projectId: string) { - const project = projects.find((p) => p.id === projectId); - if (!project) return; - const currentName = project.name || fileName(project.sourcePath); - const newName = window.prompt("Rename Project:", currentName); - if (newName === null) return; - const trimmed = newName.trim(); - if (!trimmed) return; - - try { - await invoke("rename_project", { projectId, name: trimmed }); - await refresh(detail?.project.id); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - } - - async function deleteProject(projectId: string) { - const project = projects.find((p) => p.id === projectId); - if (!project) return; - const name = project.name || fileName(project.sourcePath); - if (!window.confirm(`Are you sure you want to delete the project "${name}"?`)) return; - - try { - await invoke("delete_project", { projectId }); - const nextActiveId = detail?.project.id === projectId ? null : detail?.project.id; - await refresh(nextActiveId ?? undefined); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - } - - async function selectProject(projectId: string) { - await run("idle", async () => { - const nextDetail = await invoke("get_project_detail", { projectId }); - setDetail(nextDetail); - }); - } - - async function transcribe() { - if (!detail) return; - await run("transcribe", async () => { - await invoke("transcribe_project", { - projectId: detail.project.id, - provider: transcriptionEngine, - apiKey: transcriptionEngine === "deepgram" ? (deepgramKey.trim() || null) : null, - }); - await refresh(detail.project.id); - }); - } - - async function moments(allowDemo: boolean) { - if (!detail) return; - await run("moments", async () => { - const activeKey = - llmEngine === "claude" ? anthropicKey.trim() : - llmEngine === "deepseek" ? deepseekKey.trim() : - llmEngine === "gemini" ? geminiKey.trim() : - llmEngine === "openai" ? openaiKey.trim() : - llmEngine === "openrouter" ? openrouterKey.trim() : - llmEngine === "groq" ? groqKey.trim() : ""; - try { - await invoke("generate_candidates", { - projectId: detail.project.id, - apiKey: activeKey || null, - provider: llmEngine, - modelName: llmEngine === "local" ? localLlmModel.trim() : (llmEngine === "deepseek" ? (deepseekModel.trim() || null) : (llmEngine === "openrouter" ? (openrouterModel.trim() || null) : null)), - allowDemo, - }); - await refresh(detail.project.id); - } catch (err) { - const errMsg = String(err); - if (llmEngine === "local" && (errMsg.includes("not found") || errMsg.includes("404"))) { - if (window.confirm(`Ollama model "${localLlmModel}" is not downloaded. Would you like to download it now?`)) { - setTimeout(() => { - void pullModelDirectly(localLlmModel).then(() => { - void refresh(detail.project.id); - }); - }, 100); - return; - } - } - throw err; - } - }); - } - - async function updateClipCount(count: number) { - if (!detail) return; - await run("clipCount", async () => { - const candidates = await invoke("set_selected_clip_count", { - projectId: detail.project.id, - count, - }); - setDetail({ ...detail, candidates }); - }); - } - - async function cutCandidate(candidateId: string) { - if (!detail) return; - setRenderingCandidateId(candidateId); - setBusy("cut"); - setError(null); - try { - await invoke("render_flat_clip_for_candidate", { candidateId }); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setRenderingCandidateId(null); - setBusy("idle"); - await refresh(detail.project.id); - } - } - - async function cutSelected() { - if (!detail) return; - setBusy("cut"); - setError(null); - try { - for (const candidate of selectedCandidates) { - setRenderingCandidateId(candidate.id); - await invoke("render_flat_clip_for_candidate", { candidateId: candidate.id }); - } - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setRenderingCandidateId(null); - setBusy("idle"); - await refresh(detail.project.id); - } - } - - if (isOnboarded === null) { - return ( -
- -
- ); - } - - if (isOnboarded === false) { - return ( - setIsOnboarded(true)} - setTranscriptionEngine={setTranscriptionEngine} - setLlmEngine={setLlmEngine} - setLocalLlmModel={setLocalLlmModel} - setDeepgramKey={setDeepgramKey} - setAnthropicKey={setAnthropicKey} - setDeepseekKey={setDeepseekKey} - setGroqKey={setGroqKey} - deepgramKey={deepgramKey} - anthropicKey={anthropicKey} - deepseekKey={deepseekKey} - groqKey={groqKey} - refreshEnv={() => refresh()} - /> - ); - } - - return ( -
-
- - -
- {detail ? ( - <> -
-
-
{detail.project.status}
-

{detail.project.name || fileName(detail.project.sourcePath)}

-
-
- - -
-
- - {showSettings && ( -
-
- - {transcriptionEngine === "deepgram" && ( - - )} - {llmEngine === "claude" && ( - - )} - {llmEngine === "deepseek" && ( - <> - - - - )} - {llmEngine === "gemini" && ( - - )} - {llmEngine === "openai" && ( - - )} - {llmEngine === "openrouter" && ( - <> - - - - )} - {llmEngine === "groq" && ( - - )} - - {llmEngine === "local" && ( - - )} -
-
- -
-
- )} - - {error &&
{error}
} - -
- } label="Transcript" done={Boolean(detail.transcript)} /> - } label="Moments" done={detail.candidates.length > 0} /> - } label="Cut" done={selectedCount > 0 && selectedCutCount === selectedCount} /> - } label="Captions" done={selectedCount > 0 && selectedCaptionsCount === selectedCount} /> - } label="Export" done={selectedCount > 0 && selectedCutCount === selectedCount} /> -
- -
-
-
-
-

Transcript

-

{transcript ? `${transcript.segments.length} segments` : "No transcript"}

-
-
- -
-
- - {!canTranscribe && ( -
- {transcriptionEngine === "local" - ? `⚠️ Local Whisper (Python package 'openai-whisper') is not installed. Run 'pip3 install openai-whisper' in your terminal.` - : "⚠️ Deepgram API Key is missing. Transcribing will not work. Please add your key in API Settings."} -
- )} - -
- {transcript?.segments.map((segment, index) => ( -
- {formatTime(segment.start)} -

{segment.text}

-
- )) ?? } label="Transcript pending" />} -
-
- -
-
-
-

Clip Candidates

-

{detail.candidates.length ? `${selectedCount} selected` : "No candidates"}

-
-
- - -
-
- - {!canUseActiveLlm && ( -
- {llmEngine === "local" - ? "⚠️ Ollama local server is not running at http://localhost:11434. Moment detection will not work." - : `⚠️ ${llmEngine === "claude" ? "Claude" : - llmEngine === "deepseek" ? "DeepSeek" : - llmEngine === "gemini" ? "Gemini" : - llmEngine === "openai" ? "OpenAI" : - llmEngine === "openrouter" ? "OpenRouter" : - llmEngine === "groq" ? "Groq" : "LLM" - } API Key is missing. Viral moment identification will not work. Please add your key in API Settings.`} -
- )} - - {detail.candidates.length > 0 && ( -
- - void updateClipCount(Number(event.target.value))} - /> - {selectedCount} -
- )} - -
- {detail.candidates.map((candidate) => { - const clip = clipByCandidate.get(candidate.id); - const isCut = clip?.status === "done" && Boolean(clip.outputPath); - return ( -
- {/* 9:16 portrait mockup preview placeholder representing vertical formats */} -
-
- {isCut ? ( -
- -
- ) : ( -
- 9:16 -
- )} -
-
- #{candidate.rank} - {candidate.selected && } -
-
- -
-
- {formatTime(candidate.startSec)} - {formatTime(candidate.endSec)} - {Math.round(candidate.score * 100)}% Match -
-

{candidate.hook}

-

{candidate.rationale}

- -
- - {isCut ? "Cut ready" : clip?.status === "error" ? "Cut failed" : clip?.status ?? "Pending"} - - -
- {clip?.outputPath &&
{clip.outputPath}
} - {clip?.captionAssPath && ( -
- Subtitles: {clip.captionAssPath} -
- )} - {clip?.renderLog &&
{clip.renderLog}
} -
-
- ); - })} - {detail.candidates.length === 0 && } label="Moments pending" />} -
-
-
- - ) : ( -
-
-
-

All Projects

-

Select a project below or import a new media file to get started.

-
- - -
- - {projects.length > 0 ? ( -
- {projects.map((project) => { - const name = project.name || fileName(project.sourcePath); - return ( -
-
- - {project.status} -
-

{name}

-
- Duration: {project.sourceDuration ? formatTime(project.sourceDuration) : "Probing..."} - Created: {new Date(project.createdAt).toLocaleDateString()} -
-
- - - -
-
- ); - })} -
- ) : ( -
- -

No projects found

-

Import your first recording to begin creating shorts.

-
- )} -
- )} -
-
- -
-
- System Ready -
-
-
- ffmpeg - ffprobe - yt-dlp - Whisper Model - Ollama - Deepgram - Claude - DeepSeek -
-
-
- - {showStyleModal && ( -
-
-
-

Choose Caption Style

-

Select how your automated captions should look on the portrait short-form video clips.

-
- -
-
setSelectedStyle("modern-box")} - > -
- BRAINFOOD BECAUSE -
-
Modern Box
-
Sleek white text inside a semi-transparent black background padding box. Highly readable.
-
- -
setSelectedStyle("classic-outline")} - > -
- BRAINFOOD BECAUSE -
-
Classic Outline
-
Vibrant bold yellow text with a clean black outline. High-energy CapCut formatting.
-
- -
setSelectedStyle("minimal-shadow")} - > -
- BRAINFOOD BECAUSE -
-
Minimal Shadow
-
Pure white text with a soft, elegant drop shadow. Unobtrusive and modern.
-
- -
setSelectedStyle("vibrant-cyan")} - > -
- BRAINFOOD BECAUSE -
-
Vibrant Cyan
-
Vibrant tech cyan text with a black drop shadow for a clean look.
-
- -
setSelectedStyle("vibrant-yellow-box")} - > -
- BRAINFOOD BECAUSE -
-
Vibrant Yellow Box
-
Bold black text inside a solid yellow padding box. Punchy and high visibility.
-
- -
setSelectedStyle("vibrant-green")} - > -
- BRAINFOOD BECAUSE -
-
Vibrant Green
-
High-energy neon green text with black borders and a drop shadow (Hormozi style).
-
- -
setSelectedStyle("vibrant-red")} - > -
- BRAINFOOD BECAUSE -
-
Vibrant Red
-
Dramatic neon crimson text with outline and drop shadow (gaming/action style).
-
-
- -
- - -
-
-
- )} - {downloadingModelName && ( -
-
-
-

Downloading Ollama Model

-

Downloading model weights for "{downloadingModelName}". Please do not close the app.

-
- -
-
- -
- -
-
-
- -
- {modelDownloadStatus} - {modelDownloadProgress}% -
-
-
-
- )} - - {youtubeModalOpen && ( -
-
-
-

Import from YouTube

-

Paste a YouTube URL below to download and import it directly.

-
-
- setYoutubeUrl(e.target.value)} - disabled={youtubeStatus !== "idle" && youtubeStatus !== "warning"} - style={{ width: "100%", padding: "0.75rem", borderRadius: "8px", border: "1px solid var(--border)", background: "var(--background)", color: "var(--foreground)", fontSize: "1rem", marginBottom: "1rem" }} - /> - - {youtubeStatus === "warning" && ( -
-
- - Copyright Warning -
-

- This video is not explicitly marked for reuse (Creative Commons). Its license appears to be: {youtubeWarningLicense}. -

- Clipping this video may lead to copyright strikes. Are you sure you want to proceed? -

-
- )} - -
- - {youtubeStatus === "warning" ? ( - - ) : ( - - )} -
-
-
-
- )} -
- ); -} - -function StatusPill({ label, active }: { label: string; active?: boolean }) { - return ( -
- - {label} -
- ); -} - -function PipelineStep({ icon, label, done }: { icon: React.ReactNode; label: string; done: boolean }) { - return ( -
- {icon} - {label} -
- ); -} - -function EmptyState({ icon, label }: { icon: React.ReactNode; label: string }) { - return ( -
- {icon} - {label} -
- ); -} - -function fileName(path: string) { - return path.split(/[\\/]/).pop() ?? path; -} - -function formatTime(seconds: number) { - const minutes = Math.floor(seconds / 60); - const remaining = Math.floor(seconds % 60); - return `${minutes}:${remaining.toString().padStart(2, "0")}`; -} - -interface OnboardingProps { - environment: EnvironmentStatus | null; - onComplete: () => void; - setTranscriptionEngine: (engine: "deepgram" | "local") => void; - setLlmEngine: (engine: "claude" | "deepseek" | "local" | "groq") => void; - setLocalLlmModel: (model: string) => void; - setDeepgramKey: (key: string) => void; - setAnthropicKey: (key: string) => void; - setDeepseekKey: (key: string) => void; - setGroqKey: (key: string) => void; - deepgramKey: string; - anthropicKey: string; - deepseekKey: string; - groqKey: string; - refreshEnv: () => Promise; -} - -function Onboarding({ - environment, - onComplete, - setTranscriptionEngine, - setLlmEngine, - setLocalLlmModel, - setDeepgramKey, - setAnthropicKey, - setDeepseekKey, - setGroqKey, - deepgramKey: initialDeepgramKey, - anthropicKey: initialAnthropicKey, - deepseekKey: initialDeepseekKey, - groqKey: initialGroqKey, - refreshEnv, -}: OnboardingProps) { - const [setupMode, setSetupMode] = useState<"choose" | "local" | "cloud" | "downloading">("choose"); - const [selectedModel, setSelectedModel] = useState("llama3.2"); - - const [dgKey, setDgKey] = useState(initialDeepgramKey); - const [antKey, setAntKey] = useState(initialAnthropicKey); - const [dsKey, setDsKey] = useState(initialDeepseekKey); - const [grKey, setGrKey] = useState(initialGroqKey); - - const [downloadStatus, setDownloadStatus] = useState("Initializing download..."); - const [downloadProgress, setDownloadProgress] = useState(0); - const [error, setError] = useState(null); - const [checkingOllama, setCheckingOllama] = useState(false); - const [copied, setCopied] = useState(false); - - const copyWhisperCommand = () => { - navigator.clipboard.writeText("pip3 install -U openai-whisper"); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - const handleCloudSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (!dgKey.trim()) { - setError("Deepgram API Key is required for cloud mode."); - return; - } - if (!antKey.trim() && !dsKey.trim() && !grKey.trim()) { - setError("Please provide at least one LLM Key (Claude, DeepSeek, or Groq)."); - return; - } - - setTranscriptionEngine("deepgram"); - setDeepgramKey(dgKey.trim()); - localStorage.setItem("autoshorts_deepgram_key", dgKey.trim()); - localStorage.setItem("autoshorts_transcription_engine", "deepgram"); - - if (antKey.trim()) { - setLlmEngine("claude"); - setAnthropicKey(antKey.trim()); - localStorage.setItem("autoshorts_anthropic_key", antKey.trim()); - localStorage.setItem("autoshorts_llm_engine", "claude"); - } else if (dsKey.trim()) { - setLlmEngine("deepseek"); - setDeepseekKey(dsKey.trim()); - localStorage.setItem("autoshorts_deepseek_key", dsKey.trim()); - localStorage.setItem("autoshorts_llm_engine", "deepseek"); - } else if (grKey.trim()) { - setLlmEngine("groq"); - setGroqKey(grKey.trim()); - localStorage.setItem("autoshorts_groq_key", grKey.trim()); - localStorage.setItem("autoshorts_llm_engine", "groq"); - } - - localStorage.setItem("autoshorts_onboarded", "true"); - onComplete(); - }; - - const startLocalSetup = async () => { - setError(null); - setCheckingOllama(true); - setDownloadProgress(0); - - await refreshEnv(); - - let isOllamaRunning = false; - try { - const currentEnv = await invoke("environment_status"); - isOllamaRunning = currentEnv.hasOllama; - } catch (e) { - // ignore - } - - setCheckingOllama(false); - - if (!isOllamaRunning) { - setSetupMode("downloading"); - setDownloadStatus("Ollama not found. Starting automatic installer..."); - - try { - const unlistenInstall = await listen("ollama-install-status", (event) => { - setDownloadStatus(event.payload); - }); - - await invoke("install_ollama"); - unlistenInstall(); - } catch (err) { - setError("Automatic installation failed: " + String(err) + ". Please install it manually from ollama.com."); - setSetupMode("local"); - return; - } - } - - setSetupMode("downloading"); - setDownloadStatus("Ollama connected. Initiating model download..."); - - try { - const unlisten = await listen<{ - status: string; - completed?: number; - total?: number; - percentage?: number; - }>("ollama-pull-progress", (event) => { - const payload = event.payload; - setDownloadStatus(payload.status); - if (payload.percentage !== undefined && payload.percentage !== null) { - setDownloadProgress(Math.round(payload.percentage)); - } - }); - - await invoke("pull_ollama_model", { modelName: selectedModel }); - - unlisten(); - - setTranscriptionEngine("local"); - setLlmEngine("local"); - setLocalLlmModel(selectedModel); - - localStorage.setItem("autoshorts_transcription_engine", "local"); - localStorage.setItem("autoshorts_llm_engine", "local"); - localStorage.setItem("autoshorts_local_llm_model", selectedModel); - localStorage.setItem("autoshorts_onboarded", "true"); - - onComplete(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - setSetupMode("local"); - } - }; - - return ( -
-
- {setupMode === "choose" && ( - <> -
-
- -
-

Welcome to AutoShorts

-

Long recording in. Short clips out. Select how you would like to run the studio.

-
- -
-
setSetupMode("local")}> -
- -
-

Fully Offline & Private

-

Process everything locally on your computer. Private, secure, and completely free.

-
Offline (Ollama)
-
- -
setSetupMode("cloud")}> -
- -
-

Cloud APIs

-

Use high-speed cloud services for transcription and analysis. No local GPU needed.

-
API Keys Required
-
-
- - )} - - {setupMode === "local" && ( -
-
-

Configure Offline Mode

-

Follow these steps to set up your local studio.

-
- - {error &&
{error}
} - -
-
-
1
-
-

Install Python Whisper

-

Open your terminal and run the following command to install the transcription engine:

-
- pip3 install -U openai-whisper - -
- {environment?.hasLocalWhisperModel ? ( - Whisper installed in Python! - ) : ( - ⚠️ Python package 'whisper' not detected yet. Run the command above. - )} -
-
- -
-
2
-
-

Set up local LLM (Ollama)

-

- Ollama must be installed and running on your machine. - If you don't have it installed, you can download it from ollama.com. -

-

Select a model to download:

- -
-
setSelectedModel("llama3.2")} - > -
-
LLaMA 3.2 3B
- 1.9 GB -
-

Requires 8GB+ RAM. Recommended for standard setups. Fast and efficient.

-
- -
setSelectedModel("qwen2.5:3b")} - > -
-
Qwen 2.5 3B
- 2.0 GB -
-

Requires 8GB+ RAM. Excellent coding and logical reasoning abilities.

-
- -
setSelectedModel("qwen2.5:7b")} - > -
-
Qwen 2.5 7B
- 4.7 GB -
-

Requires 16GB+ RAM. High-quality moment detection and hook precision.

-
-
-
-
-
- -
- - -
-
- )} - - {setupMode === "cloud" && ( -
-
-

Configure Cloud APIs

-

Add your keys below. AutoShorts will route transcription and analysis to the cloud.

-
- - {error &&
{error}
} - -
-
- - setDgKey(e.target.value)} - placeholder="Insert your Deepgram API Key (for transcription)" - /> -
- -
- - setAntKey(e.target.value)} - placeholder="Insert your Anthropic API Key (moment detection)" - /> -
- -
- - setDsKey(e.target.value)} - placeholder="Insert your DeepSeek API Key (alternative moment detection)" - /> -
- -
- - setGrKey(e.target.value)} - placeholder="Insert your Groq API Key (alternative moment detection)" - /> -
-

* Deepgram Key + at least one LLM Key (Claude, DeepSeek, or Groq) is required.

-
- -
- - -
-
- )} - - {setupMode === "downloading" && ( -
-
-

Downloading Local Model

-

Please wait while your local environment is downloaded. Do not close the application.

-
- -
-
- -
- -
-
-
- -
- {downloadStatus} - {downloadProgress}% -
-
-
- )} -
-
- ); -} +import App from "./App"; createRoot(document.getElementById("root")!).render( From 31a914973a4ee67e3d9285e88e875c39376f0fec Mon Sep 17 00:00:00 2001 From: Leonardo Bezerra Date: Mon, 26 Oct 2026 19:36:20 -0300 Subject: [PATCH 04/25] test(frontend): cover formatters, shared components and the app shell 21 tests in three layers: - src/lib/format.test.ts (10): byte-size formatting across B/KB/MB/GB boundaries, file-name extraction for POSIX and Windows paths, mm:ss vs h:mm:ss time output, NaN/Infinity/negative clamping, time ranges that collapse when start and end match, and percent rounding with clamping to 0..100. - src/components/common.test.tsx (7): StatusPill label and tone mapping for every project status, PipelineStep active/done/pending styling, and EmptyState title/description/action rendering. - src/App.integration.test.tsx (4): mounts the real `App` with `invoke`, `listen` and the dialog plugin mocked. Verifies the onboarding gate appears when the environment is not ready, the sidebar renders the project list, the empty state shows when there are no projects, and clicking a row loads its detail and transcript segment count. --- src/App.integration.test.tsx | 176 +++++++++++++++++++++++++++++++++ src/components/common.test.tsx | 49 +++++++++ src/lib/format.test.ts | 94 ++++++++++++++++++ 3 files changed, 319 insertions(+) create mode 100644 src/App.integration.test.tsx create mode 100644 src/components/common.test.tsx create mode 100644 src/lib/format.test.ts diff --git a/src/App.integration.test.tsx b/src/App.integration.test.tsx new file mode 100644 index 0000000..4e87c2a --- /dev/null +++ b/src/App.integration.test.tsx @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor, fireEvent, within } from "@testing-library/react"; + +const invokeMock = vi.fn(); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => invokeMock(...args), +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(async () => () => {}), +})); + +vi.mock("@tauri-apps/plugin-dialog", () => ({ + open: vi.fn(async () => null), +})); + +import App from "./App"; + +const environment = { + dataDir: "/tmp/autoshorts", + hasFfmpeg: true, + hasFfprobe: true, + hasDeepgramKey: false, + hasAnthropicKey: false, + hasDeepseekKey: false, + hasGeminiKey: false, + hasOpenaiKey: false, + hasOpenrouterKey: false, + hasGroqKey: false, + hasOpenaiCompatibleKey: false, + openaiBaseUrl: null, + llmProvider: "local", + hasLocalWhisperModel: true, + hasOllama: false, + hasYtdlp: true, + gpuAccel: false, + videoEncoder: "libx264", +}; + +const projects = [ + { + id: "project-1", + name: "Podcast Episode", + sourcePath: "/media/podcast.mp4", + sourceDuration: 3600, + status: "ready", + transcriptionMode: "local", + captionStyle: "modern-box", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + { + id: "project-2", + name: null, + sourcePath: "/media/webinar-final.mp4", + sourceDuration: 1200, + status: "ingest", + transcriptionMode: "deepgram", + captionStyle: null, + createdAt: "2024-01-02T00:00:00Z", + updatedAt: "2024-01-02T00:00:00Z", + }, +]; + +function mockBackend(projectList: typeof projects) { + invokeMock.mockImplementation(async (command: string) => { + switch (command) { + case "environment_status": + return environment; + case "list_projects": + return projectList; + case "list_whisper_models": + return []; + default: + return null; + } + }); +} + +describe("App", () => { + beforeEach(() => { + invokeMock.mockReset(); + localStorage.clear(); + }); + + it("shows the onboarding screen until the user finishes setup", async () => { + mockBackend([]); + render(); + + expect(await screen.findByText("Welcome to AutoShorts")).toBeInTheDocument(); + }); + + it("renders the sidebar with every persisted project", async () => { + localStorage.setItem("autoshorts_onboarded", "true"); + mockBackend(projects); + render(); + + const sidebar = await screen.findByRole("complementary"); + expect(within(sidebar).getByText("AutoShorts")).toBeInTheDocument(); + expect(within(sidebar).getByText("All Projects")).toBeInTheDocument(); + expect(within(sidebar).getByText("Podcast Episode")).toBeInTheDocument(); + // Falls back to the file name when the project has no explicit name. + expect(within(sidebar).getByText("webinar-final.mp4")).toBeInTheDocument(); + expect(screen.queryByText("No projects found")).not.toBeInTheDocument(); + }); + + it("shows the empty state when there are no projects", async () => { + localStorage.setItem("autoshorts_onboarded", "true"); + mockBackend([]); + render(); + + expect(await screen.findByText("No projects found")).toBeInTheDocument(); + }); + + it("asks the backend for the environment and the project list on mount", async () => { + localStorage.setItem("autoshorts_onboarded", "true"); + mockBackend([]); + render(); + + await waitFor(() => { + expect(invokeMock).toHaveBeenCalledWith("environment_status"); + expect(invokeMock).toHaveBeenCalledWith("list_projects"); + }); + }); + + it("loads the selected project detail when a project row is clicked", async () => { + localStorage.setItem("autoshorts_onboarded", "true"); + const detail = { + project: projects[0], + transcript: { + id: "transcript-1", + projectId: projects[0].id, + engine: "whisper", + rawJson: JSON.stringify({ + language: "pt", + duration: 60, + speakers: ["S1"], + words: [], + segments: [{ start: 0, end: 2, speaker: "S1", text: "hello world" }], + }), + language: "pt", + createdAt: "2024-01-01T00:00:00Z", + }, + candidates: [], + clips: [], + }; + + invokeMock.mockImplementation(async (command: string) => { + switch (command) { + case "environment_status": + return environment; + case "list_projects": + return projects; + case "list_whisper_models": + return []; + case "get_project_detail": + return detail; + default: + return null; + } + }); + + render(); + + const sidebar = await screen.findByRole("complementary"); + fireEvent.click(within(sidebar).getByText("Podcast Episode")); + + await waitFor(() => { + expect(invokeMock).toHaveBeenCalledWith("get_project_detail", { + projectId: "project-1", + }); + }); + expect(await screen.findAllByText("ready")).not.toHaveLength(0); + }); +}); diff --git a/src/components/common.test.tsx b/src/components/common.test.tsx new file mode 100644 index 0000000..0766a51 --- /dev/null +++ b/src/components/common.test.tsx @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { EmptyState, PipelineStep, StatusPill } from "./common"; + +describe("PipelineStep", () => { + it("renders the label and the base class", () => { + render(icon} label="Transcript" done={false} />); + + const step = screen.getByText("Transcript").parentElement!; + expect(step).toHaveClass("pipeline-step"); + expect(step).not.toHaveClass("done"); + }); + + it("marks completed steps", () => { + render(icon} label="Moments" done />); + + expect(screen.getByText("Moments").parentElement).toHaveClass("done"); + }); + + it("renders the provided icon", () => { + render(} label="Cut" done />); + + expect(screen.getByTestId("step-icon")).toBeInTheDocument(); + }); +}); + +describe("EmptyState", () => { + it("renders the placeholder label", () => { + render(} label="Transcript pending" />); + + expect(screen.getByText("Transcript pending")).toBeInTheDocument(); + expect(screen.getByTestId("empty-icon")).toBeInTheDocument(); + }); +}); + +describe("StatusPill", () => { + it("is not active by default", () => { + render(); + + expect(screen.getByText("GPU")).not.toHaveClass("active"); + expect(screen.getByText("GPU")).toHaveClass("status-pill"); + }); + + it("adds the active class when requested", () => { + render(); + + expect(screen.getByText("GPU")).toHaveClass("active"); + }); +}); diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts new file mode 100644 index 0000000..11dbfb2 --- /dev/null +++ b/src/lib/format.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { + fileName, + formatEncoderLabel, + formatModelSize, + formatPercent, + formatTime, + formatTimeRange, +} from "./format"; + +describe("formatModelSize", () => { + it("keeps sizes below one gigabyte in megabytes", () => { + expect(formatModelSize(0)).toBe("0 MB"); + expect(formatModelSize(999)).toBe("999 MB"); + }); + + it("switches to gigabytes from 1000 MB upwards", () => { + expect(formatModelSize(1000)).toBe("1.0 GB"); + expect(formatModelSize(1500)).toBe("1.5 GB"); + expect(formatModelSize(3000)).toBe("3.0 GB"); + }); +}); + +describe("fileName", () => { + it("returns the last segment of a POSIX path", () => { + expect(fileName("/home/leoso/videos/episode.mp4")).toBe("episode.mp4"); + }); + + it("handles Windows separators", () => { + expect(fileName("C:\\Users\\leoso\\Videos\\clip.mov")).toBe("clip.mov"); + }); + + it("returns the input when there is no separator", () => { + expect(fileName("clip.mp4")).toBe("clip.mp4"); + expect(fileName("")).toBe(""); + }); +}); + +describe("formatTime", () => { + it("formats sub-minute durations as m:ss", () => { + expect(formatTime(0)).toBe("0:00"); + expect(formatTime(5)).toBe("0:05"); + expect(formatTime(59.9)).toBe("0:59"); + }); + + it("rolls over into minutes", () => { + expect(formatTime(60)).toBe("1:00"); + expect(formatTime(125)).toBe("2:05"); + expect(formatTime(3661)).toBe("61:01"); + }); + + it("guards against negative and non-finite input", () => { + expect(formatTime(-12)).toBe("0:00"); + expect(formatTime(Number.NaN)).toBe("0:00"); + expect(formatTime(Number.POSITIVE_INFINITY)).toBe("0:00"); + }); +}); + +describe("formatTimeRange", () => { + it("joins both ends of a clip window", () => { + expect(formatTimeRange(12, 45.7)).toBe("0:12 - 0:45"); + }); +}); + +describe("formatPercent", () => { + it("rounds and clamps the value", () => { + expect(formatPercent(0)).toBe("0%"); + expect(formatPercent(42.4)).toBe("42%"); + expect(formatPercent(99.6)).toBe("100%"); + expect(formatPercent(-10)).toBe("0%"); + expect(formatPercent(250)).toBe("100%"); + }); +}); + +describe("formatEncoderLabel", () => { + it("maps the hardware encoders to short names", () => { + expect(formatEncoderLabel("h264_nvenc (NVIDIA GPU)")).toBe("NVENC"); + expect(formatEncoderLabel("h264_videotoolbox (Apple GPU)")).toBe("VideoToolbox"); + expect(formatEncoderLabel("h264_amf (AMD GPU)")).toBe("AMF"); + expect(formatEncoderLabel("h264_vaapi (VA-API GPU)")).toBe("VA-API"); + }); + + it("recognizes the CPU encoder", () => { + expect(formatEncoderLabel("libx264 (CPU)")).toBe("CPU"); + expect(formatEncoderLabel("libx264")).toBe("CPU"); + }); + + it("falls back to unknown for missing or unrecognized values", () => { + expect(formatEncoderLabel(null)).toBe("unknown"); + expect(formatEncoderLabel(undefined)).toBe("unknown"); + expect(formatEncoderLabel("")).toBe("unknown"); + expect(formatEncoderLabel("mystery_codec")).toBe("unknown"); + }); +}); From 9dba081c9005b394f6d3e7c3ade861dd7b41b4d3 Mon Sep 17 00:00:00 2001 From: Leonardo Bezerra Date: Mon, 26 Oct 2026 19:37:10 -0300 Subject: [PATCH 05/25] refactor(rust): make every module public in the library target `db`, `llm`, `media`, `progress` and `transcription` were private, so an integration test under `src-tauri/tests/` could not reach them through `autoshorts_lib`. Promote all of them to `pub mod`, matching `hardware` and `naming` which were already public. This only widens the library's API surface; the Tauri binary and its commands are unaffected. --- src-tauri/src/lib.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a068eef..94c74f2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,11 +1,11 @@ -mod db; +pub mod db; pub mod hardware; -mod llm; -mod media; -mod models; +pub mod llm; +pub mod media; +pub mod models; pub mod naming; -mod progress; -mod transcription; +pub mod progress; +pub mod transcription; use std::path::PathBuf; @@ -1389,7 +1389,7 @@ fn build_drawtext_filters( } #[cfg(test)] -mod tests { +pub mod tests { use super::*; #[test] From 4345513603873605a63eff5a06368f65852eff79 Mon Sep 17 00:00:00 2001 From: Leonardo Bezerra Date: Mon, 26 Oct 2026 19:37:30 -0300 Subject: [PATCH 06/25] test(rust): cover ffmpeg progress parsing and ASS filter escaping 10 unit tests for `media`: - progress parsing: microsecond values (`out_time_us`), `HH:MM:SS.mmm` clock values, and unknown/invalid keys being ignored instead of aborting the parse. - `parse_clock`: seconds-only, minute, hour forms and malformed input. - `ass_filter_arg`: escaping of `\ : ' ,` for the `subtitles` filter, falling back to the bare file name when the path has no parent, and `None` when there is no file name at all. - `absolute_path`: leaves absolute paths alone, resolves relative ones against the current directory. - `command_exists`: false for a binary that cannot exist. --- src-tauri/src/media.rs | 64 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src-tauri/src/media.rs b/src-tauri/src/media.rs index 089ad31..f6a0f42 100644 --- a/src-tauri/src/media.rs +++ b/src-tauri/src/media.rs @@ -362,3 +362,67 @@ fn run_clip_ffmpeg( // 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) } + +#[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 command_exists_rejects_missing_binary() { + assert!(!command_exists("autoshorts-definitely-missing-binary")); + } +} From 254e23e5860b1e3cedf2faad85308d7549030314 Mon Sep 17 00:00:00 2001 From: Leonardo Bezerra Date: Mon, 26 Oct 2026 19:37:30 -0300 Subject: [PATCH 07/25] test(rust): cover transcript normalization and model catalog 12 unit tests for `transcription`: - download progress: percentage parsing out of the model downloader output, plus lines without a percentage being ignored. - Whisper stdout parsing: `[00:00:01.500 --> 00:00:03.000]` timestamps and the segment-end helper. - `build_segments`: empty input yields no segments, consecutive words are joined until a break, and a sentence-ending word closes the segment. - Deepgram: normalization of the alternative/words payload, and errors for a response with no alternative or no words. - Whisper raw JSON: normalization, and an error when `segments` is absent. - the bundled model catalog is non-empty and every entry has the fields the UI needs. --- src-tauri/src/transcription.rs | 155 +++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/src-tauri/src/transcription.rs b/src-tauri/src/transcription.rs index 4d32207..9035d84 100644 --- a/src-tauri/src/transcription.rs +++ b/src-tauri/src/transcription.rs @@ -772,3 +772,158 @@ if __name__ == "__main__": .context("spawn_blocking failed")? } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn word(text: &str, start: f64, end: f64, speaker: &str) -> TranscriptWord { + TranscriptWord { + text: text.to_string(), + start, + end, + speaker: Some(speaker.to_string()), + } + } + + #[test] + fn parses_percentage_from_download_output() { + assert_eq!(parse_percentage("Downloading 45%"), Some(45.0)); + assert_eq!(parse_percentage("12.5%"), Some(12.5)); + assert_eq!(parse_percentage("100% complete"), Some(100.0)); + assert_eq!(parse_percentage("no percent here"), None); + assert_eq!(parse_percentage("%"), None); + } + + #[test] + fn parses_whisper_timestamps() { + assert_eq!(parse_timestamp_token("00:00:05.500"), Some(5.5)); + assert_eq!(parse_timestamp_token("00:01:02.500"), Some(62.5)); + assert_eq!(parse_timestamp_token("01:02"), Some(62.0)); + assert_eq!(parse_timestamp_token("5.5"), Some(5.5)); + assert_eq!(parse_timestamp_token("not-a-time"), None); + assert_eq!(parse_timestamp_token("1:2:3:4"), None); + } + + #[test] + fn parses_segment_end_from_whisper_output_line() { + let line = "[00:00:00.000 --> 00:00:05.500] hello world"; + assert_eq!(parse_segment_end_sec(line), Some(5.5)); + assert_eq!(parse_segment_end_sec("no timestamps here"), None); + } + + #[test] + fn build_segments_returns_empty_for_no_words() { + assert!(build_segments(&[]).is_empty()); + } + + #[test] + fn build_segments_joins_words_until_a_break() { + let words = vec![ + word("Hello", 0.0, 0.5, "S1"), + word("world.", 0.6, 1.0, "S1"), + word("Next", 2.0, 2.5, "S1"), + word("speaker", 2.6, 3.0, "S2"), + word("here.", 3.1, 3.5, "S2"), + ]; + + let segments = build_segments(&words); + assert_eq!(segments.len(), 3); + assert_eq!(segments[0].text, "Hello world."); + assert_eq!(segments[0].start, 0.0); + assert_eq!(segments[0].end, 1.0); + assert_eq!(segments[1].text, "Next"); + assert_eq!(segments[2].text, "speaker here."); + assert_eq!(segments[2].speaker.as_deref(), Some("S2")); + } + + #[test] + fn build_segments_breaks_after_sentence_end() { + let words = vec![ + word("Done.", 0.0, 0.5, "S1"), + word("Again", 0.6, 1.0, "S1"), + ]; + + let segments = build_segments(&words); + assert_eq!(segments.len(), 2); + assert_eq!(segments[0].text, "Done."); + assert_eq!(segments[1].text, "Again"); + } + + #[test] + fn normalizes_deepgram_response() { + let value = json!({ + "metadata": { "language": "pt", "duration": 12.5 }, + "results": { + "channels": [{ + "alternatives": [{ + "words": [ + { "word": "hello", "punctuated_word": "Hello", "start": 0.0, "end": 0.4, "speaker": 0 }, + { "word": "", "start": 0.4, "end": 0.5, "speaker": 0 }, + { "word": "world", "start": 0.5, "end": 0.9, "speaker": 1 } + ] + }] + }] + } + }); + + let transcript = normalize_deepgram(value).unwrap(); + assert_eq!(transcript.language, "pt"); + assert_eq!(transcript.duration, 12.5); + assert_eq!(transcript.words.len(), 2); + assert_eq!(transcript.words[0].text, "Hello"); + assert_eq!(transcript.speakers, vec!["S1".to_string(), "S2".to_string()]); + assert!(!transcript.segments.is_empty()); + } + + #[test] + fn deepgram_response_without_alternative_is_an_error() { + let value = json!({ "metadata": {} }); + assert!(normalize_deepgram(value).is_err()); + } + + #[test] + fn deepgram_response_without_words_is_an_error() { + let value = json!({ + "results": { "channels": [{ "alternatives": [{}] }] } + }); + assert!(normalize_deepgram(value).is_err()); + } + + #[test] + fn normalizes_whisper_raw_json() { + let raw = json!({ + "language": "es", + "segments": [{ + "start": 0.0, + "end": 1.5, + "text": " hola mundo ", + "words": [ + { "word": " hola ", "start": 0.0, "end": 0.6 }, + { "word": " mundo ", "start": 0.6, "end": 1.5 } + ] + }] + }); + + let transcript = normalize_whisper_raw_json(raw).unwrap(); + assert_eq!(transcript.language, "es"); + assert_eq!(transcript.duration, 1.5); + assert_eq!(transcript.segments.len(), 1); + assert_eq!(transcript.segments[0].text, "hola mundo"); + assert_eq!(transcript.words.len(), 2); + assert_eq!(transcript.words[1].text, "mundo"); + assert_eq!(transcript.speakers, vec!["S1".to_string()]); + } + + #[test] + fn whisper_json_without_segments_is_an_error() { + assert!(normalize_whisper_raw_json(json!({ "language": "en" })).is_err()); + } + + #[test] + fn whisper_model_catalog_is_not_empty() { + assert!(!WHISPER_MODELS.is_empty()); + assert!(list_whisper_models().len() == WHISPER_MODELS.len()); + } +} From 3e0da6f263ded51b258f76bf2bbe37e809824b50 Mon Sep 17 00:00:00 2001 From: Leonardo Bezerra Date: Mon, 26 Oct 2026 19:37:30 -0300 Subject: [PATCH 08/25] test(rust): cover candidate parsing and SSE streaming in llm 17 unit tests for `llm`: - `normalize_chat_completions_url`: base URLs, `/v1` bases, URLs that already end in `/chat/completions`, and trailing slashes. - `compact_segments`: one numbered line per segment. - `parse_candidate_json` robustness against real model output: fenced ```json blocks, a root array, a single bare candidate object, alternative key names for start/end/score, clips shorter than the minimum being dropped, clips without a hook being dropped, loose string scores, clamping out-of-range scores, truncation to 10 candidates, and a hard error on invalid JSON. - SSE: chunked `data:` lines are concatenated, non-chunk payloads and empty streams are rejected. - `extract_completion_text`: all chat-completion content shapes, and `None` when there is no content. - `body_snippet`: long bodies get truncated. - `chat_completion_payload`: JSON mode toggles `response_format`. --- src-tauri/src/llm.rs | 214 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) diff --git a/src-tauri/src/llm.rs b/src-tauri/src/llm.rs index 75b2200..3a6cc23 100644 --- a/src-tauri/src/llm.rs +++ b/src-tauri/src/llm.rs @@ -982,3 +982,217 @@ 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 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 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); + assert_eq!(with_json["response_format"]["type"], "json_object"); + assert_eq!(with_json["stream"], false); + + let without_json = chat_completion_payload("model", "prompt", false); + assert!(without_json.get("response_format").is_none()); + } +} From 367648a3ea26baa8b3d5ae421d9a12a6897a5ca3 Mon Sep 17 00:00:00 2001 From: Leonardo Bezerra Date: Mon, 26 Oct 2026 19:37:48 -0300 Subject: [PATCH 09/25] test(rust): cover caption generation, export formats and slug validation 14 unit tests for the helpers that live in `lib.rs`: - `generate_srt`: three-word chunking, text concatenation, index numbers, clipping to the requested window, and an empty result outside it. (The window in the clipping test is 0.0..4.0 because the filter is `w.start < end_sec`, exclusive.) - `format_srt_time` / `format_ass_time`: zero, sub-second rounding, minute and hour rollover, and the ASS centisecond field. - `build_ass_captions`: header (Script Info / V4+ Styles / Events), one Dialogue line per word, and escaping of `{ } \` in the text. - `project_output_slug`: keeps letters and digits, collapses separators, strips leading/trailing dashes, and never returns an empty slug. - `validate_media_extension`: accepts the supported video/audio extensions case-insensitively and rejects the rest. - `demo_transcript`: has words, segments and a matching duration. --- src-tauri/src/lib.rs | 153 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 136 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 94c74f2..b7f87c9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1389,34 +1389,153 @@ fn build_drawtext_filters( } #[cfg(test)] -pub mod tests { +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 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()); assert!(result.contains("drawtext=")); assert!(result.contains("text='HELLO WORLD'")); + if result.contains("fontfile=") { assert!(result.contains("fontfile='")); + assert!(!result.contains("\\:")); } - 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"); + + 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"); + assert!(!ass.contains("Dialogue:")); + } + + #[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 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()]); } } From 09fa1987eeb6efe3b8a89d120c11bc22f8424155 Mon Sep 17 00:00:00 2001 From: Leonardo Bezerra Date: Mon, 26 Oct 2026 19:38:03 -0300 Subject: [PATCH 10/25] test(rust): add SQLite integration tests for the database layer `src-tauri/tests/db_integration.rs` drives `autoshorts_lib::db::Database` against a real on-disk SQLite file instead of mocking it, so schema migration, foreign keys and cascades are exercised for real. 12 tests: - projects: create/read, detail starts empty and tracks the transcript, rename + status updates persist, delete cascades to candidates, clips and transcripts. - transcripts: saving a second transcript replaces the first. - candidates: `replace_candidates` ranks rows 1..n and auto-selects the top six, is idempotent when called twice, and `get_candidate_with_project` joins both tables. - clips: selection toggling by rank, and `clip updates` keeping the existing render paths when `None` is passed. - recovery: reopening the database resets clips stuck in `cutting`, and opening the same file twice reuses the existing schema. The test file carries a small `TempDir` helper (env::temp_dir + an `AtomicU64` suffix, cleaned up on `Drop`) so no extra dev-dependency is needed. --- src-tauri/tests/db_integration.rs | 364 ++++++++++++++++++++++++++++++ 1 file changed, 364 insertions(+) create mode 100644 src-tauri/tests/db_integration.rs diff --git a/src-tauri/tests/db_integration.rs b/src-tauri/tests/db_integration.rs new file mode 100644 index 0000000..80e2bd3 --- /dev/null +++ b/src-tauri/tests/db_integration.rs @@ -0,0 +1,364 @@ +//! Integration tests for the SQLite persistence layer. +//! +//! These tests exercise the real `Database` against a temporary database file, +//! so migrations, foreign keys and cascade deletes are covered end to end. + +use std::{ + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, +}; + +use autoshorts_lib::db::Database; +use autoshorts_lib::models::CandidateDraft; + +static COUNTER: AtomicU64 = AtomicU64::new(0); + +/// Temporary directory that removes itself (and the database inside) on drop. +struct TempDir { + path: PathBuf, +} + +impl TempDir { + fn new(label: &str) -> Self { + let unique = COUNTER.fetch_add(1, Ordering::SeqCst); + let path = std::env::temp_dir().join(format!( + "autoshorts-tests-{}-{}-{}", + std::process::id(), + unique, + label + )); + std::fs::create_dir_all(&path).expect("creating temp directory"); + Self { path } + } + + fn db_path(&self) -> PathBuf { + self.path.join("autoshorts.sqlite3") + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +fn open_db(dir: &TempDir) -> Database { + Database::open(&dir.db_path()).expect("opening test database") +} + +fn draft(index: usize) -> CandidateDraft { + CandidateDraft { + start: index as f64 * 10.0, + end: index as f64 * 10.0 + 30.0, + score: 1.0 - index as f64 * 0.05, + hook: format!("hook {index}"), + rationale: format!("rationale {index}"), + } +} + +fn drafts(count: usize) -> Vec { + (0..count).map(draft).collect() +} + +fn table_count(path: &Path, table: &str) -> i64 { + let conn = rusqlite::Connection::open(path).expect("opening database for assertions"); + conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row.get(0)) + .expect("counting rows") +} + +#[test] +fn creates_and_reads_projects() { + let dir = TempDir::new("projects"); + let db = open_db(&dir); + + let project = db + .create_project("/media/input.mp4", "local", "modern-box", Some(120.0)) + .expect("creating project"); + + assert_eq!(project.source_path, "/media/input.mp4"); + assert_eq!(project.status, "ingest"); + assert_eq!(project.transcription_mode, "local"); + assert_eq!(project.caption_style.as_deref(), Some("modern-box")); + assert_eq!(project.source_duration, Some(120.0)); + + let loaded = db.get_project(&project.id).expect("loading project"); + assert_eq!(loaded.id, project.id); + assert_eq!(loaded.source_path, project.source_path); + + let all = db.list_projects().expect("listing projects"); + assert_eq!(all.len(), 1); + assert_eq!(all[0].id, project.id); +} + +#[test] +fn project_detail_starts_empty_and_tracks_transcript() { + let dir = TempDir::new("detail"); + let db = open_db(&dir); + + let project = db + .create_project("/media/input.mp4", "local", "modern-box", None) + .expect("creating project"); + + let detail = db.project_detail(&project.id).expect("loading detail"); + assert!(detail.transcript.is_none()); + assert!(detail.candidates.is_empty()); + assert!(detail.clips.is_empty()); + assert!(detail.copy.is_empty()); + + db.save_transcript(&project.id, "whisper", "{\"segments\":[]}", Some("pt")) + .expect("saving transcript"); + + let detail = db.project_detail(&project.id).expect("loading detail"); + let transcript = detail.transcript.expect("transcript should exist"); + assert_eq!(transcript.engine, "whisper"); + assert_eq!(transcript.language.as_deref(), Some("pt")); +} + +#[test] +fn saving_a_transcript_replaces_the_previous_one() { + let dir = TempDir::new("transcripts"); + let db = open_db(&dir); + + let project = db + .create_project("/media/input.mp4", "local", "modern-box", None) + .expect("creating project"); + + db.save_transcript(&project.id, "deepgram", "{\"v\":1}", Some("en")) + .expect("saving first transcript"); + db.save_transcript(&project.id, "whisper", "{\"v\":2}", Some("es")) + .expect("saving second transcript"); + + let latest = db + .latest_transcript(&project.id) + .expect("loading latest transcript") + .expect("transcript should exist"); + assert_eq!(latest.engine, "whisper"); + assert_eq!(latest.raw_json, "{\"v\":2}"); + assert_eq!(latest.language.as_deref(), Some("es")); + assert_eq!(table_count(&dir.db_path(), "transcripts"), 1); +} + +#[test] +fn replace_candidates_ranks_and_auto_selects_six() { + let dir = TempDir::new("candidates"); + let db = open_db(&dir); + + let project = db + .create_project("/media/input.mp4", "local", "modern-box", None) + .expect("creating project"); + + let created = db + .replace_candidates(&project.id, &drafts(8)) + .expect("replacing candidates"); + assert_eq!(created.len(), 8); + + let candidates = db.list_candidates(&project.id).expect("listing candidates"); + assert_eq!(candidates.len(), 8); + for (index, candidate) in candidates.iter().enumerate() { + assert_eq!(candidate.rank, index as i64 + 1); + assert_eq!(candidate.selected, index < 6); + assert_eq!(candidate.project_id, project.id); + } + + let detail = db.project_detail(&project.id).expect("loading detail"); + assert_eq!(detail.clips.len(), 8); + assert!(detail.clips.iter().all(|clip| clip.status == "pending")); +} + +#[test] +fn replace_candidates_is_idempotent() { + let dir = TempDir::new("replace"); + let db = open_db(&dir); + + let project = db + .create_project("/media/input.mp4", "local", "modern-box", None) + .expect("creating project"); + + db.replace_candidates(&project.id, &drafts(4)) + .expect("first replacement"); + db.replace_candidates(&project.id, &drafts(2)) + .expect("second replacement"); + + assert_eq!(db.list_candidates(&project.id).unwrap().len(), 2); + assert_eq!(table_count(&dir.db_path(), "candidates"), 2); + assert_eq!(table_count(&dir.db_path(), "clips"), 2); +} + +#[test] +fn selected_clip_count_toggles_selection_by_rank() { + let dir = TempDir::new("selection"); + let db = open_db(&dir); + + let project = db + .create_project("/media/input.mp4", "local", "modern-box", None) + .expect("creating project"); + + db.replace_candidates(&project.id, &drafts(5)) + .expect("replacing candidates"); + + let selected = db + .set_selected_clip_count(&project.id, 2) + .expect("selecting two clips"); + assert_eq!(selected.iter().filter(|candidate| candidate.selected).count(), 2); + assert!(selected[0].selected); + assert!(selected[1].selected); + assert!(!selected[2].selected); + + let selected = db + .set_selected_clip_count(&project.id, 4) + .expect("selecting four clips"); + assert_eq!(selected.iter().filter(|candidate| candidate.selected).count(), 4); +} + +#[test] +fn clip_updates_keep_existing_paths_when_none_is_passed() { + let dir = TempDir::new("clips"); + let db = open_db(&dir); + + let project = db + .create_project("/media/input.mp4", "local", "modern-box", None) + .expect("creating project"); + let candidate = db + .replace_candidates(&project.id, &drafts(1)) + .expect("replacing candidates") + .remove(0); + + db.update_clip_for_candidate( + &candidate.id, + "rendered", + Some("/out/clip.mp4"), + Some("/out/clip.ass"), + None, + ) + .expect("updating clip"); + + db.update_clip_for_candidate(&candidate.id, "failed", None, None, Some("ffmpeg died")) + .expect("updating clip status"); + + let detail = db.project_detail(&project.id).expect("loading detail"); + let clip = &detail.clips[0]; + assert_eq!(clip.status, "failed"); + assert_eq!(clip.output_path.as_deref(), Some("/out/clip.mp4")); + assert_eq!(clip.caption_ass_path.as_deref(), Some("/out/clip.ass")); + assert_eq!(clip.render_log.as_deref(), Some("ffmpeg died")); +} + +#[test] +fn reopening_the_database_resets_interrupted_cutting_clips() { + let dir = TempDir::new("reopen"); + let project_id; + let candidate_id; + + { + let db = open_db(&dir); + let project = db + .create_project("/media/input.mp4", "local", "modern-box", None) + .expect("creating project"); + let candidate = db + .replace_candidates(&project.id, &drafts(1)) + .expect("replacing candidates") + .remove(0); + + db.update_clip_for_candidate(&candidate.id, "cutting", None, None, None) + .expect("marking clip as cutting"); + + project_id = project.id; + candidate_id = candidate.id; + } + + let db = open_db(&dir); + let detail = db.project_detail(&project_id).expect("loading detail"); + assert_eq!(detail.clips.len(), 1); + assert_eq!(detail.clips[0].candidate_id, candidate_id); + assert_eq!(detail.clips[0].status, "pending"); +} + +#[test] +fn get_candidate_with_project_joins_both_rows() { + let dir = TempDir::new("join"); + let db = open_db(&dir); + + let project = db + .create_project("/media/input.mp4", "local", "modern-box", Some(90.0)) + .expect("creating project"); + let candidate = db + .replace_candidates(&project.id, &drafts(1)) + .expect("replacing candidates") + .remove(0); + + let (loaded_candidate, loaded_project) = db + .get_candidate_with_project(&candidate.id) + .expect("loading candidate with project"); + assert_eq!(loaded_candidate.id, candidate.id); + assert_eq!(loaded_project.id, project.id); + assert_eq!(loaded_project.source_duration, Some(90.0)); +} + +#[test] +fn rename_and_status_updates_are_persisted() { + let dir = TempDir::new("updates"); + let db = open_db(&dir); + + let project = db + .create_project("/media/input.mp4", "local", "modern-box", None) + .expect("creating project"); + + db.rename_project(&project.id, "My Episode") + .expect("renaming project"); + db.update_project_status(&project.id, "transcribing", Some(300.5)) + .expect("updating status"); + + let loaded = db.get_project(&project.id).expect("loading project"); + assert_eq!(loaded.name.as_deref(), Some("My Episode")); + assert_eq!(loaded.status, "transcribing"); + assert_eq!(loaded.source_duration, Some(300.5)); + + db.update_project_status(&project.id, "ready", None) + .expect("updating status without duration"); + let loaded = db.get_project(&project.id).expect("loading project"); + assert_eq!(loaded.status, "ready"); + assert_eq!(loaded.source_duration, Some(300.5)); +} + +#[test] +fn delete_project_cascades_candidates_clips_and_transcripts() { + let dir = TempDir::new("delete"); + let db = open_db(&dir); + + let project = db + .create_project("/media/input.mp4", "local", "modern-box", None) + .expect("creating project"); + db.save_transcript(&project.id, "whisper", "{}", None) + .expect("saving transcript"); + db.replace_candidates(&project.id, &drafts(3)) + .expect("replacing candidates"); + + db.delete_project(&project.id).expect("deleting project"); + + assert!(db.get_project(&project.id).is_err()); + assert!(db.list_projects().unwrap().is_empty()); + assert!(db.latest_transcript(&project.id).unwrap().is_none()); + assert!(db.list_candidates(&project.id).unwrap().is_empty()); + + let path = dir.db_path(); + assert_eq!(table_count(&path, "projects"), 0); + assert_eq!(table_count(&path, "transcripts"), 0); + assert_eq!(table_count(&path, "candidates"), 0); + assert_eq!(table_count(&path, "clips"), 0); +} + +#[test] +fn opening_the_same_database_twice_reuses_the_schema() { + let dir = TempDir::new("schema"); + let project_id = { + let db = open_db(&dir); + db.create_project("/media/input.mp4", "local", "modern-box", None) + .expect("creating project") + .id + }; + + let db = open_db(&dir); + let projects = db.list_projects().expect("listing projects"); + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].id, project_id); +} From e8468ce8d7b2bf4680ef1a07abc58e4d88a22f20 Mon Sep 17 00:00:00 2001 From: Leonardo Bezerra Date: Mon, 26 Oct 2026 19:38:18 -0300 Subject: [PATCH 11/25] ci: gate the release on frontend and Rust tests New `test` job on ubuntu-22.04 that runs before anything is published: 1. checkout, Node 20, stable Rust toolchain, `Swatinem/rust-cache` scoped to `src-tauri`. 2. Linux system dependencies (webkit2gtk, gtk, libappindicator, ...) needed to compile the Tauri crate. 3. `npm ci`, `npm run build` (the Rust build embeds `dist/`, so it has to exist first), `npm test`. 4. `cargo test --all-targets` for the unit and integration suites. `release`, `android` and `ios` now declare `needs: test`, so a red test suite stops the whole matrix instead of shipping broken binaries. Also fixes the `matrix` indentation under `release`. --- .github/workflows/release.yml | 41 +++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 85d1341..ba7864a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,8 +11,47 @@ on: - '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 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - 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: if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + needs: test permissions: contents: write strategy: @@ -54,6 +93,7 @@ jobs: prerelease: false android: + needs: test if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') permissions: @@ -115,6 +155,7 @@ jobs: path: artifacts/android/* ios: + needs: test if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') permissions: From edc37cda1c35fa4b42211ebaaf20544a8c20ebe3 Mon Sep 17 00:00:00 2001 From: Leonardo Bezerra Date: Mon, 26 Oct 2026 19:38:18 -0300 Subject: [PATCH 12/25] docs: document how to run the test suites Adds "4. Run the Tests" to the README with the commands and a table of every test file: - `npm test` / `npm run test:watch` for Vitest, - `npm run test:rust` (or `cargo test`) for the Rust suites, - the three frontend files and the five Rust files with what each covers. Renumbers "Multi-Platform Build" to 5. --- README.md | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 762c7cb..1332106 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,38 @@ npm run tauri:build ``` The output installers will be built under `src-tauri/target/release/bundle/`. -### 4. Multi-Platform Build (Windows + Linux + Android + iOS) +### 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 + Android + iOS) One command attempts every platform the current host can actually target: ```bash npm run build:all From 6286e6a8661b82e78ad2f5858787e9dc232b432e Mon Sep 17 00:00:00 2001 From: Leonardo Bezerra Date: Mon, 26 Oct 2026 19:39:19 -0300 Subject: [PATCH 13/25] refactor(rust): always re-render clips to the same file name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `naming::unique_path` appended `-2`, `-3`, … whenever the target file already existed, so re-cutting a clip after editing its hook left the old render behind and the project folder grew one duplicate per attempt. Clip names are already deterministic (project slug + rank + hook + timestamps), so the render path is now `clips_dir.join(file_name)`: a re-cut overwrites the previous render instead of accumulating copies. Removes `unique_path` and its two tests from `naming`. --- src-tauri/src/lib.rs | 4 ++- src-tauri/src/naming.rs | 62 ----------------------------------------- 2 files changed, 3 insertions(+), 63 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b7f87c9..7b0207e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -642,7 +642,9 @@ async fn render_flat_clip_for_candidate( candidate.start_sec, candidate.end_sec, ); - naming::unique_path(&clips_dir, &file_name) + // 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 diff --git a/src-tauri/src/naming.rs b/src-tauri/src/naming.rs index 3348337..688cf0c 100644 --- a/src-tauri/src/naming.rs +++ b/src-tauri/src/naming.rs @@ -5,8 +5,6 @@ //! rank and the hook line, then sanitized so they stay valid on Linux, macOS //! and Windows (no reserved characters, no trailing dots or spaces). -use std::path::{Path, PathBuf}; - /// Longest hook fragment kept in a file name. Enough to be descriptive while /// staying well below the 255-byte limit of common file systems. const MAX_HOOK_LEN: usize = 60; @@ -121,36 +119,6 @@ pub fn clip_file_name( format!("{project}_{:02}_{suffix}.mp4", rank.max(1)) } -/// Return `dir/file_name`, appending `-2`, `-3`, … when the file exists. -/// -/// Re-cutting a clip overwrites its previous render only when the name still -/// matches; a renamed hook therefore produces a new file instead of silently -/// replacing the old one. -pub fn unique_path(dir: &Path, file_name: &str) -> PathBuf { - let candidate = dir.join(file_name); - if !candidate.exists() { - return candidate; - } - - let path = Path::new(file_name); - let stem = path - .file_stem() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| "clip".to_string()); - let extension = path - .extension() - .map(|e| e.to_string_lossy().to_string()) - .unwrap_or_else(|| "mp4".to_string()); - - for index in 2..1000 { - let next = dir.join(format!("{stem}-{index}.{extension}")); - if !next.exists() { - return next; - } - } - candidate -} - #[cfg(test)] mod tests { use super::*; @@ -210,34 +178,4 @@ mod tests { let name = clip_file_name("", 2, "hook", 0.0, 30.0); assert_eq!(name, "clip_02_hook.mp4"); } - - #[test] - fn unique_path_returns_plain_name_when_free() { - let dir = std::env::temp_dir().join(format!("autoshorts-naming-{}", uuid_like())); - std::fs::create_dir_all(&dir).unwrap(); - let path = unique_path(&dir, "clip.mp4"); - assert_eq!(path, dir.join("clip.mp4")); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[test] - fn unique_path_appends_counter_when_taken() { - let dir = std::env::temp_dir().join(format!("autoshorts-naming-{}", uuid_like())); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("clip.mp4"), b"x").unwrap(); - let path = unique_path(&dir, "clip.mp4"); - assert_eq!(path, dir.join("clip-2.mp4")); - std::fs::write(dir.join("clip-2.mp4"), b"x").unwrap(); - assert_eq!(unique_path(&dir, "clip.mp4"), dir.join("clip-3.mp4")); - std::fs::remove_dir_all(&dir).unwrap(); - } - - fn uuid_like() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - format!("{nanos}") - } } From c0c3d112045b583a6928b1a24b90c42afe4b028f Mon Sep 17 00:00:00 2001 From: Leonardo Bezerra Date: Mon, 26 Oct 2026 19:43:39 -0300 Subject: [PATCH 14/25] lib --- src-tauri/src/lib.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7b0207e..1f69ad3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -635,8 +635,16 @@ async fn render_flat_clip_for_candidate( 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_output_slug(&project), + &project_label, candidate.rank, &candidate.hook, candidate.start_sec, From f6a1a6570850a257981b7f9b00776064a12a98db Mon Sep 17 00:00:00 2001 From: Leonardo Bezerra Date: Mon, 26 Oct 2026 19:55:40 -0300 Subject: [PATCH 15/25] FIX TESTE --- package.json | 4 ++-- scripts/test.mjs | 44 ++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 2 +- 3 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 scripts/test.mjs diff --git a/package.json b/package.json index e97f282..55c4856 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "build:windows": "node scripts/build-all.mjs --only=windows", "build:android": "node scripts/build-all.mjs --only=android", "build:ios": "node scripts/build-all.mjs --only=ios", - "test": "vitest run", - "test:watch": "vitest", + "test": "node scripts/test.mjs", + "test:watch": "node scripts/test.mjs watch", "test:rust": "cargo test --manifest-path src-tauri/Cargo.toml" }, "dependencies": { 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/tsconfig.json b/tsconfig.json index df11947..596336d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ "strict": true, "forceConsistentCasingInFileNames": true, "module": "ESNext", - "moduleResolution": "Node", + "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, From 892f1b8b072dd23e9e410129215b0f7c3d4fe86b Mon Sep 17 00:00:00 2001 From: Leonardo Bezerra Date: Mon, 26 Oct 2026 20:01:22 -0300 Subject: [PATCH 16/25] Fix teste --- .github/workflows/release.yml | 10 ++++++---- package-lock.json | 3 +++ package.json | 3 +++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba7864a..28af0a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,10 +17,12 @@ jobs: - 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: 20 + node-version: 22 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -66,7 +68,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -106,7 +108,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -168,7 +170,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 - name: Install Rust uses: dtolnay/rust-toolchain@stable diff --git a/package-lock.json b/package-lock.json index 2aee930..9427a67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,9 @@ "typescript": "^5.7.3", "vite": "^6.1.0", "vitest": "^5.0.5" + }, + "engines": { + "node": ">=22.19.0" } }, "node_modules/@adobe/css-tools": { diff --git a/package.json b/package.json index 55c4856..98615b8 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "version": "0.1.3", "private": true, "type": "module", + "engines": { + "node": ">=22.19.0" + }, "scripts": { "dev": "vite --host 127.0.0.1 --port 1420", "build": "tsc && vite build", From 48ff0da02cf94565fe35efaae5a1fbebc75ec0c0 Mon Sep 17 00:00:00 2001 From: Leonardo Bezerra Date: Mon, 26 Oct 2026 23:20:36 -0300 Subject: [PATCH 17/25] Edit Mode --- src-tauri/Cargo.lock | 7 + src-tauri/Cargo.toml | 4 +- src-tauri/src/db.rs | 130 +++- src-tauri/src/lib.rs | 878 ++++++++++++++++++++---- src-tauri/src/llm.rs | 641 +++++++++++++++--- src-tauri/src/media.rs | 289 +++++++- src-tauri/src/media_server.rs | 351 ++++++++++ src-tauri/src/models.rs | 202 ++++++ src-tauri/tauri.conf.json | 6 +- src-tauri/tests/db_integration.rs | 172 ++++- src/App.tsx | 122 +++- src/components/ClipEditor.test.tsx | 445 ++++++++++++ src/components/ClipEditor.tsx | 1012 ++++++++++++++++++++++++++++ src/lib/clipEditor.test.ts | 364 ++++++++++ src/lib/clipEditor.ts | 597 ++++++++++++++++ src/styles.css | 727 +++++++++++++++++++- 16 files changed, 5699 insertions(+), 248 deletions(-) create mode 100644 src-tauri/src/media_server.rs create mode 100644 src/components/ClipEditor.test.tsx create mode 100644 src/components/ClipEditor.tsx create mode 100644 src/lib/clipEditor.test.ts create mode 100644 src/lib/clipEditor.ts 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/src/db.rs b/src-tauri/src/db.rs index 8f3be34..ebea036 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,22 @@ 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", []); // 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. @@ -175,6 +187,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, @@ -182,6 +195,7 @@ impl Database { candidates, clips, copy, + clip_edits, }) } @@ -399,6 +413,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( @@ -446,6 +491,75 @@ 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 + 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 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, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + 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, + 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, + 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 + 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])?; @@ -477,6 +591,20 @@ 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)?; + 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()), + }) +} + fn candidate_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { let selected: i64 = row.get(8)?; Ok(Candidate { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1f69ad3..11f411f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,19 +2,23 @@ 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 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, }; @@ -30,6 +34,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] @@ -611,6 +635,306 @@ 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, @@ -624,6 +948,10 @@ 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)?; @@ -669,30 +997,60 @@ async fn render_flat_clip_for_candidate( .map(|probe| probe.has_video) .unwrap_or(true); - if let Ok(Some(transcript_record)) = db.latest_transcript(&project.id) { - if let Ok(normalized) = serde_json::from_str::(&transcript_record.raw_json) { - let project_data_dir = data_dir.join("projects").join(&project.id); - let _ = std::fs::create_dir_all(&project_data_dir); - - // Plain-text sidecar kept for the UI / upload metadata. - let srt_content = generate_srt(&normalized.words, candidate.start_sec, candidate.end_sec); - 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 now - // evaluates a single `ass` filter per frame instead of dozens. - let style = project.caption_style.as_deref().unwrap_or("modern-box"); - let ass_content = build_ass_captions( - &normalized.words, - candidate.start_sec, - candidate.end_sec, - style, - ); - 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); + 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); + + // Captions authored in the editor win; otherwise derive them from the + // latest transcript exactly like before. + let ass_content = if !edit.captions.is_empty() { + Some(build_ass_captions_from_cues( + &edit.captions, + candidate.start_sec, + candidate.end_sec, + &caption_style, + edit.caption_override.as_ref(), + )) + } else { + match db.latest_transcript(&project.id) { + Ok(Some(transcript_record)) => { + serde_json::from_str::(&transcript_record.raw_json) + .ok() + .map(|normalized| { + // Plain-text sidecar kept for the UI / upload metadata. + let srt_content = generate_srt( + &normalized.words, + candidate.start_sec, + candidate.end_sec, + ); + 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. + build_ass_captions( + &normalized.words, + candidate.start_sec, + candidate.end_sec, + &caption_style, + edit.caption_override.as_ref(), + ) + }) } + _ => None, + } + }; + + 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); } } @@ -703,6 +1061,9 @@ async fn render_flat_clip_for_candidate( output_path: &output_path, has_video, ass_path: caption_ass_path.as_deref(), + aspect_ratio: &edit.aspect_ratio, + crop_x: edit.crop_x, + crop_y: edit.crop_y, }; match media::render_flat_clip(&request, |percentage| { @@ -804,7 +1165,14 @@ pub fn run() { 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")?; 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![ @@ -822,6 +1190,13 @@ 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, @@ -1015,8 +1390,20 @@ 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 } fn generate_srt(words: &[TranscriptWord], start_sec: f64, end_sec: f64) -> String { @@ -1088,84 +1475,149 @@ fn caption_font_name() -> &'static str { } } +/// Resolved ASS style for the caption line, after preset + user overrides. struct AssCaptionStyle { - primary_colour: &'static str, - outline_colour: &'static str, - back_colour: &'static str, + 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" => AssCaptionStyle { - primary_colour: "&H0000FFFF", // yellow - outline_colour: "&H00000000", - back_colour: "&H80000000", - border_style: 1, - outline: 5.0, - shadow: 0.0, - margin_v: 592, - }, - "minimal-shadow" => AssCaptionStyle { - primary_colour: "&H00FFFFFF", - outline_colour: "&H00000000", - back_colour: "&H80000000", - border_style: 1, - outline: 0.0, - shadow: 3.0, - margin_v: 496, - }, - "vibrant-cyan" => AssCaptionStyle { - primary_colour: "&H00FFFF00", - outline_colour: "&H00000000", - back_colour: "&H80000000", - border_style: 1, - outline: 0.0, - shadow: 3.0, - margin_v: 496, - }, - "vibrant-yellow-box" => AssCaptionStyle { - primary_colour: "&H00000000", // black text on yellow box - outline_colour: "&H00000000", - back_colour: "&H1F00FFFF", - border_style: 3, - outline: 10.0, - shadow: 0.0, - margin_v: 458, - }, - "vibrant-green" => AssCaptionStyle { - primary_colour: "&H0014FF39", - outline_colour: "&H00000000", - back_colour: "&H80000000", - border_style: 1, - outline: 4.0, - shadow: 2.0, - margin_v: 496, - }, - "vibrant-red" => AssCaptionStyle { - primary_colour: "&H00303BFF", - outline_colour: "&H00000000", - back_colour: "&H80000000", - border_style: 1, - outline: 4.0, - shadow: 2.0, - margin_v: 496, - }, - _ => AssCaptionStyle { - primary_colour: "&H00FFFFFF", - outline_colour: "&H00000000", - back_colour: "&H50000000", - border_style: 3, - outline: 10.0, - shadow: 0.0, - margin_v: 458, - }, + "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 { @@ -1184,15 +1636,91 @@ fn build_ass_captions( 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 = ass_caption_style(caption_style); - let font = caption_font_name(); + 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("PlayResX: 1080\n"); - ass.push_str("PlayResY: 1920\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"); @@ -1203,52 +1731,34 @@ fn build_ass_captions( BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n", ); ass.push_str(&format!( - "Style: Default,{font},81,{primary},&H000000FF,{outline_c},{back},-1,0,0,0,100,100,0,0,\ - {border},{outline_w},{shadow},2,40,40,{margin_v},1\n\n", + "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"); - - 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.to_uppercase()) - .collect::>() - .join(" "); - 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) - )); - } - 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)] @@ -1426,6 +1936,15 @@ mod tests { } } + #[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![word("Hello", 0.0, 1.0), word("world", 1.0, 2.0)]; @@ -1488,7 +2007,7 @@ mod tests { #[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"); + let ass = build_ass_captions(&words, 0.0, 5.0, "classic-outline", None); assert!(ass.contains("[Script Info]")); assert!(ass.contains("PlayResX: 1080")); @@ -1499,10 +2018,59 @@ mod tests { #[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"); + 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"); @@ -1510,6 +2078,54 @@ mod tests { 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!( diff --git a/src-tauri/src/llm.rs b/src-tauri/src/llm.rs index 3a6cc23..8659390 100644 --- a/src-tauri/src/llm.rs +++ b/src-tauri/src/llm.rs @@ -1,6 +1,7 @@ use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; use serde_json::json; +use std::time::Duration; use crate::models::{CandidateDraft, NormalizedTranscript, TranscriptSegment}; @@ -34,6 +35,26 @@ pub fn normalize_chat_completions_url(base_url: &str) -> String { } } +/// 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, @@ -276,34 +297,51 @@ fn body_snippet(body: &str) -> String { /// 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 collected = String::new(); + let mut content = String::new(); + let mut reasoning = String::new(); + let mut saw_chunk = false; for line in body.lines() { let line = line.trim(); - if !line.starts_with("data:") { + let Some(payload) = line.strip_prefix("data:") else { continue; - } - let payload = line["data:".len()..].trim(); + }; + let payload = payload.trim(); if payload.is_empty() || payload == "[DONE]" { continue; } - let chunk: serde_json::Value = serde_json::from_str(payload).ok()?; + 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") { - return None; + continue; } + saw_chunk = true; if let Some(delta) = chunk .pointer("/choices/0/delta/content") .and_then(|c| c.as_str()) { - collected.push_str(delta); + 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()) { - collected.push_str(text); + content.push_str(text); } } - if collected.trim().is_empty() { - None + if !saw_chunk { + return None; + } + if !content.trim().is_empty() { + Some(content) + } else if !reasoning.trim().is_empty() { + Some(reasoning) } else { - Some(collected) + None } } @@ -317,25 +355,34 @@ async fn read_chat_completion_text( .text() .await .with_context(|| format!("reading {provider_label} response body"))?; - let res_body: ChatCompletionResponse = serde_json::from_str(&body).with_context(|| { - format!( - "parsing {provider_label} response: {}", - body_snippet(&body) - ) - })?; - if let Some(text) = extract_completion_text(&res_body) { - return Ok(text); + 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); } - Err(anyhow!( - "{provider_label} response did not include choices content: {}", - body_snippet(&body) - )) + 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) -> serde_json::Value { +fn chat_completion_payload( + model: &str, + prompt: &str, + json_mode: bool, + stream: bool, +) -> serde_json::Value { let mut payload = json!({ "model": model, "messages": [ @@ -345,8 +392,7 @@ fn chat_completion_payload(model: &str, prompt: &str, json_mode: bool) -> serde_ } ], "temperature": 0.2, - // Some [OI]-compatible servers (LM Studio, vLLM) default to streaming; force a full response. - "stream": false, + "stream": stream, }); if json_mode { payload["response_format"] = json!({ "type": "json_object" }); @@ -361,11 +407,12 @@ async fn send_chat_completion( 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)); + .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}")); } @@ -375,6 +422,112 @@ async fn send_chat_completion( .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, @@ -385,38 +538,51 @@ async fn detect_candidates_openai_style( ) -> Result> { let segments = compact_segments(&transcript.segments); let prompt = candidate_prompt(&segments); - let url = normalize_chat_completions_url(base_url); - let client = reqwest::Client::new(); - - let mut response = send_chat_completion( - &client, - &url, - api_key, - model, - &prompt, - true, - provider_label, - ) - .await?; + 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; + } - // Some OpenAI-compatible servers (LM Studio, llama.cpp, vLLM) reject `response_format`. - if matches!(response.status().as_u16(), 400 | 422) { - response = send_chat_completion( - &client, - &url, - api_key, - model, - &prompt, - false, - provider_label, - ) - .await?; + 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(); - return Err(anyhow!("{provider_label} request failed ({status}): {body}")); + 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?; @@ -828,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() @@ -836,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") { @@ -1021,6 +1278,25 @@ mod tests { ); } + #[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![ @@ -1119,6 +1395,98 @@ mod tests { 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!( @@ -1188,11 +1556,90 @@ mod tests { #[test] fn chat_completion_payload_toggles_json_mode() { - let with_json = chat_completion_payload("model", "prompt", true); + 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); + 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 f6a0f42..2e5839b 100644 --- a/src-tauri/src/media.rs +++ b/src-tauri/src/media.rs @@ -202,7 +202,208 @@ pub fn extract_audio( Ok(output_path) } -/// Options for rendering a single vertical clip. +/// 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(&args) + .output() + .context("running ffmpeg for a thumbnail")?; + if !output.status.success() { + return Err(anyhow!( + "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, @@ -212,13 +413,14 @@ pub struct ClipRenderRequest<'a> { pub has_video: 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, } -/// Output resolution for every clip. Normalizing here keeps the encoder from -/// working on 4K frames when the source is larger than a phone screen. -const CLIP_WIDTH: i64 = 1080; -const CLIP_HEIGHT: i64 = 1920; - fn absolute_path(path: &Path) -> PathBuf { if path.is_absolute() { path.to_path_buf() @@ -325,10 +527,11 @@ fn run_clip_ffmpeg( let mut ass_dir: Option = None; if request.has_video { - let mut filters = vec![ - "crop=w='2*trunc(min(iw,ih*9/16)/2)':h='2*trunc(min(ih,iw*16/9)/2)'".to_string(), - format!("scale={CLIP_WIDTH}:{CLIP_HEIGHT}:flags=fast_bilinear"), - ]; + let mut filters = vec![crop_and_scale_filter( + request.aspect_ratio, + request.crop_x, + request.crop_y, + )]; if let Some(ass_path) = request.ass_path { let ass_path = absolute_path(ass_path); @@ -421,6 +624,72 @@ mod tests { 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. +//! +//! `
)} + + {editingCandidateId && ( + setEditingCandidateId(null)} + onSaved={handleClipEditSaved} + onBulkSaved={handleBulkEditsSaved} + onRangeChanged={handleRangeChanged} + onRender={() => { + const candidateId = editingCandidateId; + if (candidateId) void cutCandidate(candidateId); + }} + /> + )} ); } diff --git a/src/components/ClipEditor.test.tsx b/src/components/ClipEditor.test.tsx new file mode 100644 index 0000000..75e1c23 --- /dev/null +++ b/src/components/ClipEditor.test.tsx @@ -0,0 +1,445 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; + +const invokeMock = vi.fn(); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => invokeMock(...args), + convertFileSrc: (path: string) => `asset://localhost/${encodeURIComponent(path)}`, +})); + +import { ClipEditor } from "./ClipEditor"; +import type { ClipEditor as ClipEditorData } from "../lib/clipEditor"; + +function buildPayload(overrides: Partial = {}): ClipEditorData { + return { + candidate: { + id: "cand-1", + projectId: "project-1", + startSec: 10, + endSec: 20, + score: 0.9, + hook: "The hook", + rationale: "Why", + rank: 1, + selected: true, + }, + sourcePath: "/media/source.mp4", + edit: { + candidateId: "cand-1", + aspectRatio: "9:16", + cropX: 0.5, + cropY: 0.5, + captions: [], + captionStyle: null, + }, + cues: [ + { start: 5, end: 9.5, text: "before" }, + { start: 10.5, end: 12.5, text: "hello there" }, + { start: 18, end: 22, text: "after" }, + ], + captionStyle: "modern-box", + sourceWidth: 1920, + sourceHeight: 1080, + ...overrides, + }; +} + +function renderEditor(payload: ClipEditorData = buildPayload()) { + invokeMock.mockImplementation(async (command: string, args?: { edit?: unknown }) => { + if (command === "get_clip_editor") return payload; + if (command === "save_clip_edit") return args?.edit ?? null; + if (command === "prepare_project_preview") { + return { + path: "/cache/previews/source.webm", + url: "http://127.0.0.1:51999/media/abc123/source.webm", + startSec: 0, + endSec: 0, + }; + } + return null; + }); + + const onClose = vi.fn(); + const onSaved = vi.fn(); + const onRender = vi.fn(); + + const utils = render( + , + ); + + return { ...utils, onClose, onSaved, onRender }; +} + +describe("ClipEditor", () => { + beforeEach(() => { + invokeMock.mockReset(); + }); + + it("loads the editor and renders the source video inside the crop stage", async () => { + // WebM-family codecs play directly, so this test exercises the raw source. + renderEditor(buildPayload({ sourceVideoCodec: "vp8", sourceAudioCodec: "opus" })); + + const dialog = await screen.findByRole("dialog"); + expect(dialog).toHaveAttribute("aria-modal", "true"); + await waitFor(() => expect(invokeMock).toHaveBeenCalledWith("get_clip_editor", { candidateId: "cand-1" })); + + const video = (await screen.findByTestId("clip-editor-video")) as HTMLVideoElement; + expect(video.getAttribute("src")).toContain("source.mp4"); + // The stage keeps the output ratio; the video fills it without stretching. + expect(video.style.objectPosition).toBe("50% 50%"); + expect(video.style.width).toBe(""); + expect(video.style.height).toBe(""); + + const stage = screen.getByLabelText("Crop preview"); + expect(stage.style.aspectRatio).toBe("1080 / 1920"); + expect(stage.style.maxWidth).toBe("calc(29.25vh)"); + }); + + it("switches the output aspect ratio", async () => { + renderEditor(); + await screen.findByTestId("clip-editor-video"); + + const square = screen.getByRole("button", { name: "1:1" }); + fireEvent.click(square); + expect(square).toHaveAttribute("aria-pressed", "true"); + + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(invokeMock).toHaveBeenCalledWith( + "save_clip_edit", + expect.objectContaining({ + edit: expect.objectContaining({ candidateId: "cand-1", aspectRatio: "1:1" }), + }), + ); + }); + }); + + it("moves the crop with the sliders and resets it to the center", async () => { + renderEditor(); + await screen.findByTestId("clip-editor-video"); + + const horizontal = screen.getByLabelText("Horizontal crop") as HTMLInputElement; + const vertical = screen.getByLabelText("Vertical crop") as HTMLInputElement; + fireEvent.change(horizontal, { target: { value: "0.2" } }); + fireEvent.change(vertical, { target: { value: "0.9" } }); + expect(horizontal.value).toBe("0.2"); + expect(vertical.value).toBe("0.9"); + + fireEvent.click(screen.getByRole("button", { name: /Center/ })); + expect(horizontal.value).toBe("0.5"); + expect(vertical.value).toBe("0.5"); + + fireEvent.change(horizontal, { target: { value: "0.75" } }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(invokeMock).toHaveBeenCalledWith( + "save_clip_edit", + expect.objectContaining({ edit: expect.objectContaining({ cropX: 0.75, cropY: 0.5 }) }), + ); + }); + }); + + it("drags inside the preview to move the crop window", async () => { + renderEditor(); + const video = await screen.findByTestId("clip-editor-video"); + + const stage = screen.getByLabelText("Crop preview"); + Object.defineProperty(stage, "getBoundingClientRect", { + configurable: true, + value: () => ({ width: 200, height: 200, left: 0, top: 0, right: 200, bottom: 200, x: 0, y: 0 }), + }); + + fireEvent.pointerDown(stage, { clientX: 100, clientY: 100, pointerId: 1 }); + fireEvent.pointerMove(stage, { clientX: 140, clientY: 80, pointerId: 1 }); + fireEvent.pointerUp(stage, { clientX: 140, clientY: 80, pointerId: 1 }); + + // Dragging right by 20% of the stage reveals the left edge: cropX 0.5 -> 0.3. + const horizontal = screen.getByLabelText("Horizontal crop") as HTMLInputElement; + expect(Number(horizontal.value)).toBeCloseTo(0.3, 5); + const vertical = screen.getByLabelText("Vertical crop") as HTMLInputElement; + expect(Number(vertical.value)).toBeCloseTo(0.6, 5); + + // Video crop follows the new offset through `object-position`. + expect(video.style.objectPosition).toBe("30% 60%"); + }); + + it("shows the active caption as an uppercase overlay", async () => { + // Direct playback keeps source time == player time for this assertion. + renderEditor(buildPayload({ sourceVideoCodec: "vp8", sourceAudioCodec: "opus" })); + const video = (await screen.findByTestId("clip-editor-video")) as HTMLVideoElement; + + Object.defineProperty(video, "currentTime", { configurable: true, value: 11, writable: true }); + fireEvent.timeUpdate(video); + + const overlay = await screen.findByTestId("clip-editor-caption"); + expect(overlay).toHaveTextContent("HELLO THERE"); + expect(overlay).toHaveAttribute("data-style", "modern-box"); + }); + + it("lists only cues overlapping the clip and lets them be edited", async () => { + renderEditor(); + await screen.findByTestId("clip-editor-video"); + + expect(screen.queryByDisplayValue("before")).toBeNull(); + expect(screen.getByDisplayValue("hello there")).toBeInTheDocument(); + expect(screen.getByDisplayValue("after")).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText("Caption 2 text"), { target: { value: "edited" } }); + fireEvent.change(screen.getByLabelText("Caption 2 start"), { target: { value: "16" } }); + fireEvent.change(screen.getByLabelText("Caption 2 end"), { target: { value: "19" } }); + + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(invokeMock).toHaveBeenCalledWith( + "save_clip_edit", + expect.objectContaining({ + edit: expect.objectContaining({ + captions: expect.arrayContaining([ + { start: 5, end: 9.5, text: "before" }, + { start: 16, end: 19, text: "edited" }, + { start: 18, end: 22, text: "after" }, + ]), + }), + }), + ); + }); + }); + + it("flags an end time that is not after the start time", async () => { + renderEditor(); + await screen.findByTestId("clip-editor-video"); + + // Keep both times inside the clip window so the row stays mounted. + fireEvent.change(screen.getByLabelText("Caption 2 start"), { target: { value: "19" } }); + const end = screen.getByLabelText("Caption 2 end"); + fireEvent.change(end, { target: { value: "18" } }); + expect(screen.getByLabelText("Caption 2 end")).toHaveAttribute("aria-invalid", "true"); + }); + + it("adds a caption at the playhead and deletes rows", async () => { + renderEditor(); + await screen.findByTestId("clip-editor-video"); + + expect(screen.getAllByLabelText(/Caption \d+ text/)).toHaveLength(2); + + fireEvent.click(screen.getByRole("button", { name: /Add caption/ })); + + // The new cue starts at the playhead (clip start) and sorts before the + // transcript cue that begins at 10.5s. + expect(screen.getAllByLabelText(/Caption \d+ text/)).toHaveLength(3); + expect((screen.getByLabelText("Caption 2 start") as HTMLInputElement).value).toBe("10"); + + fireEvent.click(screen.getByRole("button", { name: "Delete caption 2" })); + expect(screen.getAllByLabelText(/Caption \d+ text/)).toHaveLength(2); + }); + + it("resets captions from the transcript", async () => { + renderEditor(); + await screen.findByTestId("clip-editor-video"); + + fireEvent.change(screen.getByLabelText("Caption 2 text"), { target: { value: "edited" } }); + expect(screen.getByDisplayValue("edited")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /Reset to transcript/ })); + + await waitFor(() => expect(screen.getByDisplayValue("hello there")).toBeInTheDocument()); + const editorLoads = invokeMock.mock.calls.filter(([command]) => command === "get_clip_editor"); + expect(editorLoads).toHaveLength(2); + }); + + it("saves and cuts", async () => { + const { onSaved, onRender, onClose } = renderEditor(); + await screen.findByTestId("clip-editor-video"); + + fireEvent.click(screen.getByRole("button", { name: /Save & Cut/ })); + + await waitFor(() => expect(onRender).toHaveBeenCalledTimes(1)); + expect(onSaved).toHaveBeenCalledWith( + expect.objectContaining({ candidateId: "cand-1", aspectRatio: "9:16" }), + ); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("saves the caption style", async () => { + renderEditor(); + await screen.findByTestId("clip-editor-video"); + + fireEvent.change(screen.getByLabelText("Caption style"), { + target: { value: "vibrant-cyan" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(invokeMock).toHaveBeenCalledWith( + "save_clip_edit", + expect.objectContaining({ + edit: expect.objectContaining({ captionStyle: "vibrant-cyan" }), + }), + ); + }); + }); + + it("closes on Escape and on Cancel", async () => { + const { onClose } = renderEditor(); + await screen.findByTestId("clip-editor-video"); + + fireEvent.keyDown(document, { key: "Escape" }); + expect(onClose).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onClose).toHaveBeenCalledTimes(2); + }); + + it("shows an error state when loading fails", async () => { + invokeMock.mockRejectedValue(new Error("backend offline")); + const onClose = vi.fn(); + render(); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("backend offline"); + expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); + }); + + it("restyles the caption colour, font and area", async () => { + renderEditor(); + const video = (await screen.findByTestId("clip-editor-video")) as HTMLVideoElement; + Object.defineProperty(video, "currentTime", { configurable: true, value: 11, writable: true }); + fireEvent.timeUpdate(video); + + const caption = await screen.findByTestId("clip-editor-caption"); + expect(caption).toHaveTextContent("HELLO THERE"); + + fireEvent.change(screen.getByLabelText("Cor do texto"), { target: { value: "#ff0000" } }); + await waitFor(() => expect(caption).toHaveStyle({ color: "#ff0000" })); + + fireEvent.change(screen.getByLabelText(/Posição vertical/), { target: { value: "0.5" } }); + await waitFor(() => expect(caption).toHaveStyle({ top: "50%" })); + + fireEvent.change(screen.getByLabelText(/Largura da área/), { target: { value: "0.4" } }); + await waitFor(() => expect(caption).toHaveStyle({ maxWidth: "40%" })); + + fireEvent.click(screen.getByLabelText("Maiúsculas")); + await waitFor(() => expect(caption).toHaveTextContent("hello there")); + }); + + it("persists caption overrides on save and clears them on reset", async () => { + renderEditor(); + await screen.findByTestId("clip-editor-video"); + + fireEvent.change(screen.getByLabelText(/Posição vertical/), { target: { value: "0.5" } }); + fireEvent.change(screen.getByLabelText("Cor do texto"), { target: { value: "#00ff00" } }); + fireEvent.click(screen.getByLabelText("Negrito")); + + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(invokeMock).toHaveBeenCalledWith( + "save_clip_edit", + expect.objectContaining({ + edit: expect.objectContaining({ + captionOverride: expect.objectContaining({ + position: 0.5, + textColor: "#00FF00", + bold: false, + }), + }), + }), + ); + }); + + fireEvent.click(screen.getByRole("button", { name: /Resetar estilo/ })); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + expect(invokeMock).toHaveBeenLastCalledWith( + "save_clip_edit", + expect.objectContaining({ edit: expect.objectContaining({ captionOverride: null }) }), + ); + }); + }); + + it("plays the source directly when the webview can decode it", async () => { + const original = HTMLMediaElement.prototype.canPlayType; + HTMLMediaElement.prototype.canPlayType = (() => "probably") as unknown as () => CanPlayTypeResult; + + try { + renderEditor(buildPayload({ sourceVideoCodec: "vp9", sourceAudioCodec: "opus" })); + + const video = (await screen.findByTestId("clip-editor-video")) as HTMLVideoElement; + await waitFor(() => expect(video.getAttribute("src")).toContain("source.mp4")); + expect(invokeMock).not.toHaveBeenCalledWith("prepare_project_preview", expect.anything()); + } finally { + HTMLMediaElement.prototype.canPlayType = original; + } + }); + + it("falls back to a transcoded proxy when the source codec is undecodable", async () => { + const canPlayType = vi.fn(() => ""); + const original = HTMLMediaElement.prototype.canPlayType; + HTMLMediaElement.prototype.canPlayType = canPlayType as unknown as () => CanPlayTypeResult; + + try { + renderEditor(buildPayload({ sourceVideoCodec: "h264", sourceAudioCodec: "aac" })); + + const video = (await screen.findByTestId("clip-editor-video")) as HTMLVideoElement; + await waitFor(() => + expect(invokeMock).toHaveBeenCalledWith("prepare_project_preview", { + projectId: "project-1", + }), + ); + // Served over loopback HTTP: WebKitGTK cannot open the `asset:` scheme. + await waitFor(() => expect(video.getAttribute("src")).toContain("127.0.0.1")); + expect(video.getAttribute("src")).toContain("source.webm"); + expect(video.getAttribute("src")).not.toContain("source.mp4"); + } finally { + HTMLMediaElement.prototype.canPlayType = original; + } + }); + + it("seeks the whole-video proxy to the clip start", async () => { + const canPlayType = vi.fn(() => ""); + const original = HTMLMediaElement.prototype.canPlayType; + HTMLMediaElement.prototype.canPlayType = canPlayType as unknown as () => CanPlayTypeResult; + + try { + renderEditor(buildPayload({ sourceVideoCodec: "h264", sourceAudioCodec: "aac" })); + const video = (await screen.findByTestId("clip-editor-video")) as HTMLVideoElement; + await waitFor(() => expect(video.getAttribute("src")).toContain("source.webm")); + + // The proxy covers the whole source, so clip start 10s is proxy time 10s. + Object.defineProperty(video, "currentTime", { + configurable: true, + value: 10, + writable: true, + }); + fireEvent.timeUpdate(video); + + await waitFor(() => { + const slider = screen.getByLabelText("Playhead") as HTMLInputElement; + expect(Number(slider.value)).toBeCloseTo(10, 5); + }); + } finally { + HTMLMediaElement.prototype.canPlayType = original; + } + }); + + it("reports a preview error when the proxy also fails to load", async () => { + renderEditor(); + + const video = await screen.findByTestId("clip-editor-video"); + fireEvent.error(video); + await waitFor(() => + expect(invokeMock).toHaveBeenCalledWith("prepare_project_preview", { + projectId: "project-1", + }), + ); + + fireEvent.error(video); + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("pré-visualização"); + }); +}); diff --git a/src/components/ClipEditor.tsx b/src/components/ClipEditor.tsx new file mode 100644 index 0000000..70fa001 --- /dev/null +++ b/src/components/ClipEditor.tsx @@ -0,0 +1,1012 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { PointerEvent as ReactPointerEvent } from "react"; +import { convertFileSrc, invoke } from "@tauri-apps/api/core"; +import { + Crosshair, + Loader2, + Pause, + Play, + Plus, + RotateCcw, + Trash2, + X, +} from "lucide-react"; +import { + ASPECT_PRESETS, + CAPTION_STYLES, + DEFAULT_CAPTION_STYLE, + FONT_FAMILIES, + canPlayDirectly, + captionPreviewStyle, + clampCues, + cropObjectPosition, + cueAt, + isLinuxWebview, + needsProxyFor, + normalizeAspect, + resolveCaptionStyle, + sanitizeHex, + stageMaxWidth, + toPreviewTime, + toSourceTime, +} from "../lib/clipEditor"; +import type { + CaptionCue, + CaptionStyleOverride, + ClipEdit, + ClipEditor as ClipEditorData, + ClipEditorCandidate, + ClipPreview, +} from "../lib/clipEditor"; +import { formatTime } from "../lib/format"; + +export type ClipEditorProps = { + candidateId: string; + onClose: () => void; + onSaved?: (edit: ClipEdit) => void; + onBulkSaved?: (edits: ClipEdit[]) => void; + onRangeChanged?: (candidate: ClipEditorCandidate) => void; + onRender?: () => void; +}; + +const CUE_LENGTH_SEC = 1.5; +/** Height budget for the preview stage, in vh. */ +const STAGE_MAX_HEIGHT_VH = 52; + +function clamp01(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.min(1, Math.max(0, value)); +} + +/** `convertFileSrc` is unavailable in plain browser/test contexts. */ +function safeConvertFileSrc(path: string): string { + if (typeof convertFileSrc !== "function") return path; + try { + return convertFileSrc(path); + } catch { + return path; + } +} + +/** + * Modal preview/edit surface shown before a candidate is cut. + * + * The user sees the actual crop window over the source video, picks the output + * aspect ratio and fixes the captions that land inside the clip. + */ +export function ClipEditor({ candidateId, onClose, onSaved, onBulkSaved, onRangeChanged, onRender }: ClipEditorProps) { + const panelRef = useRef(null); + const stageRef = useRef(null); + const videoRef = useRef(null); + const dragRef = useRef<{ + startX: number; + startY: number; + cropX: number; + cropY: number; + width: number; + height: number; + } | null>(null); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [saveError, setSaveError] = useState(null); + const [saving, setSaving] = useState(false); + + const [aspectRatio, setAspectRatio] = useState("9:16"); + const [cropX, setCropX] = useState(0.5); + const [cropY, setCropY] = useState(0.5); + const [cues, setCues] = useState([]); + const [captionStyle, setCaptionStyle] = useState(DEFAULT_CAPTION_STYLE); + const [captionOverride, setCaptionOverride] = useState({}); + + const [currentTime, setCurrentTime] = useState(0); + const [playing, setPlaying] = useState(false); + const [stageWidth, setStageWidth] = useState(0); + // Clip window. Mirrors `data.candidate` but stays authoritative after the + // user widens or shrinks the cut. + const [rangeStart, setRangeStart] = useState(0); + const [rangeEnd, setRangeEnd] = useState(0); + const [savingRange, setSavingRange] = useState(false); + const [applyingStyle, setApplyingStyle] = useState(false); + const [styleNotice, setStyleNotice] = useState(null); + + // Preview source. When the webview cannot decode the source codecs (no + // H.264/AAC decoder in WebKitGTK on a stock Linux install) the backend + // transcodes the *whole* video once into a VP8/Opus WebM proxy and we seek + // inside it, so every clip of the project shares a single cache entry. + const [previewSrc, setPreviewSrc] = useState(""); + const [previewOffset, setPreviewOffset] = useState(0); + const [preparingPreview, setPreparingPreview] = useState(false); + const [previewError, setPreviewError] = useState(null); + const proxyRequestedRef = useRef(false); + + const load = useCallback(async () => { + setLoading(true); + setLoadError(null); + setPreviewError(null); + proxyRequestedRef.current = false; + try { + const payload = await invoke("get_clip_editor", { candidateId }); + setData(payload); + setAspectRatio(normalizeAspect(payload.edit?.aspectRatio)); + setCropX(clamp01(payload.edit?.cropX ?? 0.5)); + setCropY(clamp01(payload.edit?.cropY ?? 0.5)); + setCues(Array.isArray(payload.cues) ? payload.cues : payload.edit?.captions ?? []); + setCaptionStyle(payload.captionStyle || payload.edit?.captionStyle || DEFAULT_CAPTION_STYLE); + setCaptionOverride(payload.edit?.captionOverride ?? {}); + setRangeStart(payload.candidate?.startSec ?? 0); + setRangeEnd(payload.candidate?.endSec ?? 0); + setStyleNotice(null); + setCurrentTime(payload.candidate?.startSec ?? 0); + } catch (err) { + setLoadError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [candidateId]); + + useEffect(() => { + void load(); + }, [load]); + + useEffect(() => { + panelRef.current?.focus(); + }, []); + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.stopPropagation(); + onClose(); + } + } + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + // Caption preview is sized in ASS canvas units, so it has to know how wide + // the stage actually is on screen. + useEffect(() => { + const stage = stageRef.current; + if (!stage) return; + const measure = () => setStageWidth(stage.getBoundingClientRect().width); + measure(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(measure); + observer.observe(stage); + return () => observer.disconnect(); + }, [loading, loadError, data]); + + const startSec = rangeStart; + const endSec = rangeEnd; + const clipDuration = Math.max(0, endSec - startSec); + const sourceDuration = data?.sourceDurationSec ?? 0; + + const preset = ASPECT_PRESETS.find((item) => item.id === aspectRatio) ?? ASPECT_PRESETS[0]; + + const resolvedStyle = resolveCaptionStyle(captionStyle, captionOverride); + const captionCss = captionPreviewStyle(resolvedStyle, stageWidth); + + /** Transcode the whole source once and switch the player to the proxy. */ + const prepareProxy = useCallback(async () => { + if (proxyRequestedRef.current) return; + const projectId = data?.candidate?.projectId; + if (!projectId) return; + proxyRequestedRef.current = true; + setPreparingPreview(true); + setPreviewError(null); + try { + const preview = await invoke("prepare_project_preview", { projectId }); + setPreviewOffset(Number.isFinite(preview.startSec) ? preview.startSec : 0); + // Loopback HTTP, not `convertFileSrc`: WebKitGTK's media pipeline cannot + // open the `asset:` scheme at all. + setPreviewSrc(preview.url || safeConvertFileSrc(preview.path)); + } catch (err) { + setPreviewError(err instanceof Error ? err.message : String(err)); + } finally { + setPreparingPreview(false); + } + }, [data]); + + useEffect(() => { + if (!data) return; + // On Linux `canPlayType` lies for MP4 (container support without a + // decoder), so the probed codec decides there instead of the probe. + const linux = isLinuxWebview(); + const useProxy = linux + ? needsProxyFor(data.sourceVideoCodec, true) + : canPlayDirectly(data.sourceVideoCodec, data.sourceAudioCodec, (mime) => + typeof document === "undefined" ? "" : document.createElement("video").canPlayType(mime), + ) === false; + console.info( + "[clip-editor] preview mode", + useProxy ? "proxy" : "direct", + { codec: data.sourceVideoCodec, audio: data.sourceAudioCodec, linux }, + ); + if (useProxy) { + void prepareProxy(); + return; + } + setPreviewOffset(0); + setPreviewSrc(data.sourcePath ? safeConvertFileSrc(data.sourcePath) : ""); + }, [data, prepareProxy]); + + function handleVideoError() { + const code = videoRef.current?.error?.code; + if (proxyRequestedRef.current) { + setPreviewError( + `Não foi possível carregar a pré-visualização deste vídeo (código ${code ?? "?"}).`, + ); + return; + } + // Unknown codecs reach this branch: try a transcoded proxy before giving up. + console.warn("[clip-editor] source failed to load, building proxy", { code }); + void prepareProxy(); + } + + const activeCue = cueAt(cues, currentTime); + + const visibleCues = useMemo( + () => cues.filter((cue) => cue.end > startSec && cue.start < endSec), + [cues, startSec, endSec], + ); + + const relativeTime = Math.max(0, currentTime - startSec); + + function seek(time: number) { + const video = videoRef.current; + const next = Math.min(Math.max(time, startSec), endSec); + if (video) { + try { + video.currentTime = Math.max(0, toPreviewTime(next, previewOffset)); + } catch { + /* jsdom / unsupported seek */ + } + } + setCurrentTime(next); + } + + function handleLoadedMetadata() { + const video = videoRef.current; + if (!video) return; + try { + video.currentTime = Math.max(0, toPreviewTime(startSec, previewOffset)); + } catch { + /* ignore */ + } + setCurrentTime(startSec); + } + + function handleTimeUpdate() { + const video = videoRef.current; + if (!video) return; + const time = toSourceTime(video.currentTime, previewOffset); + if (!Number.isFinite(time)) return; + + if (time >= endSec || time < startSec - 0.001) { + try { + video.currentTime = Math.max(0, toPreviewTime(startSec, previewOffset)); + } catch { + /* ignore */ + } + setCurrentTime(startSec); + return; + } + setCurrentTime(time); + } + + async function togglePlay() { + const video = videoRef.current; + if (!video) return; + if (playing) { + try { + video.pause(); + } catch { + /* ignore */ + } + setPlaying(false); + return; + } + if (currentTime < startSec || currentTime >= endSec) { + seek(startSec); + } + try { + await video.play(); + setPlaying(true); + } catch { + setPlaying(false); + } + } + + function handlePointerDown(event: ReactPointerEvent) { + const rect = event.currentTarget.getBoundingClientRect(); + dragRef.current = { + startX: event.clientX, + startY: event.clientY, + cropX, + cropY, + width: Math.max(1, rect.width), + height: Math.max(1, rect.height), + }; + try { + event.currentTarget.setPointerCapture(event.pointerId); + } catch { + /* jsdom */ + } + } + + function handlePointerMove(event: ReactPointerEvent) { + const drag = dragRef.current; + if (!drag) return; + const dx = (event.clientX - drag.startX) / drag.width; + const dy = (event.clientY - drag.startY) / drag.height; + // Grab semantics: dragging the picture right reveals what is on the left. + setCropX(clamp01(drag.cropX - dx)); + setCropY(clamp01(drag.cropY - dy)); + } + + function handlePointerUp() { + dragRef.current = null; + } + + function addCaptionAtPlayhead() { + const start = Math.min(Math.max(currentTime, startSec), Math.max(startSec, endSec - 0.1)); + const end = Math.min(endSec, start + CUE_LENGTH_SEC); + const cue: CaptionCue = { start, end: end > start ? end : start + 0.1, text: "" }; + setCues((current) => [...current, cue].sort((a, b) => a.start - b.start)); + } + + function updateCue(index: number, patch: Partial) { + setCues((current) => current.map((cue, i) => (i === index ? { ...cue, ...patch } : cue))); + } + + function deleteCue(index: number) { + setCues((current) => current.filter((_, i) => i !== index)); + } + + function updateOverride(patch: CaptionStyleOverride) { + setCaptionOverride((current) => ({ ...current, ...patch })); + } + + /** Drop every override so the preset renders untouched again. */ + function resetOverride() { + setCaptionOverride({}); + } + + async function resetToTranscript() { + try { + const payload = await invoke("get_clip_editor", { candidateId }); + setCues(Array.isArray(payload.cues) ? payload.cues : []); + setSaveError(null); + } catch (err) { + setSaveError(err instanceof Error ? err.message : String(err)); + } + } + + /** Persist a new clip window, then reload the cues that fall inside it. */ + async function applyRange(nextStart: number, nextEnd: number) { + if (!data || savingRange) return; + const projectId = data.candidate?.projectId; + if (!projectId) return; + const safeStart = Math.max(0, Number.isFinite(nextStart) ? nextStart : 0); + const rawEnd = Number.isFinite(nextEnd) ? nextEnd : safeStart + 0.1; + const limit = sourceDuration > 0 ? sourceDuration : rawEnd; + const safeEnd = Math.min(Math.max(rawEnd, safeStart + 0.1), Math.max(limit, safeStart + 0.1)); + setSavingRange(true); + setSaveError(null); + try { + const updated = await invoke("update_candidate_range", { + candidateId, + startSec: safeStart, + endSec: safeEnd, + }); + setRangeStart(updated.startSec); + setRangeEnd(updated.endSec); + setCurrentTime(updated.startSec); + setData((current) => + current ? { ...current, candidate: { ...current.candidate, ...updated } } : current, + ); + const nextCues = await invoke("caption_cues_for_range", { + projectId, + startSec: updated.startSec, + endSec: updated.endSec, + }); + if (Array.isArray(nextCues)) setCues(nextCues); + onRangeChanged?.(updated); + } catch (err) { + setSaveError(err instanceof Error ? err.message : String(err)); + } finally { + setSavingRange(false); + } + } + + /** Broadcast the current caption look to every clip of the project. */ + async function applyStyleToAll() { + if (!data || applyingStyle) return; + const projectId = data.candidate?.projectId; + if (!projectId) return; + const hasOverride = Object.values(captionOverride).some( + (value) => value !== null && value !== undefined, + ); + setApplyingStyle(true); + setStyleNotice(null); + setSaveError(null); + try { + const edits = await invoke("apply_caption_style_to_project", { + projectId, + captionStyle: captionStyle || DEFAULT_CAPTION_STYLE, + captionOverride: hasOverride ? captionOverride : null, + }); + const list = Array.isArray(edits) ? edits : []; + if (onBulkSaved) onBulkSaved(list); + else list.forEach((edit) => onSaved?.(edit)); + setStyleNotice(`Estilo aplicado a ${list.length} clipe(s).`); + } catch (err) { + setSaveError(err instanceof Error ? err.message : String(err)); + } finally { + setApplyingStyle(false); + } + } + + async function handleSave(andCut: boolean) { + if (!data || saving) return; + setSaving(true); + setSaveError(null); + const hasOverride = Object.values(captionOverride).some( + (value) => value !== null && value !== undefined, + ); + const edit: ClipEdit = { + candidateId, + aspectRatio: normalizeAspect(aspectRatio), + cropX: clamp01(cropX), + cropY: clamp01(cropY), + captions: clampCues(cues, 0, Number.POSITIVE_INFINITY), + captionStyle: captionStyle || DEFAULT_CAPTION_STYLE, + captionOverride: hasOverride ? captionOverride : null, + }; + try { + const saved = await invoke("save_clip_edit", { edit }); + onSaved?.(saved ?? edit); + if (andCut) { + onClose(); + onRender?.(); + } + } catch (err) { + setSaveError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + } + + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > +
+
+
+

Edit clip

+ {data && ( +

+ {formatTime(startSec)} - {formatTime(endSec)} · {formatTime(clipDuration)} long +

+ )} +
+ +
+ + {loading && ( +
+ + Loading clip… +
+ )} + + {!loading && loadError && ( +
+ {loadError} +
+ + +
+
+ )} + + {!loading && !loadError && data && ( + <> +
+
+
+
+ +
+
+ + +
+ +
+ Legenda: cor, fonte e área + +
+ + +
+ +
+ + updateOverride({ fontSize: Number(event.target.value) })} + /> +
+ +
+ + + +
+ +
+ + + +
+ +
+ + updateOverride({ position: Number(event.target.value) })} + /> +
+ +
+ + updateOverride({ maxWidth: Number(event.target.value) })} + /> +
+ +
+ + +
+ {styleNotice && ( +

+ {styleNotice} +

+ )} +
+ +
+
+ Captions +
+ + +
+
+ + {visibleCues.length === 0 && ( +

No captions inside this clip.

+ )} + + {visibleCues.map((cue) => { + const index = cues.indexOf(cue); + const invalid = !(cue.end > cue.start); + return ( +
+
+ + updateCue(index, { start: Number(event.target.value) }) + } + /> + - + + updateCue(index, { end: Number(event.target.value) }) + } + /> + +
+ updateCue(index, { text: event.target.value })} + /> +
+ ); + })} +
+
+
+ +
+ {saveError && ( + + {saveError} + + )} + + + +
+ + )} +
+
+ ); +} + +export default ClipEditor; diff --git a/src/lib/clipEditor.test.ts b/src/lib/clipEditor.test.ts new file mode 100644 index 0000000..cbf7259 --- /dev/null +++ b/src/lib/clipEditor.test.ts @@ -0,0 +1,364 @@ +import { describe, expect, it } from "vitest"; +import { + ASPECT_PRESETS, + CAPTION_STYLES, + DEFAULT_ASPECT_RATIO, + aspectValue, + canPlayDirectly, + captionPreviewStyle, + clampCues, + hexToRgba, + isLinuxWebview, + needsProxyFor, + resolveCaptionStyle, + sanitizeHex, + cropGeometry, + cropObjectPosition, + cueAt, + normalizeAspect, + previewMimeType, + stageMaxWidth, + toPreviewTime, + toSourceTime, +} from "./clipEditor"; + +describe("normalizeAspect", () => { + it("keeps every known ratio", () => { + for (const preset of ASPECT_PRESETS) { + expect(normalizeAspect(preset.id)).toBe(preset.id); + } + }); + + it("falls back to 9:16 for unknown or missing values", () => { + expect(normalizeAspect("3:2")).toBe("9:16"); + expect(normalizeAspect("")).toBe("9:16"); + expect(normalizeAspect(null)).toBe("9:16"); + expect(normalizeAspect(undefined)).toBe(DEFAULT_ASPECT_RATIO); + }); +}); + +describe("aspectValue", () => { + it("returns width / height for the presets", () => { + expect(aspectValue("9:16")).toBeCloseTo(1080 / 1920, 10); + expect(aspectValue("1:1")).toBeCloseTo(1, 10); + expect(aspectValue("16:9")).toBeCloseTo(1920 / 1080, 10); + expect(aspectValue("4:5")).toBeCloseTo(1080 / 1350, 10); + }); + + it("uses the 9:16 fallback for unknown ratios", () => { + expect(aspectValue("nope")).toBeCloseTo(1080 / 1920, 10); + }); +}); + +describe("cropGeometry", () => { + it("pillarboxes a wide source at 9:16", () => { + // 1920x1080 -> 1080*9/16 tall window, full height. + const geometry = cropGeometry(1920, 1080, "9:16", 0.5, 0.5); + expect(geometry.height).toBeCloseTo(1080, 6); + expect(geometry.width).toBeCloseTo(607.5, 6); + expect(geometry.x).toBeCloseTo((1920 - 607.5) / 2, 6); + expect(geometry.y).toBeCloseTo(0, 6); + }); + + it("letterboxes a tall source at 16:9", () => { + // 1080x1920 -> full width, height 1080/(16/9). + const geometry = cropGeometry(1080, 1920, "16:9", 0.5, 0.5); + expect(geometry.width).toBeCloseTo(1080, 6); + expect(geometry.height).toBeCloseTo(607.5, 6); + expect(geometry.x).toBeCloseTo(0, 6); + expect(geometry.y).toBeCloseTo((1920 - 607.5) / 2, 6); + }); + + it("keeps the whole frame when the source already matches the ratio", () => { + const geometry = cropGeometry(1080, 1920, "9:16", 0.5, 0.5); + expect(geometry.width).toBeCloseTo(1080, 6); + expect(geometry.height).toBeCloseTo(1920, 6); + expect(geometry.x).toBeCloseTo(0, 6); + expect(geometry.y).toBeCloseTo(0, 6); + }); + + it("pins the window to the edges at 0 and 1", () => { + const left = cropGeometry(1920, 1080, "9:16", 0, 0); + expect(left.x).toBeCloseTo(0, 6); + + const right = cropGeometry(1920, 1080, "9:16", 1, 1); + expect(right.x).toBeCloseTo(1920 - 607.5, 6); + }); + + it("clamps out-of-range crop offsets", () => { + const under = cropGeometry(1920, 1080, "9:16", -3, -3); + expect(under.x).toBeCloseTo(0, 6); + expect(under.y).toBeCloseTo(0, 6); + + const over = cropGeometry(1920, 1080, "9:16", 7, 7); + expect(over.x).toBeCloseTo(1920 - 607.5, 6); + }); + + it("returns zeros for unusable dimensions", () => { + expect(cropGeometry(0, 1080, "9:16", 0.5, 0.5)).toEqual({ width: 0, height: 0, x: 0, y: 0 }); + expect(cropGeometry(Number.NaN, 1080, "9:16", 0.5, 0.5)).toEqual({ + width: 0, + height: 0, + x: 0, + y: 0, + }); + }); +}); + +describe("cropObjectPosition", () => { + it("centres the frame by default", () => { + expect(cropObjectPosition(0.5, 0.5)).toBe("50% 50%"); + }); + + it("maps the crop sliders onto object-position percentages", () => { + expect(cropObjectPosition(0, 1)).toBe("0% 100%"); + expect(cropObjectPosition(1, 0)).toBe("100% 0%"); + expect(cropObjectPosition(0.25, 0.75)).toBe("25% 75%"); + }); + + it("clamps out-of-range values instead of emitting invalid CSS", () => { + expect(cropObjectPosition(-3, 7)).toBe("0% 100%"); + expect(cropObjectPosition(Number.NaN, 0.5)).toBe("0% 50%"); + }); +}); + +describe("stageMaxWidth", () => { + it("keeps the stage inside the height budget", () => { + // 52vh tall at 9:16 is 29.25vh wide. + expect(stageMaxWidth("9:16", 52)).toBe("calc(29.2500vh)"); + expect(stageMaxWidth("16:9", 52)).toBe("calc(92.4444vh)"); + }); + + it("falls back to the default budget for invalid input", () => { + expect(stageMaxWidth("1:1", Number.NaN)).toBe("calc(52.0000vh)"); + }); +}); + +describe("cueAt", () => { + const cues = [ + { start: 10, end: 12, text: "one" }, + { start: 12, end: 14.5, text: "two" }, + ]; + + it("finds the cue covering the time", () => { + expect(cueAt(cues, 10)?.text).toBe("one"); + expect(cueAt(cues, 11.9)?.text).toBe("one"); + expect(cueAt(cues, 13)?.text).toBe("two"); + }); + + it("returns null outside every cue", () => { + expect(cueAt(cues, 9.9)).toBeNull(); + expect(cueAt(cues, 14.5)).toBeNull(); + expect(cueAt([], 10)).toBeNull(); + expect(cueAt(null, 10)).toBeNull(); + expect(cueAt(cues, Number.NaN)).toBeNull(); + }); +}); + +describe("clampCues", () => { + it("drops empty and invalid cues", () => { + const result = clampCues( + [ + { start: 0, end: 1, text: " " }, + { start: 5, end: 5, text: "zero length" }, + { start: 6, end: 4, text: "backwards" }, + { start: Number.NaN, end: 3, text: "nan" }, + { start: 1, end: 2, text: " keep " }, + ], + 0, + 10, + ); + expect(result).toEqual([{ start: 1, end: 2, text: "keep" }]); + }); + + it("clamps into the window and sorts by start", () => { + const result = clampCues( + [ + { start: 8, end: 20, text: "late" }, + { start: 2, end: 6, text: "early" }, + ], + 5, + 10, + ); + expect(result).toEqual([ + { start: 5, end: 6, text: "early" }, + { start: 8, end: 10, text: "late" }, + ]); + }); + + it("drops cues entirely outside the window", () => { + expect(clampCues([{ start: 0, end: 1, text: "before" }], 5, 10)).toEqual([]); + }); + + it("handles missing input", () => { + expect(clampCues(null, 0, 10)).toEqual([]); + expect(clampCues(undefined, 0, 10)).toEqual([]); + }); +}); + +describe("CAPTION_STYLES", () => { + it("exposes every backend style id", () => { + expect(CAPTION_STYLES.map((style) => style.id)).toEqual([ + "modern-box", + "classic-outline", + "minimal-shadow", + "vibrant-cyan", + "vibrant-yellow-box", + "vibrant-green", + "vibrant-red", + ]); + }); +}); + +describe("previewMimeType", () => { + it("builds an mp4 mime for h264 + aac", () => { + expect(previewMimeType("h264", "aac")).toBe('video/mp4; codecs="avc1.42E01E, mp4a.40.2"'); + }); + + it("builds a webm mime for vp9 + opus", () => { + expect(previewMimeType("vp9", "opus")).toBe('video/webm; codecs="vp09.00.10.08, opus"'); + }); + + it("returns null when the video codec is unknown", () => { + expect(previewMimeType(null, "aac")).toBeNull(); + expect(previewMimeType("prores", "aac")).toBeNull(); + }); +}); + +describe("canPlayDirectly", () => { + it("returns null when the codec is unknown", () => { + expect(canPlayDirectly(undefined, undefined, () => "maybe")).toBeNull(); + }); + + it("reports support when canPlayType returns a non-empty value", () => { + const probe = () => "probably"; + expect(canPlayDirectly("vp8", "opus", probe)).toBe(true); + }); + + it("reports failure for an empty canPlayType answer", () => { + expect(canPlayDirectly("h264", "aac", () => "")).toBe(false); + }); +}); + +describe("preview time mapping", () => { + it("offsets source time into proxy time and back", () => { + expect(toPreviewTime(12, 9)).toBe(3); + expect(toSourceTime(3, 9)).toBe(12); + }); + + it("keeps the raw offset (callers clamp to zero)", () => { + expect(toPreviewTime(1, 9)).toBe(-8); + expect(toSourceTime(-5, 9)).toBe(4); + }); + + it("treats a zero offset as identity", () => { + expect(toPreviewTime(42, 0)).toBe(42); + expect(toSourceTime(42, 0)).toBe(42); + }); +}); + +describe("needsProxyFor", () => { + it("never proxies on non-Linux webviews", () => { + expect(needsProxyFor("h264", false)).toBe(false); + expect(needsProxyFor(null, false)).toBe(false); + }); + + it("proxies every non-WebM codec on Linux", () => { + expect(needsProxyFor("h264", true)).toBe(true); + expect(needsProxyFor("aac", true)).toBe(true); + expect(needsProxyFor(null, true)).toBe(true); + }); + + it("plays WebM-family codecs directly on Linux", () => { + expect(needsProxyFor("vp8", true)).toBe(false); + expect(needsProxyFor("VP9", true)).toBe(false); + expect(needsProxyFor("av1", true)).toBe(false); + }); +}); + +describe("isLinuxWebview", () => { + it("detects a Linux user agent", () => { + expect(isLinuxWebview()).toBe(/Linux/i.test(navigator.userAgent)); + }); +}); + +describe("sanitizeHex", () => { + it("keeps 6-digit hex and uppercases it", () => { + expect(sanitizeHex("#ffcc00")).toBe("#FFCC00"); + expect(sanitizeHex(" #00ff00 ")).toBe("#00FF00"); + }); + + it("rejects anything else", () => { + expect(sanitizeHex("#abc")).toBeNull(); + expect(sanitizeHex("red")).toBeNull(); + expect(sanitizeHex(null)).toBeNull(); + }); +}); + +describe("hexToRgba", () => { + it("converts hex plus alpha", () => { + expect(hexToRgba("#112233", 0.5)).toBe("rgba(17, 34, 51, 0.5)"); + expect(hexToRgba("#112233", 4)).toBe("rgba(17, 34, 51, 1)"); + }); + + it("falls back to white for invalid input", () => { + expect(hexToRgba("nope", 1)).toBe("rgba(255, 255, 255, 1)"); + }); +}); + +describe("resolveCaptionStyle", () => { + it("uses the preset defaults when there is no override", () => { + const style = resolveCaptionStyle("vibrant-cyan", null); + expect(style.textColor).toBe("#22D3EE"); + expect(style.borderStyle).toBe(1); + expect(style.uppercase).toBe(true); + }); + + it("layers overrides and clamps out-of-range values", () => { + const style = resolveCaptionStyle("modern-box", { + textColor: "#00ff00", + fontSize: 900, + outline: -4, + position: 3, + maxWidth: 0.05, + borderStyle: 1, + bold: false, + uppercase: false, + }); + expect(style.textColor).toBe("#00FF00"); + expect(style.fontSize).toBe(200); + expect(style.outline).toBe(0); + expect(style.position).toBe(1); + expect(style.maxWidth).toBe(0.2); + expect(style.borderStyle).toBe(1); + expect(style.bold).toBe(false); + expect(style.uppercase).toBe(false); + }); + + it("ignores unknown presets", () => { + expect(resolveCaptionStyle("nope", null)).toEqual(resolveCaptionStyle("modern-box", null)); + }); +}); + +describe("captionPreviewStyle", () => { + it("scales the ASS font size onto the rendered stage", () => { + const style = resolveCaptionStyle("modern-box", { fontSize: 100 }); + const css = captionPreviewStyle(style, 270); + expect(css.fontSize).toBeCloseTo(25, 5); + }); + + it("maps the position onto a top offset and keeps boxed presets unstroked", () => { + const boxed = captionPreviewStyle(resolveCaptionStyle("modern-box", { position: 0.8 }), 540); + expect(boxed.top).toBe("20%"); + expect(boxed.background).toContain("rgba("); + expect(boxed.WebkitTextStroke).toBe("0"); + + const outlined = captionPreviewStyle( + resolveCaptionStyle("classic-outline", { position: 0.5, outline: 6 }), + 540, + ); + expect(outlined.top).toBe("50%"); + expect(outlined.background).toBe("transparent"); + expect(outlined.WebkitTextStroke).toContain("px"); + }); +}); diff --git a/src/lib/clipEditor.ts b/src/lib/clipEditor.ts new file mode 100644 index 0000000..76c3685 --- /dev/null +++ b/src/lib/clipEditor.ts @@ -0,0 +1,597 @@ +/** + * Pure helpers behind the pre-render clip editor. + * + * Everything here is framework-free so the crop math (which mirrors the + * ffmpeg filter used by the backend) can be unit tested without booting Tauri. + */ + +export type CaptionCue = { + /** Absolute source time, in seconds. */ + start: number; + /** Absolute source time, in seconds. */ + end: number; + text: string; +}; + +/** + * Per-clip caption styling on top of a preset. + * + * Mirrors `models::CaptionStyleOverride`; every field is optional so a clip + * only stores what the user actually changed. + */ +export type CaptionStyleOverride = { + fontFamily?: string | null; + fontSize?: number | null; + /** `#RRGGBB`. */ + textColor?: string | null; + /** `#RRGGBB`. */ + outlineColor?: string | null; + /** `#RRGGBB`. */ + boxColor?: string | null; + /** 1 = outline + shadow, 3 = opaque box. */ + borderStyle?: number | null; + outline?: number | null; + bold?: boolean | null; + uppercase?: boolean | null; + /** 0 = top of the frame, 1 = bottom. */ + position?: number | null; + /** Max line width as a fraction of the frame width (0.2 - 1). */ + maxWidth?: number | null; +}; + +export type ClipEdit = { + candidateId: string; + aspectRatio: string; + cropX: number; + cropY: number; + captions: CaptionCue[]; + captionStyle: string | null; + captionOverride?: CaptionStyleOverride | null; +}; + +export type ClipEditorCandidate = { + id: string; + projectId?: string; + startSec: number; + endSec: number; + score?: number; + hook?: string; + rationale?: string; + rank?: number; + selected?: boolean; +}; + +/** Payload returned by `get_clip_editor`. */ +export type ClipEditor = { + candidate: ClipEditorCandidate; + sourcePath: string; + edit: ClipEdit; + cues: CaptionCue[]; + captionStyle: string; + sourceWidth: number | null; + sourceHeight: number | null; + /** Total source duration; bounds the clip window sliders. */ + sourceDurationSec?: number | null; + sourceVideoCodec?: string | null; + sourceAudioCodec?: string | null; +}; + +/** One still frame of a candidate, returned by `prepare_project_thumbnails`. */ +export type CandidateThumbnail = { + candidateId: string; + path: string; + /** Loopback HTTP URL for the `` element. */ + url: string; + timeSec: number; +}; + +/** Payload returned by `prepare_project_preview`. */ +export type ClipPreview = { + path: string; + /** + * Loopback HTTP URL the `
{transcriptionEngine === "local" && (
+ - {!environment?.hasLocalWhisperModel && ( -

- ⚠️ Python package 'openai-whisper' not detected. Run 'pip3 install -U openai-whisper' - before downloading models. -

- )}
)}
@@ -1295,7 +1358,7 @@ function App() { {!canTranscribe && (
{transcriptionEngine === "local" - ? `⚠️ Local Whisper (Python package 'openai-whisper') is not installed. Run 'pip3 install openai-whisper' in your terminal.` + ? `⚠️ Local Whisper (pacote Python 'openai-whisper') não está instalado. Abra as configurações e clique em "Instalar Whisper".` : "⚠️ Deepgram API Key is missing. Transcribing will not work. Please add your key in API Settings."}
)} @@ -1781,6 +1844,69 @@ function App() { ); } +// Local Whisper needs a Python package (~1-2 GB with PyTorch). The app owns a +// virtualenv under its data dir and installs into it, so no manual pip step is +// required after installing AutoShorts. +function PythonEnvCard({ + status, + installing, + onInstall, +}: { + status: PythonEnvStatus | null; + installing: PythonInstallState | null; + onInstall: () => Promise; +}) { + const busy = Boolean(installing); + const ready = Boolean(status?.ready); + + return ( +
+
+
+ Python / Whisper +

+ {ready + ? "Ambiente gerenciado pronto — transcrição local habilitada." + : "Instala o openai-whisper em um ambiente isolado do app."} +

+
+ {ready ? ( + + Pronto + + ) : ( + + )} +
+ + {installing && ( +
+
+
+
+ {installing.status} +
+ )} + + {!ready && !busy && !status?.systemPython && ( +

+ ⚠️ Python 3 não encontrado. Instale o Python (python.org no Windows,{" "} + sudo apt install python3 python3-venv no Debian/Ubuntu) e recarregue o app. +

+ )} + + {status?.venvDir && ( +

+ {status.venvDir} +

+ )} +
+ ); +} + function WhisperModelList({ models, selected, @@ -1801,8 +1927,7 @@ function WhisperModelList({ return (
- Whisper Model -
@@ -1868,8 +1993,7 @@ function WhisperModelList({ interface OnboardingProps { environment: EnvironmentStatus | null; - onComplete: () => void; - setTranscriptionEngine: (engine: "deepgram" | "local") => void; + onComplete: () => void; setTranscriptionEngine: (engine: "deepgram" | "local") => void; setLlmEngine: (engine: "claude" | "deepseek" | "local" | "groq" | "openai-compatible") => void; setLocalLlmModel: (model: string) => void; setDeepgramKey: (key: string) => void; @@ -1892,6 +2016,9 @@ interface OnboardingProps { downloadWhisperModel: (modelId: string) => Promise; loadWhisperModels: () => Promise; whisperDownload: WhisperDownloadState | null; + pythonEnv: PythonEnvStatus | null; + pythonInstall: PythonInstallState | null; + installPythonEnv: () => Promise; refreshEnv: () => Promise; } function Onboarding({ @@ -1913,6 +2040,9 @@ function Onboarding({ downloadWhisperModel, loadWhisperModels, whisperDownload, + pythonEnv, + pythonInstall, + installPythonEnv, deepgramKey: initialDeepgramKey, anthropicKey: initialAnthropicKey, deepseekKey: initialDeepseekKey, @@ -1941,13 +2071,6 @@ function Onboarding({ const [downloadProgress, setDownloadProgress] = useState(0); const [error, setError] = useState(null); const [checkingOllama, setCheckingOllama] = useState(false); - const [copied, setCopied] = useState(false); - - const copyWhisperCommand = () => { - navigator.clipboard.writeText("pip3 install -U openai-whisper"); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; const handleCloudSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -2118,19 +2241,17 @@ function Onboarding({
1
-

Install Python Whisper

-

Open your terminal and run the following command to install the transcription engine:

-
- pip3 install -U openai-whisper - -
+

Instalar Whisper

+

O AutoShorts baixa o openai-whisper em um ambiente Python isolado.

+ {environment?.hasLocalWhisperModel ? ( - Whisper installed in Python! + Whisper instalado! ) : ( - ⚠️ Python package 'whisper' not detected yet. Run the command above. + ⚠️ Pacote Python 'whisper' ainda não detectado. )}
@@ -2245,7 +2366,7 @@ function Onboarding({

{environment?.hasLocalWhisperModel ? "Local Whisper detected. Transcription will run on this machine." - : "Local Whisper not detected. Run 'pip3 install -U openai-whisper' before importing media."} + : "Local Whisper não detectado. Use \"Instalar Whisper\" nas configurações antes de importar mídia."}

)} diff --git a/src/styles.css b/src/styles.css index 6de2c0f..28a0b7f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1936,6 +1936,120 @@ button:disabled { gap: 6px; } +/* App-managed Python venv (local Whisper provisioning) */ +.python-env-card { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 12px; + padding: 12px; + border: 1px solid rgba(246, 193, 119, 0.24); + border-radius: 10px; + background: rgba(246, 193, 119, 0.05); +} + +.python-env-card.ready { + border-color: rgba(142, 230, 199, 0.28); + background: rgba(142, 230, 199, 0.05); +} + +.python-env-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.python-env-head strong { + font-size: 12.5px; + letter-spacing: 0.01em; +} + +.python-env-head p { + margin: 2px 0 0; + font-size: 11.5px; + color: var(--text-muted); + line-height: 1.45; +} + +.python-env-btn { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + padding: 6px 10px; + font-size: 11.5px; + font-weight: 600; + color: var(--text-primary); + background: rgba(246, 193, 119, 0.12); + border: 1px solid rgba(246, 193, 119, 0.32); + border-radius: 8px; + cursor: pointer; +} + +.python-env-btn:hover:not(:disabled) { + background: rgba(246, 193, 119, 0.2); +} + +.python-env-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.python-env-badge { + display: inline-flex; + align-items: center; + gap: 5px; + flex-shrink: 0; + padding: 3px 8px; + font-size: 10.5px; + font-weight: 600; + border-radius: 6px; +} + +.python-env-badge.ready { + color: var(--accent-primary); + background: rgba(142, 230, 199, 0.1); + border: 1px solid rgba(142, 230, 199, 0.28); +} + +.python-env-progress { + display: flex; + flex-direction: column; + gap: 6px; +} + +.python-env-progress-bar { + height: 4px; + overflow: hidden; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); +} + +.python-env-progress-bar > div { + height: 100%; + background: var(--accent-primary); + transition: width 0.25s ease; +} + +.python-env-status { + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 10.5px; + color: var(--text-muted); + word-break: break-word; +} + +.python-env-path { + margin: 0; + overflow: hidden; + font-family: "JetBrains Mono", ui-monospace, monospace; + font-size: 10px; + color: var(--text-muted); + text-overflow: ellipsis; + white-space: nowrap; + opacity: 0.7; +} + /* Clip editor */ .clip-edit-badge { display: inline-flex; diff --git a/tsconfig.json b/tsconfig.json index 596336d..04a70ad 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,5 +17,9 @@ "jsx": "react-jsx" }, "include": ["src"], + // Testes ficam de fora do `tsc` do build de produção: eles importam + // devDependencies (vitest, @testing-library) que podem não existir em + // ambientes que só instalam o necessário para empacotar o app. + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test/**"], "references": [] } From 0614ba88e4de86438a205143efb4d9b3f91f769b Mon Sep 17 00:00:00 2001 From: Leonardo Bezerra Date: Tue, 27 Oct 2026 00:49:53 -0300 Subject: [PATCH 21/25] fix(whisper): resolve the venv CLI once and document the in-app install --- README.md | 2 +- src-tauri/src/transcription.rs | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1332106..f2be014 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: diff --git a/src-tauri/src/transcription.rs b/src-tauri/src/transcription.rs index ea63a75..3d0ffee 100644 --- a/src-tauri/src/transcription.rs +++ b/src-tauri/src/transcription.rs @@ -576,11 +576,13 @@ pub async fn transcribe_local( // fails hard on `--device cuda` when CUDA is unavailable. let device = crate::hardware::whisper_device(); + // Resolved once: probing the venv spawns the CLI, which imports + // torch, so it must not run again for the CPU retry below. + let whisper_cli = crate::python_env::active_whisper_cli() + .unwrap_or_else(|| std::path::PathBuf::from("whisper")); + let build = |device: Option<&str>| { - // Prefer the venv's console script over whatever is on PATH. - let cli = crate::python_env::active_whisper_cli() - .unwrap_or_else(|| std::path::PathBuf::from("whisper")); - let mut cmd = std::process::Command::new(cli); + let mut cmd = std::process::Command::new(&whisper_cli); cmd.arg(&audio_path) .args(["--model", &cli_model]) .args(["--output_format", "json"]) From 8d9928c4c54c8f00beaaf66c6479e243490665eb Mon Sep 17 00:00:00 2001 From: Leonardo Bezerra Date: Tue, 27 Oct 2026 00:54:10 -0300 Subject: [PATCH 22/25] Only windows,mac,linux realease --- .github/workflows/release.yml | 108 +--------------------------------- 1 file changed, 2 insertions(+), 106 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 79c8b1c..f1af7f0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,7 +54,7 @@ jobs: release: # Only real version tags cut a release. On a push to `main` `github.ref_name` # is literally "main", which used to create a bogus `main` tag/release. - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + if: github.event_name == 'push' needs: test permissions: contents: write @@ -108,108 +108,4 @@ jobs: releaseDraft: true prerelease: false - android: - needs: test - if: - github.event_name == 'push' - permissions: - contents: write - runs-on: ubuntu-22.04 - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android - - - 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 JDK - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: 17 - - - name: Install Android SDK - uses: android-actions/setup-android@v3 - - - name: Install Android NDK - run: | - sdkmanager --install "ndk;27.0.12077973" - echo "NDK_HOME=$ANDROID_SDK_ROOT/ndk/27.0.12077973" >> "$GITHUB_ENV" - - - name: Install node modules - run: npm ci - - - name: Init Android project - run: npx tauri android init --ci - - - name: Build Android bundles - run: npx tauri android build --apk --aab - - - name: Collect artifacts - run: | - mkdir -p artifacts/android - find src-tauri/gen/android/app/build/outputs -type f \ - \( -name '*.apk' -o -name '*.aab' \) -exec cp {} artifacts/android/ \; - ls -lh artifacts/android - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: android-bundles - path: artifacts/android/* - - ios: - needs: test - if: - github.event_name == 'push' - permissions: - contents: write - runs-on: macos-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-apple-ios,x86_64-apple-ios,aarch64-apple-ios-sim - - - name: Install node modules - run: npm ci - - - name: Init iOS project - run: npx tauri ios init --ci - - - name: Build iOS app (unsigned) - run: npx tauri ios build --no-sign - - - name: Collect artifacts - run: | - mkdir -p artifacts/ios - find src-tauri/gen/apple/build -maxdepth 4 -name '*.app' -type d -exec cp -R {} artifacts/ios/ \; - find src-tauri/gen/apple/build -maxdepth 4 -name '*.ipa' -type f -exec cp {} artifacts/ios/ \; - ls -lhR artifacts/ios || true - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: ios-app - path: artifacts/ios/** - + \ No newline at end of file From 46e07a2e83fd61949cb33496dcf0b5d838294a08 Mon Sep 17 00:00:00 2001 From: Leonardo Bezerra Date: Tue, 27 Oct 2026 00:58:09 -0300 Subject: [PATCH 23/25] build: limitar alvos a Windows, Linux e macOS - scripts/build-all.mjs: remove alvos android/ios e helpers (androidSdk, ANDROID_RUST_TARGETS, installedRustTargets); adiciona alvo macos (dmg/app) - package.json: build:android/build:ios -> build:macos - README: matriz de plataformas e comandos atualizados --- README.md | 10 +++--- package.json | 3 +- scripts/build-all.mjs | 71 ++++++------------------------------------- 3 files changed, 14 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index f2be014..788eb98 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ Test layout: | `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 + Android + iOS) +### 5. Multi-Platform Build (Windows + Linux + macOS) One command attempts every platform the current host can actually target: ```bash npm run build:all @@ -198,10 +198,9 @@ Each platform is detected first; unsupported ones are skipped with the reason in ```bash npm run build:linux # .deb / .rpm / .AppImage npm run build:windows # .msi / NSIS .exe -npm run build:android # .apk / .aab -npm run build:ios # .app / .ipa (macOS only) +npm run build:macos # .dmg / .app (macOS only) -npm run build:all -- --only=linux,android # subset +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 ``` @@ -212,8 +211,7 @@ Host capability matrix: | --- | --- | | 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. | -| Android | `ANDROID_HOME` (or `~/Android/Sdk`) with NDK, JDK 17, Android Rust targets (added automatically). `tauri android init` runs on first build. | -| iOS | macOS + Xcode only. Cannot be produced from Linux/WSL — use a Mac or the `Release App` GitHub workflow. | +| macOS | macOS + Xcode only. Cannot be produced from Linux/WSL — use a Mac or the `Release App` GitHub workflow. | diff --git a/package.json b/package.json index 6769482..559d96c 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,7 @@ "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:android": "node scripts/build-all.mjs --only=android", - "build:ios": "node scripts/build-all.mjs --only=ios", + "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" diff --git a/scripts/build-all.mjs b/scripts/build-all.mjs index e3be8b8..f242023 100644 --- a/scripts/build-all.mjs +++ b/scripts/build-all.mjs @@ -3,7 +3,7 @@ * 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,android + * npm run build:all -- --only=linux,macos * npm run build:all -- --skip=windows * npm run build:all -- --dry-run # mostra os comandos sem executar * @@ -11,15 +11,13 @@ * - 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 - * - Android : Android SDK + NDK + JDK + rust targets android - * - iOS : SOMENTE macOS + Xcode (impossível a partir de Linux/WSL) + * - 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 os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -190,25 +188,6 @@ const winToolchain = (() => { }; })(); -const androidSdk = () => - firstExisting([ - process.env.ANDROID_HOME, - process.env.ANDROID_SDK_ROOT, - path.join(os.homedir(), 'Android/Sdk'), - ]); - -const ANDROID_RUST_TARGETS = [ - 'aarch64-linux-android', - 'armv7-linux-androideabi', - 'i686-linux-android', - 'x86_64-linux-android', -]; - -function installedRustTargets() { - const out = capture('rustup', ['target', 'list', '--installed']); - return out ? out.split('\n').map((l) => l.trim()) : []; -} - /* ------------------------------------------------------------ build targets */ const targets = [ @@ -332,50 +311,18 @@ const targets = [ }, }, { - id: 'android', - label: 'Android (.apk / .aab)', + id: 'macos', + label: 'macOS (.dmg / .app)', reason() { - const sdk = androidSdk(); - if (!sdk) return 'Android SDK não encontrado (defina ANDROID_HOME ou instale ~/Android/Sdk)'; - if (!fs.existsSync(path.join(sdk, 'ndk'))) return `NDK não encontrado em ${sdk}/ndk`; - if (!which('java')) return 'JDK não encontrado (instale openjdk-17)'; - return null; - }, - build() { - const missingTargets = ANDROID_RUST_TARGETS.filter((t) => !installedRustTargets().includes(t)); - if (missingTargets.length) { - const res = run('rustup', ['target', 'add', ...missingTargets]); - if (res.status !== 0) throw new Error('`rustup target add` para Android falhou'); - } - if (!fs.existsSync(path.join(ROOT, 'src-tauri/gen/android'))) { - const res = run('npx', ['tauri', 'android', 'init']); - if (res.status !== 0) throw new Error('`tauri android init` falhou'); - } - const res = run('npx', ['tauri', 'android', 'build']); - if (res.status !== 0) throw new Error('`tauri android build` falhou'); - collect( - 'android', - [path.join(ROOT, 'src-tauri/gen/android/app/build/outputs')], - ['.apk', '.aab'], - ); - }, - }, - { - id: 'ios', - label: 'iOS (.app / .ipa)', - reason() { - if (!IS_MAC) return 'iOS exige macOS + Xcode — impossível compilar a partir de Linux/WSL'; + 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() { - if (!fs.existsSync(path.join(ROOT, 'src-tauri/gen/apple'))) { - const res = run('npx', ['tauri', 'ios', 'init']); - if (res.status !== 0) throw new Error('`tauri ios init` falhou'); - } - const res = run('npx', ['tauri', 'ios', 'build']); - if (res.status !== 0) throw new Error('`tauri ios build` falhou'); - collect('ios', [path.join(ROOT, 'src-tauri/gen/apple/build')], ['.ipa', '.app']); + 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']); }, }, ]; From a61b705348c834440bbecec78fc9cb128a0be99b Mon Sep 17 00:00:00 2001 From: Leonardo Bezerra Date: Tue, 27 Oct 2026 02:00:30 -0300 Subject: [PATCH 24/25] =?UTF-8?q?feat:=20auto-edit=20de=20clipes=20cortand?= =?UTF-8?q?o=20sil=C3=AAncios=20e=20deixando=20o=20v=C3=ADdeo=20mais=20din?= =?UTF-8?q?=C3=A2mico?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motor novo em `autoedit.rs`: - `detect_silences` roda `ffmpeg silencedetect` na janela do clipe; - `build_plan`/`plan_from_silences` transformam as pausas em lista de cortes, subtraindo da detecção tudo que o transcript diz ser fala (evita cortar voz baixa), aplicando padding nas bordas e alternando zoom punch-in nos trechos mantidos; - `remap_cues` reescreve as legendas na linha do tempo comprimida, removendo as que caíram em pausa cortada e dividindo as que atravessam um corte. Render (`media.rs`): - `ClipRenderRequest` ganhou `has_audio` e `segments`; - com cut list o ffmpeg usa `-filter_complex` (trim/atrim + setpts/atempo por segmento, `concat`, crop/scale e `ass` uma única vez no resultado final) em vez de `-vf`; - `-t` passa a cobrir a janela de origem (não a duração de saída), senão os últimos segmentos eram truncados; - punch-in é aplicado por segmento, antes do concat, porque depois não haveria mais como distinguir os trechos. Persistência: `clip_edits.auto_edit_json` + `AutoEditSettings::sanitized`. UI: painel "Auto-edit: cortar silêncios" no editor do clipe (toggle, limite de silêncio, pausa mínima, respiro, modo cortar/acelerar, velocidade e zoom dinâmico) com resumo em texto e persistência junto do resto do `ClipEdit`. --- src-tauri/src/autoedit.rs | 607 +++++++++++++++++++++++++++++ src-tauri/src/db.rs | 18 +- src-tauri/src/lib.rs | 142 +++++-- src-tauri/src/media.rs | 192 ++++++++- src-tauri/src/models.rs | 104 +++++ src-tauri/tests/db_integration.rs | 3 + src/components/ClipEditor.test.tsx | 61 +++ src/components/ClipEditor.tsx | 157 ++++++++ src/lib/clipEditor.test.ts | 73 ++++ src/lib/clipEditor.ts | 113 ++++++ src/styles.css | 7 + 11 files changed, 1426 insertions(+), 51 deletions(-) create mode 100644 src-tauri/src/autoedit.rs 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 ebea036..b540804 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -111,6 +111,8 @@ impl Database { 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. @@ -497,7 +499,7 @@ impl Database { let edit = conn .query_row( "SELECT candidate_id, aspect_ratio, crop_x, crop_y, captions_json, caption_style, - caption_override_json + caption_override_json, auto_edit_json FROM clip_edits WHERE candidate_id = ?1", params![candidate_id], clip_edit_from_row, @@ -514,13 +516,17 @@ impl Database { 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, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + 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, @@ -528,6 +534,7 @@ impl Database { 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, @@ -537,6 +544,7 @@ impl Database { captions_json, stored.caption_style, override_json, + auto_edit_json, now ], )?; @@ -549,7 +557,7 @@ impl Database { 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.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 @@ -594,6 +602,7 @@ 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)?, @@ -602,6 +611,7 @@ fn clip_edit_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { 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()), }) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 437c99f..d233fd3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +pub mod autoedit; pub mod db; pub mod hardware; pub mod llm; @@ -1013,8 +1014,11 @@ async fn render_flat_clip_for_candidate( let mut caption_ass_path: Option = None; // Probe once and reuse the result for the whole render. - let has_video = media::probe_media(&project.source_path) - .map(|probe| probe.has_video) + let probe = media::probe_media(&project.source_path).ok(); + 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 @@ -1025,45 +1029,80 @@ async fn render_flat_clip_for_candidate( 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, + &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. - let ass_content = if !edit.captions.is_empty() { + // 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( - &edit.captions, - candidate.start_sec, - candidate.end_sec, + &cues, + caption_start, + caption_end, &caption_style, edit.caption_override.as_ref(), )) - } else { - match db.latest_transcript(&project.id) { - Ok(Some(transcript_record)) => { - serde_json::from_str::(&transcript_record.raw_json) - .ok() - .map(|normalized| { - // Plain-text sidecar kept for the UI / upload metadata. - let srt_content = generate_srt( - &normalized.words, - candidate.start_sec, - candidate.end_sec, - ); - 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. - build_ass_captions( - &normalized.words, - candidate.start_sec, - candidate.end_sec, - &caption_style, - edit.caption_override.as_ref(), - ) - }) - } - _ => None, - } }; if let Some(ass_content) = ass_content { @@ -1074,16 +1113,23 @@ async fn render_flat_clip_for_candidate( } } + 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| { @@ -1431,6 +1477,7 @@ fn to_command_error(error: impl Into) -> String { message } +#[cfg(test)] fn generate_srt(words: &[TranscriptWord], start_sec: f64, end_sec: f64) -> String { let mut srt = String::new(); let mut index = 1; @@ -1468,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; @@ -1656,6 +1725,7 @@ fn ass_escape_text(text: &str) -> String { /// 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, diff --git a/src-tauri/src/media.rs b/src-tauri/src/media.rs index 2e5839b..46dc2ba 100644 --- a/src-tauri/src/media.rs +++ b/src-tauri/src/media.rs @@ -7,6 +7,7 @@ use std::{ use anyhow::{anyhow, Context, Result}; use serde_json::Value; +use crate::autoedit::RenderSegment; use crate::hardware; use crate::models::MediaProbe; @@ -411,6 +412,8 @@ pub struct ClipRenderRequest<'a> { 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`]. @@ -419,6 +422,8 @@ pub struct ClipRenderRequest<'a> { 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 { @@ -465,7 +470,16 @@ pub fn render_flat_clip( std::fs::create_dir_all(parent)?; } - let duration_sec = (request.end_sec - request.start_sec).max(0.1); + 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 @@ -518,29 +532,67 @@ fn run_clip_ffmpeg( 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 duration = format!("{duration_sec:.3}"); + + 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(&plan.pre_input); cmd.args(["-y", "-ss", &start, "-i", &source_arg, "-t", &duration]); 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 request.has_video { + 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(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()); - } - if let Some(filter) = ass_filter_arg(&ass_path) { - filters.push(filter); - } + if let Some(filter) = ass_filter { + filters.push(filter); } let mut filter_chain = filters.join(","); @@ -566,6 +618,124 @@ fn run_clip_ffmpeg( 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}]")); + } + } + + 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::*; diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 7639e7f..2f88b9b 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -140,6 +140,108 @@ pub struct ClipEdit { /// `caption_style` field by field. #[serde(default)] pub caption_override: Option, + /// Automatic tight-cut settings (silence removal + pacing). + #[serde(default)] + pub auto_edit: Option, +} + +/// Auto-edit knobs for one clip. +/// +/// `silence_mode` decides what happens to a detected pause: `cut` removes it +/// (jump cut) while `speed` keeps it but plays it faster, which reads as a +/// natural time-lapse instead of a hard jump. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutoEditSettings { + #[serde(default)] + pub enabled: bool, + /// Loudness below which audio counts as silence, in dBFS. + #[serde(default = "default_silence_threshold_db")] + pub silence_threshold_db: f64, + /// Shortest pause worth touching, in seconds. + #[serde(default = "default_min_silence_sec")] + pub min_silence_sec: f64, + /// Audio kept on each side of a cut, so consonants are not clipped. + #[serde(default = "default_padding_sec")] + pub padding_sec: f64, + /// `cut` or `speed`. + #[serde(default = "default_silence_mode")] + pub silence_mode: String, + /// Playback rate used when `silence_mode` is `speed`. + #[serde(default = "default_silence_speed")] + pub silence_speed: f64, + /// Alternating punch-in zoom on speech segments; 1.0 disables it. + #[serde(default = "default_punch_in_zoom")] + pub punch_in_zoom: f64, +} + +fn default_silence_threshold_db() -> f64 { + -32.0 +} + +fn default_min_silence_sec() -> f64 { + 0.45 +} + +fn default_padding_sec() -> f64 { + 0.08 +} + +fn default_silence_mode() -> String { + "cut".to_string() +} + +fn default_silence_speed() -> f64 { + 1.6 +} + +fn default_punch_in_zoom() -> f64 { + 1.0 +} + +impl Default for AutoEditSettings { + fn default() -> Self { + Self { + enabled: false, + silence_threshold_db: default_silence_threshold_db(), + min_silence_sec: default_min_silence_sec(), + padding_sec: default_padding_sec(), + silence_mode: default_silence_mode(), + silence_speed: default_silence_speed(), + punch_in_zoom: default_punch_in_zoom(), + } + } +} + +impl AutoEditSettings { + /// True when detected pauses should be kept and accelerated. + pub fn keeps_silence(&self) -> bool { + self.silence_mode == "speed" + } + + /// Clamp every value into a range the renderer can honour. + pub fn sanitized(mut self) -> Self { + self.silence_threshold_db = clamp_finite(self.silence_threshold_db, -60.0, -8.0, -32.0); + self.min_silence_sec = clamp_finite(self.min_silence_sec, 0.15, 5.0, 0.45); + self.padding_sec = clamp_finite(self.padding_sec, 0.0, 0.5, 0.08); + self.silence_speed = clamp_finite(self.silence_speed, 1.1, 4.0, 1.6); + self.punch_in_zoom = clamp_finite(self.punch_in_zoom, 1.0, 1.4, 1.0); + let mode = self.silence_mode.trim().to_ascii_lowercase(); + self.silence_mode = if mode == "speed" { + "speed".to_string() + } else { + "cut".to_string() + }; + self + } +} + +fn clamp_finite(value: f64, min: f64, max: f64, fallback: f64) -> f64 { + if value.is_finite() { + value.clamp(min, max) + } else { + fallback + } } /// Caption look edited in the clip editor. @@ -235,6 +337,7 @@ impl ClipEdit { captions: Vec::new(), caption_style: None, caption_override: None, + auto_edit: None, } } @@ -248,6 +351,7 @@ impl ClipEdit { .map(|style| style.trim().to_string()) .filter(|style| !style.is_empty()); self.caption_override = self.caption_override.map(|value| value.sanitized()); + self.auto_edit = self.auto_edit.map(|value| value.sanitized()); self.captions.retain(|cue| cue.end > cue.start && !cue.text.trim().is_empty()); self } diff --git a/src-tauri/tests/db_integration.rs b/src-tauri/tests/db_integration.rs index 4cb7a56..8ace226 100644 --- a/src-tauri/tests/db_integration.rs +++ b/src-tauri/tests/db_integration.rs @@ -397,6 +397,7 @@ fn clip_edits_round_trip_with_captions() { ], caption_style: Some("classic-outline".to_string()), caption_override: None, + auto_edit: None, }; let stored = db.save_clip_edit(&edit).expect("saving edit"); @@ -456,6 +457,7 @@ fn save_clip_edit_sanitizes_out_of_range_values() { }], caption_style: Some(" ".to_string()), caption_override: None, + auto_edit: None, }) .expect("saving edit"); @@ -499,6 +501,7 @@ fn caption_override_round_trips_and_sanitizes() { position: Some(0.25), max_width: Some(0.8), }), + auto_edit: None, }; let stored = db.save_clip_edit(&edit).expect("saving edit"); diff --git a/src/components/ClipEditor.test.tsx b/src/components/ClipEditor.test.tsx index 75e1c23..181c2fb 100644 --- a/src/components/ClipEditor.test.tsx +++ b/src/components/ClipEditor.test.tsx @@ -443,3 +443,64 @@ describe("ClipEditor", () => { expect(alert).toHaveTextContent("pré-visualização"); }); }); + +describe("ClipEditor auto-edit", () => { + it("saves null when auto-edit stays off", async () => { + const { onSaved } = renderEditor(); + await screen.findByTestId("clip-editor-video"); + + fireEvent.click(screen.getByRole("button", { name: /^Save$/ })); + + await waitFor(() => expect(onSaved).toHaveBeenCalled()); + const [, args] = invokeMock.mock.calls.find(([command]) => command === "save_clip_edit")!; + expect((args as { edit: { autoEdit: unknown } }).edit.autoEdit).toBeNull(); + }); + + it("sends sanitized settings once the toggle is enabled", async () => { + const { onSaved } = renderEditor(); + await screen.findByTestId("clip-editor-video"); + + fireEvent.click(screen.getByLabelText("Ativar auto-edit")); + await screen.findByText(/pausas cortadas/); + + fireEvent.click(screen.getByRole("button", { name: /^Save$/ })); + + await waitFor(() => expect(onSaved).toHaveBeenCalled()); + const [, args] = invokeMock.mock.calls.find(([command]) => command === "save_clip_edit")!; + const autoEdit = (args as { edit: { autoEdit: Record } }).edit.autoEdit; + expect(autoEdit).toMatchObject({ + enabled: true, + silenceMode: "cut", + silenceThresholdDb: -32, + minSilenceSec: 0.45, + }); + }); + + it("restores saved settings from the backend payload", async () => { + renderEditor( + buildPayload({ + edit: { + candidateId: "cand-1", + aspectRatio: "9:16", + cropX: 0.5, + cropY: 0.5, + captions: [], + captionStyle: null, + autoEdit: { + enabled: true, + silenceThresholdDb: -40, + minSilenceSec: 0.6, + paddingSec: 0.1, + silenceMode: "speed", + silenceSpeed: 2.5, + punchInZoom: 1.12, + }, + }, + }), + ); + + const toggle = (await screen.findByLabelText("Ativar auto-edit")) as HTMLInputElement; + expect(toggle.checked).toBe(true); + expect(screen.getByText(/pausas em 2.5x/)).toBeTruthy(); + }); +}); diff --git a/src/components/ClipEditor.tsx b/src/components/ClipEditor.tsx index 70fa001..4bf54aa 100644 --- a/src/components/ClipEditor.tsx +++ b/src/components/ClipEditor.tsx @@ -14,8 +14,10 @@ import { import { ASPECT_PRESETS, CAPTION_STYLES, + DEFAULT_AUTO_EDIT, DEFAULT_CAPTION_STYLE, FONT_FAMILIES, + autoEditSummary, canPlayDirectly, captionPreviewStyle, clampCues, @@ -24,6 +26,7 @@ import { isLinuxWebview, needsProxyFor, normalizeAspect, + normalizeAutoEdit, resolveCaptionStyle, sanitizeHex, stageMaxWidth, @@ -31,6 +34,7 @@ import { toSourceTime, } from "../lib/clipEditor"; import type { + AutoEditSettings, CaptionCue, CaptionStyleOverride, ClipEdit, @@ -99,6 +103,7 @@ export function ClipEditor({ candidateId, onClose, onSaved, onBulkSaved, onRange const [cues, setCues] = useState([]); const [captionStyle, setCaptionStyle] = useState(DEFAULT_CAPTION_STYLE); const [captionOverride, setCaptionOverride] = useState({}); + const [autoEdit, setAutoEdit] = useState(DEFAULT_AUTO_EDIT); const [currentTime, setCurrentTime] = useState(0); const [playing, setPlaying] = useState(false); @@ -135,6 +140,7 @@ export function ClipEditor({ candidateId, onClose, onSaved, onBulkSaved, onRange setCues(Array.isArray(payload.cues) ? payload.cues : payload.edit?.captions ?? []); setCaptionStyle(payload.captionStyle || payload.edit?.captionStyle || DEFAULT_CAPTION_STYLE); setCaptionOverride(payload.edit?.captionOverride ?? {}); + setAutoEdit({ ...DEFAULT_AUTO_EDIT, ...(payload.edit?.autoEdit ?? {}) }); setRangeStart(payload.candidate?.startSec ?? 0); setRangeEnd(payload.candidate?.endSec ?? 0); setStyleNotice(null); @@ -369,6 +375,10 @@ export function ClipEditor({ candidateId, onClose, onSaved, onBulkSaved, onRange setCaptionOverride((current) => ({ ...current, ...patch })); } + function updateAutoEdit(patch: Partial) { + setAutoEdit((current) => ({ ...current, ...patch })); + } + /** Drop every override so the preset renders untouched again. */ function resetOverride() { setCaptionOverride({}); @@ -464,6 +474,7 @@ export function ClipEditor({ candidateId, onClose, onSaved, onBulkSaved, onRange captions: clampCues(cues, 0, Number.POSITIVE_INFINITY), captionStyle: captionStyle || DEFAULT_CAPTION_STYLE, captionOverride: hasOverride ? captionOverride : null, + autoEdit: autoEdit.enabled ? normalizeAutoEdit(autoEdit) : null, }; try { const saved = await invoke("save_clip_edit", { edit }); @@ -737,6 +748,152 @@ export function ClipEditor({ candidateId, onClose, onSaved, onBulkSaved, onRange
+
+ Auto-edit: cortar silêncios + +
+ +
+ +

+ {autoEditSummary(autoEdit)} +

+ + {autoEdit.enabled && ( + <> +
+ + + updateAutoEdit({ silenceThresholdDb: Number(event.target.value) }) + } + /> +
+ +
+ + + updateAutoEdit({ minSilenceSec: Number(event.target.value) }) + } + /> +
+ +
+ + + updateAutoEdit({ paddingSec: Number(event.target.value) }) + } + /> +
+ +
+ + +
+ + {autoEdit.silenceMode === "speed" && ( +
+ + + updateAutoEdit({ silenceSpeed: Number(event.target.value) }) + } + /> +
+ )} + +
+ + + updateAutoEdit({ punchInZoom: Number(event.target.value) }) + } + /> +
+ +
+ +
+ + )} +
+