commit 8f10353f0c12840c0aed0ff354fa8d1e61d93ebc Author: wehub-resource-sync Date: Mon Jul 13 12:32:09 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cc05d9a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.venv/ +jobs/ +**/__pycache__/ +*.pyc +.git/ +.gitignore +.idea/ +.vscode/ +.DS_Store +.python-version +README.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..0b7a090 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,72 @@ +name: Bug report +description: Something isn't working as expected. +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: Thanks for reporting a bug. Please fill in the details so we can reproduce it. + - type: checkboxes + id: preflight + attributes: + label: Before reporting + options: + - label: I'm on the latest release and the issue still happens. + required: true + - label: I searched existing issues and didn't find a duplicate. + required: true + - type: textarea + id: what + attributes: + label: What happened? + description: A clear description of the bug and what you expected instead. + placeholder: When I ..., StemDeck ... but I expected ... + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. Open StemDeck + 2. Import ... + 3. Click ... + 4. See error + validations: + required: true + - type: dropdown + id: os + attributes: + label: Operating system + options: + - macOS (Apple Silicon) + - macOS (Intel) + - Windows + - Linux + - Other + validations: + required: true + - type: input + id: version + attributes: + label: StemDeck version + description: Shown in the About dialog (Help icon). + placeholder: e.g. v0.7.0-alpha.12 + validations: + required: true + - type: dropdown + id: install + attributes: + label: How did you install it? + options: + - macOS DMG + - Windows ZIP + - From source + - Docker / self-hosted + validations: + required: true + - type: textarea + id: extra + attributes: + label: Logs / screenshots + description: Drag in screenshots or a recording, and paste any error text as code. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a86dc9f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Questions & Discussion + url: https://github.com/stemdeckapp/stemdeck/discussions + about: Ask questions, share setups, or discuss ideas with the community. + - name: Discord + url: https://discord.gg/2MVsWqaPRe + about: Chat with the StemDeck community in real time. + - name: Report a security vulnerability + url: https://github.com/stemdeckapp/stemdeck/security/advisories/new + about: Please report security issues privately, not as a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..f70e620 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,25 @@ +name: Feature request +description: Suggest an idea or improvement. +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: Thanks for the suggestion! Tell us what you'd like and why. + - type: textarea + id: problem + attributes: + label: What problem does this solve? + description: What are you trying to do that's hard or impossible today? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ca79ca5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..435fc62 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,143 @@ +# GitHub Actions: lint + unit tests + security scans. +# Does not build or publish artifacts. Image scanning is done via +# trivy fs on the project tree (covers deps, secrets, and Dockerfile +# misconfig) so CI does not need a docker-in-docker setup. + +name: CI + +on: + pull_request: + push: + branches: [main] + release: + types: [published] + +env: + UV_LINK_MODE: copy + # Version is git-derived (hatch-vcs). CI's clone is shallow/tagless, which + # makes setuptools_scm raise, so pin a placeholder for the build -- CI only + # lints/tests and never publishes. #169 + SETUPTOOLS_SCM_PRETEND_VERSION: "0.0.0" + +jobs: + lint: + runs-on: ubuntu-latest + container: + image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim + steps: + - uses: actions/checkout@v7 + - run: uv sync --frozen --all-extras + - run: uv run ruff check app/ tests/ + - run: uv run ruff format --check app/ tests/ + - run: bash -n run.sh + + test: + runs-on: ubuntu-latest + container: + image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim + steps: + - uses: actions/checkout@v7 + - run: apt-get update && apt-get install -y --no-install-recommends ffmpeg + - run: uv sync --frozen --all-extras + - run: uv run pytest tests/ -q + + js-syntax: + runs-on: ubuntu-latest + container: + image: node:20-alpine + steps: + - uses: actions/checkout@v7 + - run: for f in static/js/*.js; do node --check "$f"; done + + sast-bandit: + runs-on: ubuntu-latest + container: + image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim + steps: + - uses: actions/checkout@v7 + - run: uv tool install bandit + - run: uv tool run bandit -r app/ -ll # fail on medium+ severity + + deps-audit: + runs-on: ubuntu-latest + container: + image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim + steps: + - uses: actions/checkout@v7 + - run: uv tool install pip-audit + - run: uv pip compile pyproject.toml -o /tmp/requirements.txt + # Ignored CVEs (review when upgrading torch or demucs): + # + # torch 2.6.0 -- pinned to <2.7 because torchaudio 2.7+ removed its + # built-in audio writer and now requires torchcodec, which has ABI + # issues that break demucs 4.0.1's torchaudio.save() path. All torch + # CVEs below are in ops that StemDeck does not invoke; risk on a + # local-only, single-user app is negligible. Re-evaluate once demucs + # supports torch 2.7+ without torchcodec. + # + # joblib PYSEC-2024-277 -- no fix version available as of 2026-05-21 + # (1.5.3 is latest). joblib is a transitive dep via demucs/librosa; + # StemDeck does not directly invoke joblib serialization. Drop once + # a patched release is available. + - run: | + uv tool run pip-audit -r /tmp/requirements.txt --strict \ + --ignore-vuln CVE-2025-2953 \ + --ignore-vuln CVE-2025-3730 \ + --ignore-vuln PYSEC-2025-189 \ + --ignore-vuln PYSEC-2025-190 \ + --ignore-vuln PYSEC-2025-192 \ + --ignore-vuln PYSEC-2025-193 \ + --ignore-vuln PYSEC-2025-194 \ + --ignore-vuln PYSEC-2025-195 \ + --ignore-vuln PYSEC-2025-196 \ + --ignore-vuln PYSEC-2025-197 \ + --ignore-vuln PYSEC-2025-198 \ + --ignore-vuln PYSEC-2025-199 \ + --ignore-vuln PYSEC-2025-200 \ + --ignore-vuln PYSEC-2025-201 \ + --ignore-vuln PYSEC-2025-202 \ + --ignore-vuln PYSEC-2025-203 \ + --ignore-vuln PYSEC-2025-204 \ + --ignore-vuln PYSEC-2025-205 \ + --ignore-vuln PYSEC-2025-206 \ + --ignore-vuln PYSEC-2025-207 \ + --ignore-vuln PYSEC-2025-208 \ + --ignore-vuln PYSEC-2025-209 \ + --ignore-vuln PYSEC-2025-210 \ + --ignore-vuln PYSEC-2026-139 \ + --ignore-vuln PYSEC-2024-277 \ + --ignore-vuln CVE-2025-2148 \ + --ignore-vuln CVE-2025-2149 \ + --ignore-vuln CVE-2025-2998 \ + --ignore-vuln CVE-2025-2999 \ + --ignore-vuln CVE-2025-3000 \ + --ignore-vuln CVE-2025-3001 + + trivy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + # Scans the source tree for: known CVEs in deps, leaked secrets, + # and Dockerfile / compose misconfigurations. Skips .venv (it can + # be left over from earlier steps in the shared workspace; trivy + # would scan its bundled extractor files and flag false-positive + # secrets that ship inside third-party packages like yt-dlp). + - name: trivy fs + uses: aquasecurity/trivy-action@master + with: + scan-type: fs + scan-ref: . + scanners: vuln,secret,misconfig + severity: HIGH,CRITICAL + exit-code: '1' + ignore-unfixed: true + trivyignores: .trivyignore + skip-dirs: .venv,jobs + # Dedicated Dockerfile + compose static analysis (Trivy's IaC linter). + - name: trivy config + uses: aquasecurity/trivy-action@master + with: + scan-type: config + scan-ref: build/ + severity: HIGH,CRITICAL + exit-code: '1' diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..19e948e --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,94 @@ +name: Docker Publish + +# Builds the server container (build/Dockerfile) and pushes it to GHCR so it can +# be pulled by self-hosted deployments and the Unraid Community Applications app +# (ghcr.io/stemdeckapp/stemdeck). The image keeps the default Linux x86_64 torch +# wheel, which is the CUDA build -- so the same image runs on CPU by default and +# uses the GPU automatically when started with `--runtime=nvidia` (see the +# Unraid template in templates/unraid/). + +on: + # Every merge to main publishes a rolling :edge image. The version is derived + # from git (hatch-vcs style); :edge never clobbers :latest. + push: + branches: [main] + release: + # prereleased: a prerelease is published -> push the version tag only. + # released: a stable release is published, OR a prerelease is promoted to + # a full/Latest release -> push the version tag AND :latest. + # (Listening to these two instead of `published` avoids a double run on a + # stable publish, which fires both `published` and `released`.) + types: [prereleased, released] + # Same as a main push but on demand, from any ref. + workflow_dispatch: + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + IMAGE: ghcr.io/${{ github.repository_owner }}/stemdeck + +jobs: + build-and-push: + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + # Full history + tags so git describe can derive a version on manual runs. + fetch-depth: 0 + + # Derive the version fed to the Dockerfile's VERSION build-arg (consumed as + # SETUPTOOLS_SCM_PRETEND_VERSION, so it MUST be PEP 440-valid). On a + # release, use the tag. Otherwise derive from git: `git describe --long` + # yields `--g`, which is NOT PEP 440 -- rewrite the trailing + # `-N-gSHA` into a `+N.gSHA` local segment (e.g. 0.8.0-alpha.5+3.gce86e8a). + - name: resolve version + id: ver + run: | + if [ "${{ github.event_name }}" = "release" ]; then + v="${{ github.event.release.tag_name }}" + else + raw="$(git describe --tags --long 2>/dev/null || echo "0.0.0-0-g$(git rev-parse --short HEAD)")" + v="$(printf '%s' "${raw#v}" | sed -E 's/-([0-9]+)-g([0-9a-f]+)$/+\1.g\2/')" + fi + echo "value=${v#v}" >> "$GITHUB_OUTPUT" + + - name: docker metadata (tags/labels) + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ${{ env.IMAGE }} + tags: | + type=raw,value=edge,enable=${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }} + type=raw,value=${{ steps.ver.outputs.value }},enable=${{ github.event_name == 'release' }} + type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.action == 'released' }} + + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: log in to GHCR + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: build and push + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: build/Dockerfile + # Unraid is x86_64; arm64 has no CUDA torch and is not a target. + platforms: linux/amd64 + push: true + build-args: | + VERSION=${{ steps.ver.outputs.value }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + provenance: false diff --git a/.github/workflows/linux-release.yml b/.github/workflows/linux-release.yml new file mode 100644 index 0000000..074d6bb --- /dev/null +++ b/.github/workflows/linux-release.yml @@ -0,0 +1,147 @@ +name: Linux Release + +on: + release: + types: [published] + # Manual test build: builds and scans both variants on the self-hosted runner + # but does NOT upload (no release to attach to). Lets you validate the runner + # toolchain and the CUDA build without cutting a release tag. + workflow_dispatch: + inputs: + version: + description: "Version for the test build (must be valid PEP 440, e.g. 0.0.0)" + required: false + default: "0.0.0" + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-upload: + # Runner must be linux/x64 with rustup, Node.js, Docker, and sudo apt access. + runs-on: [self-hosted, linux, x64] + timeout-minutes: 90 + permissions: + contents: write + defaults: + run: + shell: bash + steps: + - name: clean workspace + run: rm -rf .build dist + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: install build dependencies + env: + DEBIAN_FRONTEND: noninteractive + run: | + # Tauri v2 build deps + tooling. webkit2gtk-4.1 matches the tauri = "2" + # crate; ffmpeg is a runtime dependency, not bundled. + # + # The self-hosted runner does NOT have passwordless sudo, so a bare + # `sudo apt-get` hangs forever at the password prompt. These packages are + # already installed on the persistent runner, so check first (dpkg needs + # no sudo) and skip apt entirely when everything is present. Only if a + # package is genuinely missing do we touch apt, via `sudo -n` (fails fast + # instead of prompting) so a release never hangs on sudo again. + PKGS="build-essential curl file wget libssl-dev libxdo-dev \ + libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev" + missing="" + for p in $PKGS; do + dpkg -s "$p" >/dev/null 2>&1 || missing="$missing $p" + done + if [ -z "$missing" ]; then + echo "All build dependencies already installed; skipping apt." + exit 0 + fi + echo "Missing packages:$missing" + if ! sudo -n true 2>/dev/null; then + echo "ERROR: build deps missing and passwordless sudo is not available." >&2 + echo "Install on the runner: sudo apt-get install -y$missing" >&2 + exit 1 + fi + sudo -n apt-get update -o DPkg::Lock::Timeout=120 + sudo -n apt-get install -y --no-install-recommends -o DPkg::Lock::Timeout=120 $missing + + - name: install uv + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: install rust toolchain + run: rustup default stable + + - name: write version files + env: + DISPATCH_VERSION: ${{ inputs.version }} + # github.ref_name (evaluated by Actions) instead of $GITHUB_REF_NAME, + # which is only injected by runner >= 2.290 — empty on older + # self-hosted runners. + REF_NAME: ${{ github.ref_name }} + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${DISPATCH_VERSION#v}" + else + VERSION="${REF_NAME#v}" + fi + if [ -z "$VERSION" ]; then + echo "Could not determine a version" >&2 + exit 1 + fi + printf '{ "version": "%s" }\n' "$VERSION" > static/version.json + sed -i "s/^version = \".*\"/version = \"$VERSION\"/" desktop/src-tauri/Cargo.toml + sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" desktop/src-tauri/tauri.conf.json + sed -i "s/^version = \".*\"/version = \"$VERSION\"/" pyproject.toml + sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" desktop/package.json + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + echo "Wrote version $VERSION to all version files" + + - name: build Linux CPU + run: | + PACKAGE_NAME=StemDeck-Linux-x64 \ + PACKAGE_VERSION="$VERSION" \ + bash scripts/linux/make-portable.sh + # Drop the uncompressed stage but keep the tarball; frees disk for the + # larger NVIDIA build. The Tauri binary under target/ is preserved. + rm -rf dist/StemDeck-Linux-x64 + + - name: build Linux NVIDIA + run: | + # CPU_ONLY=0 keeps the CUDA torch wheel; SKIP_TAURI_BUILD=1 reuses the + # binary built in the CPU step (identical for both variants). + PACKAGE_NAME=StemDeck-Linux-x64.NVIDIA \ + PACKAGE_VERSION="$VERSION" \ + CPU_ONLY=0 \ + SKIP_TAURI_BUILD=1 \ + bash scripts/linux/make-portable.sh + rm -rf dist/StemDeck-Linux-x64.NVIDIA + + - name: scan artifacts + run: | + echo "Artifacts staged in: $PWD/dist" + ls -lh dist + echo "SHA256 checksums:" + ( cd dist && sha256sum *.tar.gz ) + + echo "Pulling latest ClamAV scanner image..." + docker pull clamav/clamav:latest + + echo "Running ClamAV scan over dist/..." + docker run --rm -v "$PWD/dist:/scan:ro" clamav/clamav:latest \ + clamscan --recursive --infected --bell /scan + echo "ClamAV scan completed successfully. No infected files reported." + + - name: upload artifacts + # Only attach to a real release; a manual test build has nothing to upload to. + if: github.event_name == 'release' + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + with: + files: | + dist/StemDeck-Linux-x64.tar.gz + dist/StemDeck-Linux-x64.tar.gz.sha256 + dist/StemDeck-Linux-x64.NVIDIA.tar.gz + dist/StemDeck-Linux-x64.NVIDIA.tar.gz.sha256 diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml new file mode 100644 index 0000000..07372b1 --- /dev/null +++ b/.github/workflows/macos-release.yml @@ -0,0 +1,128 @@ +name: macOS Release + +on: + release: + types: [published] + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-upload: + # Runner must be darwin/arm64 with Rosetta 2, Xcode CLT, Homebrew, rustup, and zstd. + runs-on: [self-hosted, macOS, ARM64] + timeout-minutes: 120 + permissions: + contents: write + defaults: + run: + shell: bash + steps: + - name: clean workspace + run: rm -rf .build dist + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: write version files + env: + # github.ref_name (evaluated by Actions) instead of $GITHUB_REF_NAME, + # which is only injected by runner >= 2.290 — empty on older + # self-hosted runners. + REF_NAME: ${{ github.ref_name }} + run: | + if [ -z "${REF_NAME:-}" ]; then + echo "REF_NAME is not set" >&2 + exit 1 + fi + VERSION="${REF_NAME#v}" + printf '{ "version": "%s" }\n' "$VERSION" > static/version.json + sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" desktop/src-tauri/Cargo.toml + sed -i '' "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" desktop/src-tauri/tauri.conf.json + sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" pyproject.toml + sed -i '' "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" desktop/package.json + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + echo "Wrote version $VERSION to all version files" + + - name: build macOS arm64 + env: + SSL_CERT_FILE: /etc/ssl/cert.pem + REQUESTS_CA_BUNDLE: /etc/ssl/cert.pem + run: | + command -v zstd + command -v uv >/dev/null 2>&1 || brew install uv + uv python install cpython-3.12-macos-aarch64-none + ARM64_PYTHON="$(uv python find cpython-3.12-macos-aarch64-none)" + scripts/macos/make-iconset.sh + ARCH=arm64 VERSION="$VERSION" PYTHON_BIN="$ARM64_PYTHON" scripts/macos/make-runtime-pack.sh + ARCH=arm64 VERSION="$VERSION" scripts/macos/make-app.sh + ARCH=arm64 VERSION="$VERSION" scripts/macos/make-dmg.sh + + - name: build macOS x64 + env: + SSL_CERT_FILE: /etc/ssl/cert.pem + REQUESTS_CA_BUNDLE: /etc/ssl/cert.pem + run: | + command -v zstd + if ! arch -x86_64 /usr/bin/true >/dev/null 2>&1; then + echo "ERROR: Rosetta 2 is required to build the x64 runtime on this agent." >&2 + exit 1 + fi + rustup default stable + rustup target add x86_64-apple-darwin + command -v uv >/dev/null 2>&1 || brew install uv + uv python install cpython-3.12-macos-x86_64-none + X64_PYTHON="$(uv python find cpython-3.12-macos-x86_64-none)" + ARCH=x64 VERSION="$VERSION" PYTHON_BIN="$X64_PYTHON" scripts/macos/make-runtime-pack.sh + ARCH=x64 VERSION="$VERSION" scripts/macos/make-app.sh + ARCH=x64 VERSION="$VERSION" scripts/macos/make-dmg.sh + + - name: inspect artifacts + run: | + test -f .build/macos-dist/StemDeck-macOS-arm64.dmg + test -f .build/StemDeck-runtime-macOS-arm64.tar.zst + test -f .build/macos-dist/SHA256SUMS-macOS-arm64.txt + test -f .build/macos-dist/StemDeck-macOS-x64.dmg + test -f .build/StemDeck-runtime-macOS-x64.tar.zst + test -f .build/macos-dist/SHA256SUMS-macOS-x64.txt + cat .build/macos-dist/SHA256SUMS-macOS-arm64.txt + cat .build/macos-dist/SHA256SUMS-macOS-x64.txt + du -sh .build/macos-dist/StemDeck-macOS-arm64.dmg .build/StemDeck-runtime-macOS-arm64.tar.zst + du -sh .build/macos-dist/StemDeck-macOS-x64.dmg .build/StemDeck-runtime-macOS-x64.tar.zst + if find desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/StemDeck.app \ + \( -iname '*python*' -o -iname '*torch*' -o -iname '*ffmpeg*' -o -iname '*ffprobe*' \) | + grep -q .; then + echo "arm64 StemDeck.app contains runtime binaries that should stay outside the DMG." >&2 + exit 1 + fi + if find desktop/src-tauri/target/x86_64-apple-darwin/release/bundle/macos/StemDeck.app \ + \( -iname '*python*' -o -iname '*torch*' -o -iname '*ffmpeg*' -o -iname '*ffprobe*' \) | + grep -q .; then + echo "x64 StemDeck.app contains runtime binaries that should stay outside the DMG." >&2 + exit 1 + fi + for arch in arm64 x64; do + mountpoint="$(mktemp -d /tmp/stemdeck-dmg.XXXXXX)" + hdiutil attach ".build/macos-dist/StemDeck-macOS-$arch.dmg" -readonly -nobrowse -mountpoint "$mountpoint" + trap 'hdiutil detach "$mountpoint" >/dev/null 2>&1 || true; rmdir "$mountpoint" >/dev/null 2>&1 || true' EXIT + test -d "$mountpoint/StemDeck.app" + test -L "$mountpoint/Applications" + test -f "$mountpoint/README-macOS.txt" + test -f "$mountpoint/THIRD_PARTY_NOTICES.txt" + hdiutil detach "$mountpoint" + rmdir "$mountpoint" + trap - EXIT + done + + - name: upload artifacts + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + with: + files: | + .build/macos-dist/StemDeck-macOS-arm64.dmg + .build/StemDeck-runtime-macOS-arm64.tar.zst + .build/macos-dist/SHA256SUMS-macOS-arm64.txt + .build/macos-dist/StemDeck-macOS-x64.dmg + .build/StemDeck-runtime-macOS-x64.tar.zst + .build/macos-dist/SHA256SUMS-macOS-x64.txt diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml new file mode 100644 index 0000000..67939c4 --- /dev/null +++ b/.github/workflows/windows-release.yml @@ -0,0 +1,103 @@ +name: Windows Release + +on: + release: + types: [published] + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-upload: + # Runner must be windows/x64 with PowerShell, Docker, and rustup. + runs-on: [self-hosted, windows, x64] + timeout-minutes: 90 + permissions: + contents: write + # Source the tag from the github context (evaluated by Actions) rather than + # $env:GITHUB_REF_NAME, which is only injected by runner >= 2.290. Keeps the + # build working on older self-hosted runners. (#212 follow-up) + env: + REF_NAME: ${{ github.ref_name }} + defaults: + run: + shell: powershell + steps: + - name: clean workspace + run: | + Remove-Item -Recurse -Force .build, dist -ErrorAction SilentlyContinue + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: write version files + run: | + $tag = $env:REF_NAME + if (-not $tag) { throw "REF_NAME is not set" } + $version = $tag -replace '^v', '' + $json = "{`"version`": `"$version`"}" + Set-Content -Path "static/version.json" -Value $json -Encoding UTF8 + (Get-Content "desktop/src-tauri/Cargo.toml") -replace '^version = ".*"', "version = `"$version`"" | + Set-Content "desktop/src-tauri/Cargo.toml" + (Get-Content "desktop/src-tauri/tauri.conf.json") -replace '"version": "[^"]*"', "`"version`": `"$version`"" | + Set-Content "desktop/src-tauri/tauri.conf.json" + (Get-Content "pyproject.toml") -replace '^version = ".*"', "version = `"$version`"" | + Set-Content "pyproject.toml" + (Get-Content "desktop/package.json") -replace '"version": "[^"]*"', "`"version`": `"$version`"" | + Set-Content "desktop/package.json" + Write-Host "Wrote version $version to all version files" + + - name: build Windows NVIDIA + run: | + powershell -NoProfile -ExecutionPolicy Bypass -File scripts/windows/make-portable.ps1 ` + -PackageName StemDeck-Windows-x64.NVIDIA ` + -PackageVersion "$env:REF_NAME" ` + -StripVenv + + - name: build Windows CPU + run: | + powershell -NoProfile -ExecutionPolicy Bypass -File scripts/windows/make-portable.ps1 ` + -PackageName StemDeck-Windows-x64 ` + -PackageVersion "$env:REF_NAME" ` + -CpuOnly ` + -StripVenv + + - name: scan artifacts + run: | + Write-Host "Preparing ClamAV scan for Windows release artifacts..." + Write-Host "Artifacts staged in: $PWD\dist" + Get-ChildItem -Path "dist" -File | + Sort-Object Name | + Select-Object Name, + @{Name="SizeMB"; Expression={ [math]::Round($_.Length / 1MB, 2) }} | + Format-Table -AutoSize + + Write-Host "SHA256 checksums:" + Get-ChildItem -Path "dist" -Filter "*.zip" -File | + Sort-Object Name | + ForEach-Object { + $hash = Get-FileHash -Algorithm SHA256 $_.FullName + Write-Host " $($hash.Hash) $($_.Name)" + } + + Write-Host "Pulling latest ClamAV scanner image..." + docker pull clamav/clamav:latest + + Write-Host "Running ClamAV scan over dist/..." + docker run --rm -v "${PWD}/dist:/scan:ro" clamav/clamav:latest ` + clamscan --recursive --infected --bell /scan + if ($LASTEXITCODE -ne 0) { + throw "ClamAV scan failed or reported infected files. Exit code: $LASTEXITCODE" + } + Write-Host "ClamAV scan completed successfully. No infected files reported." + + - name: upload artifacts + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + with: + files: | + dist/StemDeck-Windows-x64.NVIDIA.zip + dist/StemDeck-Windows-x64.NVIDIA.zip.sha256 + dist/StemDeck-Windows-x64.zip + dist/StemDeck-Windows-x64.zip.sha256 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d86469d --- /dev/null +++ b/.gitignore @@ -0,0 +1,83 @@ +# Python bytecode / native artifacts +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +# Python environments and package caches +.venv/ +venv/ +env/ +.uv-cache/ +pip-wheel-metadata/ + +# Python build outputs +dist/ +*.egg-info/ +*.egg +.eggs/ + +# Version is git-derived (hatch-vcs); these are generated artifacts, never committed. +app/_version.py +static/version.json + +# Desktop dependencies and build outputs +desktop/node_modules/ +desktop/src-tauri/target/ +desktop/src-tauri/gen/ + +# Test, coverage, and type-check caches +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +.coverage.* +htmlcov/ +.tox/ + +# Runtime job artifacts and local build scratch +data/ +jobs/ +settings.json +.run/ +.build + +# Local environment and secrets +.env +.env.* +!.env.example + +# Logs +*.log +logs/ + +# Local dev scripts +.local/ + +# Local notes and agent/tool state +.docs +.issues_docs +.claude/ +.playwright-mcp/ + +# Codex: keep repo guidance, ignore local state/cache +.codex/* +.codex/AGENTS.md +.codex/skills/ +.codex/skills/** +.codex/cache/ +.codex/logs/ +.codex/tmp/ + +# Editors and OS metadata +.idea/ +.vscode/ +.DS_Store +Thumbs.db + +# Tool versions +.python-version + +# Imported design references (kept local, not shipped) +design/ diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 0000000..03fb235 --- /dev/null +++ b/.trivyignore @@ -0,0 +1,18 @@ +# CVE-2025-32434: torch remote code execution via torch.load, fixed in 2.6.0. +# The Intel macOS (x86_64) runtime is pinned to torch>=2.2,<2.3 because +# PyTorch does not publish macOS x86_64 wheels for 2.6.x. StemDeck never +# calls torch.load() on untrusted input; all model weights are fetched by +# Demucs from its own trusted cache. Risk on a local single-user app with +# no network-facing torch.load path is negligible. +# Drop this ignore once PyTorch publishes 2.6.x macOS x86_64 wheels or once +# the torchaudio/torchcodec story stabilises and the pin can be lifted. +CVE-2025-32434 + +# DS-0002: Dockerfile missing USER instruction. +# The image intentionally starts as root so the entrypoint script +# (build/docker-entrypoint.sh) can re-chown the bind-mounted /app/jobs +# directory before dropping to the app user (uid 1001) via gosu. The +# process runs as non-root for its entire lifetime after the entrypoint +# executes. Trivy's check does not account for the gosu privilege-drop +# pattern. +DS-0002 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..c3d1ca9 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,85 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement privately through the repository's Security tab using the "Report a vulnerability" option (the project's private reporting channel), or by direct message to a project maintainer. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..96edfcd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,82 @@ +# Contributing to StemDeck + +Thanks for your interest in StemDeck - free, local stem separation for musicians. Contributions of +all kinds are welcome, whether you write code or not. + +By participating you agree to follow our [Code of Conduct](CODE_OF_CONDUCT.md). + +## Ways to contribute + +- **Report a bug** - open a [Bug report](https://github.com/stemdeckapp/stemdeck/issues/new/choose). + Include your OS, the StemDeck version (Help icon -> About), and clear steps to reproduce. +- **Suggest a feature** - open a [Feature request](https://github.com/stemdeckapp/stemdeck/issues/new/choose). +- **Improve the docs** - fixes and clarifications to the README or these guides are always useful. +- **Write code** - bug fixes and features. For anything large, please open an issue or a + [Discussion](https://github.com/stemdeckapp/stemdeck/discussions) first so we can agree on the + approach before you invest time. +- **Found a security issue?** Do not open a public issue - see [SECURITY.md](SECURITY.md). + +## Project layout + +- `app/` - FastAPI backend (the audio pipeline, job registry, and HTTP API). +- `static/` - the web UI: plain ES-module JavaScript, HTML, and CSS. No build step, no bundler. +- `desktop/` - the Tauri v2 desktop shell (Rust) that wraps the backend + UI. +- `scripts/` - packaging scripts (macOS app/dmg, Windows portable, runtime pack). +- `tests/` - the pytest suite for the backend. + +## Development setup + +You need [`uv`](https://docs.astral.sh/uv/) and `ffmpeg` on your PATH. + +```bash +uv sync --python 3.12 # install the Python environment +./run.sh start # start the backend at http://localhost:8000 +``` + +Other helpers: + +```bash +./run.sh restart # restart after backend changes +./run.sh stop # stop the server +./run.sh status # is it running? +``` + +Open `http://localhost:8000` in your browser to use the app. Frontend changes are picked up on +reload (no build step). + +## Before you open a pull request + +Please run the same checks CI runs: + +```bash +uv run ruff check app/ tests/ # lint +uv run ruff format --check app/ tests/ # formatting +node --check static/js/.js # syntax-check any JS you touched +uv run pytest tests/ -q # backend tests +``` + +For Rust changes in `desktop/`, also run `cargo fmt --check` and `cargo clippy -- -D warnings`. + +## Pull request guidelines + +- Branch off `main`. +- Use [Conventional Commits](https://www.conventionalcommits.org/) for messages + (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `ci:`). +- Open the PR as a **draft** until it is ready for review. +- Keep PRs focused - one logical change per PR makes review faster. +- Describe what changed and why, and how you tested it. + +## Tests + +New API endpoints and pipeline stages should come with tests under `tests/`. The suite uses +`pytest` with `httpx.AsyncClient`; see the existing `tests/test_*.py` files for patterns. + +## License + +StemDeck is licensed under the [Apache License 2.0](LICENSE). By contributing, you agree that your +contributions are licensed under the same terms. + +## Questions + +Open a [Discussion](https://github.com/stemdeckapp/stemdeck/discussions) or join the +[Discord](https://discord.gg/2MVsWqaPRe). Thanks for helping make StemDeck better. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c368d95 --- /dev/null +++ b/README.md @@ -0,0 +1,429 @@ +
+ +StemDeck + +**Free, local stem separation. No account. No upload. No subscription.** + +
+ CI + GitHub Stars + Total Downloads + Latest Release + License +
+ +
+ +

JOIN THE COMMUNITY

+
+ GitHub + Discord + Reddit + Instagram + X + Website +
+ +
+ +
+ +Drop in an MP3, WAV, or FLAC file, or paste a YouTube URL, and StemDeck splits the audio into up to six stems (vocals, drums, bass, guitar, piano, other). Play them back in a DAW-style multitrack mixer: mute, solo, balance levels, zoom the waveform, loop a region, and export individual stems or a custom mix. Everything runs locally on your own machine. + +> **What is this?** StemDeck is a stem separation tool, not a downloader. Its main job is processing audio you already own: drag an MP3, WAV, or FLAC onto the import bar and go. YouTube support is a convenience for content you have the right to process. StemDeck does not store, cache, or redistribute any downloaded content. Everything happens locally and nothing leaves your machine. + +> StemDeck is a free, open alternative to cloud stem-splitters like Moises and LALAL.AI: no account, no quota, no uploads, no subscription. If you want stems for personal study and prefer to keep things local and free, StemDeck has you covered. If you need the polish, a mobile app, or deeper musician tooling, the commercial products are a better fit. + +![StemDeck screenshot](imgs/screenshot/stemdeck.png) + +## We Recommend + +StemDeck is free and **does not accept any money, sponsorship, or funding** - not from users, not from anyone listed below. We share these makers and artists purely for the joy of pointing you toward wonderful people doing beautiful work. Go meet them ❤️ + +| Name | What they do | Link | +|---|---|---| +| Dlima Guitars | Custom guitars and basses | [@dlimaguitars](https://www.instagram.com/dlimaguitars) | +| Lisbon Guitar Works | Guitar building | [dlimaguitars.com](https://dlimaguitars.com) | +| Joao Gaspar | Producer/Film Scorer, Touring/Session Musician | [@jay_glaspar](https://www.instagram.com/jay_glaspar) | +| Kris Luthier | Luthier and Musical Instrument Repair, Lisboa | [@krisluthier](https://www.instagram.com/krisluthier) | +| Thomann | Online Music Store | [@thomann.music](https://www.instagram.com/thomann.music) | +| Analog4Lyfe | Analog music gear | [@analog4lyfe](https://www.instagram.com/analog4lyfe) | +| Empress Effects | Effects pedals | [empresseffects.com](https://empresseffects.com) | + + +--- + +## Features + +**6-stem separation** via Demucs `htdemucs_6s`, with auto-detection of the best Torch device (CUDA on NVIDIA, MPS on Apple Silicon, CPU fallback). + +**YouTube and local file import.** Paste a YouTube URL or drop an MP3 or WAV directly onto the import bar. + +**DAW-style waveform editor** with min/max sample rendering across all stems, shared normalization, zoom in/out/Fit, loop drag on the ruler, gold playhead overlay, and stem-aligned lanes. + +**Stem subset extraction.** Click stem chips to choose which stems to keep. Clicking from "all selected" snaps to "only this one"; subsequent clicks add or remove. + +**"Original" backing track.** When you pick a subset, a 7th lane contains the complement (full song minus selected stems), perfect for A/B reference without doubling. + +**Downloadable selected mix.** A single `mix.wav` of just your selected stems, summed via ffmpeg amix. + +**Per-stem mixer** with volume fader, mute, solo, and "monitor" (solo-only) per stem. State syncs between the preview mixer and the stems sidebar. + +**Live VU meters** per stem. Post-gain RMS via Web Audio analysers with peak hold and slow falloff. + +**Song analysis** including BPM (librosa beat tracker), key, scale, and confidence (Albrecht-Shanahan profiles), integrated LUFS (BS.1770), and sample peak in dBFS. + +**Cancellable jobs.** Cancel mid-pipeline and the runner terminates the active subprocess immediately, deletes the partial job dir, and returns to ready. + +**Library panel** with folder-based track organisation, drag-and-drop, search, and trash. + +--- + +## Honest Comparison + +StemDeck is not trying to compete with commercial stem-separation products. It covers the core use case well and stops there. This table exists so you can make an informed choice rather than discover the gaps after the fact. + +| | StemDeck | Moises / LALAL.AI / similar | +|---|---|---| +| **Price** | Free, forever | Freemium; credits or subscription required for regular use | +| **Hosting** | Runs entirely on your machine | Cloud; audio must be uploaded to their servers | +| **Account / login** | None | Required | +| **Internet required** | Only for YouTube download and first model fetch (~170 MB, cached after) | Always; no offline use | +| **Privacy** | Audio never leaves your machine | Audio is uploaded and processed on third-party servers | +| **Data retention** | You control it; delete anytime | Governed by their privacy policy and retention period | +| **Stem model** | Demucs `htdemucs_6s` (open source, Meta AI) | Proprietary models, regularly updated, generally higher quality | +| **Stem count** | 6 (vocals, drums, bass, guitar, piano, other) | Up to 10 depending on service and plan | +| **Input formats** | YouTube URL, MP3, WAV | MP3, WAV, FLAC, M4A, and more depending on service | +| **Processing speed** | Depends on your hardware; fast with a GPU, slow on CPU only | Fast regardless of your hardware (runs on their servers) | +| **Batch processing** | One job at a time | Yes, on paid plans | +| **Mobile app** | No | iOS and Android | +| **Extra features** | No (no pitch shift, chord detection, lyrics, click track, BPM tap) | Yes, varies by product | +| **Polish** | Functional, hobby-grade UI | Polished, production-grade apps | +| **Source code** | Open source, forkable, self-hostable | Closed source | + +If you need speed, quality, mobile access, or the extra musician tooling, the commercial products are worth the money. If you want stems for personal study, prefer to keep audio private, or just want something that runs locally with no strings attached, StemDeck is enough. + +--- + +## Download + +Pre-built installers and zips are attached to each [GitHub Release](https://github.com/stemdeckapp/stemdeck/releases). + +**macOS** + +| DMG | GPU | Chip | +|---|---|---| +| `StemDeck-macOS-arm64.dmg` | Apple Silicon (MPS) | M1 and later | +| `StemDeck-macOS-x64.dmg` | CPU only | Intel | + +Open the DMG, drag StemDeck to Applications, and launch it. On first launch the setup screen downloads the Python runtime (~500 MB), FFmpeg, and the Demucs model (~170 MB). Subsequent launches skip setup and start in seconds. No Python or system dependencies required. + +macOS may show a Gatekeeper prompt on first open — right-click the app and choose Open to bypass it. + +**Windows** + +| Zip | GPU | Approx. size | +|---|---|---| +| `StemDeck-Windows-x64.zip` | CPU only | ~700 MB | +| `StemDeck-Windows-x64.NVIDIA.zip` | NVIDIA CUDA | ~1.6 GB | + +Extract the zip anywhere, run `StemDeck.exe`. On first launch the app verifies the bundled Python runtime and downloads FFmpeg and the Demucs model (~170 MB). Subsequent launches skip this and start in seconds. Everything is self-contained; no Python or system dependencies required. + +--- + +## Technologies + +
+ Platform + Powered by Demucs + CI: GitHub Actions +
+ +
+ +StemDeck is built on **[Python 3.12](https://python.org)** managed via **[uv](https://github.com/astral-sh/uv)**, with a **[FastAPI](https://fastapi.tiangolo.com)** backend serving REST and Server-Sent Events. Stem separation uses **[Demucs](https://github.com/facebookresearch/demucs)** (`htdemucs_6s`), Meta AI's open-source 6-stem neural network. YouTube audio is fetched via **[yt-dlp](https://github.com/yt-dlp/yt-dlp)**; transcoding and mixing use **[FFmpeg](https://ffmpeg.org)**. BPM detection and key analysis run on **[librosa](https://librosa.org)**; loudness measurement uses **[pyloudnorm](https://github.com/csteinmetz1/pyloudnorm)** (ITU-R BS.1770). The macOS and Windows desktop shells are **[Tauri v2](https://tauri.app)** (Rust/WKWebView on macOS, Rust/WebView2 on Windows). The frontend is vanilla JS with the Web Audio API, no framework and no build step; waveforms are rendered on `` using min/max sample rendering. + +*Thanks to the creators and maintainers of all the open-source libraries that make StemDeck possible.* + +--- + +## Build from Source + +### macOS Native App + +Requires Rust, Node.js, and Python 3.12. Builds a self-contained `.app` that downloads its own runtime on first launch. + +```sh +# First time only — add the cross-compilation targets +rustup target add aarch64-apple-darwin # Apple Silicon +rustup target add x86_64-apple-darwin # Intel + +# Build Apple Silicon +ARCH=arm64 scripts/macos/make-runtime-pack.sh +ARCH=arm64 scripts/macos/make-app.sh +ARCH=arm64 scripts/macos/make-dmg.sh + +# Build Intel (requires Rosetta 2 and an x86_64 Python) +ARCH=x64 scripts/macos/make-runtime-pack.sh +ARCH=x64 scripts/macos/make-app.sh +ARCH=x64 scripts/macos/make-dmg.sh +``` + +The `.app` lands at `desktop/src-tauri/target//release/bundle/macos/StemDeck.app`. The DMG lands at `.build/macos-dist/StemDeck-macOS-.dmg`. + +To run a fresh build directly without the DMG: + +```sh +open desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/StemDeck.app +``` + +If macOS blocks the app with a Gatekeeper prompt, run: + +```sh +xattr -dr com.apple.quarantine desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/StemDeck.app +``` + +> **Note:** To test a clean first-launch during development, you can wipe previous app data first: `rm -rf ~/Library/Application\ Support/StemDeck`. Don't do this on a real install. + +--- + +### Web Server (macOS / Linux / Windows with Python 3.12+) + +#### Prerequisites + +Python 3.12 or newer, `ffmpeg` on your PATH, and [uv](https://github.com/astral-sh/uv). Around 170 MB of free disk for the Demucs model, which downloads automatically on first run. + +#### macOS / Linux (one-shot) + +```sh +git clone https://github.com/stemdeckapp/stemdeck stemdeck && cd stemdeck +./run.sh setup # installs ffmpeg + uv, runs uv sync +./run.sh start +``` + +Open . + +`setup` uses Homebrew on macOS and `apt-get` on Debian/Ubuntu. For other Linux distros, install `ffmpeg` and [uv](https://github.com/astral-sh/uv) manually, then run `uv sync` followed by `./run.sh start`. + +#### Windows (PowerShell) + +Install prerequisites: +- [uv](https://docs.astral.sh/uv/getting-started/installation/) — `winget install astral-sh.uv` +- [ffmpeg](https://ffmpeg.org/download.html) — `winget install Gyan.FFmpeg` (or Chocolatey: `choco install ffmpeg`) + +```powershell +git clone https://github.com/stemdeckapp/stemdeck stemdeck; cd stemdeck +uv sync +uv run uvicorn app.main:app --host 127.0.0.1 --port 8000 +``` + +Open . + +> `run.sh` is macOS/Linux only. On Windows use the PowerShell commands above, or run inside WSL. + +**NVIDIA GPU (CUDA):** install the CUDA-enabled torch build before starting: + +```powershell +uv pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu124 +$env:STEMDECK_DEMUCS_DEVICE = "cuda" +uv run uvicorn app.main:app --host 127.0.0.1 --port 8000 +``` + +--- + +#### Manual (any platform) + +```sh +git clone https://github.com/stemdeckapp/stemdeck stemdeck && cd stemdeck +uv sync +uv run uvicorn app.main:app --reload +``` + +#### Docker + +```sh +docker compose -f build/docker-compose.yml up --build +``` + +Stems land in `./jobs/` on the host. Demucs weights are cached in a named volume so they don't re-download on rebuild. Note: no GPU passthrough on macOS Docker. + +A prebuilt image is published to GHCR. Tags: `edge` (rolling, rebuilt on every merge to main), `latest` (newest stable release), and `X.Y.Z` (pinned to a release). + +```sh +docker run -d --name stemdeck -p 8000:8000 \ + -v /path/to/jobs:/app/jobs \ + -v /path/to/cache:/cache \ + -e STEMDECK_PERSIST_LIBRARY=1 \ + ghcr.io/stemdeckapp/stemdeck:edge +``` + +On a Linux host with an NVIDIA GPU (driver + NVIDIA Container Toolkit installed), add `--runtime=nvidia -e NVIDIA_VISIBLE_DEVICES=all` and StemDeck auto-detects CUDA. The image already bundles CUDA-enabled torch, so no separate CUDA install is needed. + +#### Unraid + +StemDeck is available in Unraid Community Applications: open **Apps**, search "StemDeck", and install. Map the two volumes to persistent appdata paths: + +- `/app/jobs` -> `/mnt/user/appdata/stemdeck/jobs` (library + stems) +- `/cache` -> `/mnt/user/appdata/stemdeck/cache` (model weights) + +The library is persistent by default (`STEMDECK_PERSIST_LIBRARY=1`), so tracks are never auto-deleted. For GPU acceleration, install the **Nvidia Driver** plugin, then set the container's Extra Parameters to `--runtime=nvidia` (the `NVIDIA_VISIBLE_DEVICES` and `NVIDIA_DRIVER_CAPABILITIES` variables are already in the template). CPU-only works with no extra configuration. + +#### `run.sh` control script + +```sh +./run.sh setup # one-shot: install ffmpeg + uv, then uv sync +./run.sh start # boots uvicorn in the background +./run.sh stop # graceful shutdown +./run.sh restart # stop + start +./run.sh status # is it running? +``` + +--- + +## How to Use + +1. On the import bar, click stem chips to choose which stems to extract (defaults to all 6). +2. Paste a YouTube URL **or** drop an MP3/WAV file, then click **Process**. +3. Wait through `Uploading...` / `Downloading...` → `Analyzing...` → `Separating...` → `Mixing tracks...`. +4. When done, the studio dashboard appears. If you picked a subset, the first lane is **Original** (full song minus your selection); the rest are your isolated stems. +5. Mix: **Play/Pause/Stop** controls the master transport. **M** mutes a stem, **S** solos it (additive; multiple solos stay audible), **Monitor** solos only that stem and clears others. The volume fader moves 1:1 with drag; double-click resets to 0 dB; `Shift+wheel` gives coarse adjustment and plain wheel gives fine. The **Reset**, **Mute**, and **Solo** toolbar buttons act on all stems at once. +6. Drag on the ruler to define a loop region; click `Loop` to enable. Use `+` / `-` / `Fit` or `Ctrl/Cmd+wheel` to zoom. +7. **Download Mix** in the footer gives you a WAV of your selected stems summed together. + +**Keyboard shortcuts:** `Space` play/pause · `[` seek -5s · `]` seek +5s · `L` loop · `I` loop in · `O` loop out + +--- + +## Configuration + +| Variable | Default | Purpose | +|---|---|---| +| `STEMDECK_DEMUCS_DEVICE` | auto | Force Torch device: `cuda`, `mps`, or `cpu`. | +| `STEMDECK_DEMUCS_MODEL` | `htdemucs_6s` | Demucs model name. | +| `STEMDECK_JOBS_DIR` | `./jobs` | Where job directories land. | +| `STEMDECK_DATA_DIR` | (none) | Portable mode root; sets all sub-dirs below to live inside it. | +| `STEMDECK_CACHE_DIR` | `/cache` | Torch model cache directory. | +| `STEMDECK_DOWNLOADS_DIR` | `/downloads` | yt-dlp download scratch space. | +| `STEMDECK_MODELS_DIR` | `/models` | Demucs model weights directory. | +| `STEMDECK_LOGS_DIR` | `/logs` | Log file output directory. | +| `STEMDECK_FFMPEG_DIR` | (none) | Directory containing a bundled ffmpeg binary. | +| `STEMDECK_FFMPEG` | `ffmpeg` | Path to the ffmpeg executable. | +| `STEMDECK_FFPROBE` | `ffprobe` | Path to the ffprobe executable. | +| `STEMDECK_MAX_DURATION_SEC` | `1200` | Reject audio longer than this (seconds). | +| `STEMDECK_JOB_TTL_SECONDS` | `86400` | How long to keep job dirs on disk. | +| `STEMDECK_MAX_PENDING_JOBS` | `3` | Max queued jobs before returning 503. | +| `STEMDECK_TIMEOUT_FFMPEG` | `300` | ffmpeg subprocess timeout (seconds). | +| `STEMDECK_TIMEOUT_ANALYZE` | `120` | Audio analysis timeout (seconds). | +| `STEMDECK_TIMEOUT_DEMUCS_STALL` | `1800` | Kill Demucs if no output for this many seconds. | + +`run.sh` also reads: `HOST` (default `127.0.0.1`), `PORT` (default `8765`), `RELOAD=1` (enable uvicorn auto-reload for development), `FOREGROUND=1` (run in foreground instead of backgrounding). + +--- + +## API + +| Method | Path | Purpose | +|---|---|---| +| GET | `/api/health` | Server health and version info | +| POST | `/api/jobs` | JSON `{url, stems?}` or multipart `file + stems` → `{job_id}` | +| GET | `/api/jobs` | List completed (library) jobs | +| GET | `/api/jobs/{id}` | Job state snapshot | +| GET | `/api/jobs/{id}/events` | SSE stream of job state | +| POST | `/api/jobs/{id}/cancel` | Terminate active subprocess and cancel job | +| PATCH | `/api/jobs/{id}/sections` | Save waveform section markers for a job | +| GET | `/api/jobs/{id}/stems/{name}.wav` | Stream a single stem WAV file | +| GET | `/api/jobs/{id}/stems/{name}.mp3` | Transcode and stream a stem as MP3 | +| GET | `/api/jobs/{id}/video.mp4` | Mux the current mix with the source video (MP4 upload or YouTube) into an MP4 | +| DELETE | `/api/jobs/{id}` | Remove job dir from disk (terminal jobs only) | + +--- + +## Troubleshooting + +**`ffmpeg: command not found`:** install ffmpeg and restart with `./run.sh restart`. + +**`WARNING: [youtube] No supported JavaScript runtime`:** install deno (`brew install deno` on macOS) and restart. Downloads still work without it but may pick suboptimal formats. + +**First separation is very slow:** Demucs downloads `htdemucs_6s` weights (~170 MB) on first run; cached afterwards. + +**Demucs runs on CPU only:** check the startup log for `device=mps` or `device=cuda`. If you see `cpu`, your torch install may be CPU-only. + +**Page reloaded mid-job:** the job keeps running server-side. Wait for it to finish, then resubmit. + +**`./run.sh: Permission denied`:** run `chmod +x run.sh`. + +--- + +## Layout on Disk + +``` +jobs// +└── stems/ + ├── vocals.wav # the 6 Demucs stems (always present) + ├── drums.wav + ├── bass.wav + ├── guitar.wav + ├── piano.wav + ├── other.wav + ├── original.wav # sum of un-selected stems (subset only) + └── mix.wav # ffmpeg amix of selected stems (subset only) +``` + +Job state is in-memory. Restart the server and the job list resets, but files persist on disk. Old dirs are swept automatically (TTL 24 h, configurable). + +--- + +## Disclaimer + +StemDeck is a local audio stem separation tool intended for personal study, research, and experimentation. It is not a downloading service. It does not store, cache, or redistribute any audio content. All processing runs on the user's own machine and no audio is transmitted anywhere. + +YouTube URL support is provided via [yt-dlp](https://github.com/yt-dlp/yt-dlp) as a convenience. Automated downloading may violate YouTube's Terms of Service. You, the user, are solely responsible for ensuring you have the right to process any audio you submit, complying with the terms of service of any site you download from, and respecting the copyright of the material you work with. + +You are also responsible for following the licenses of the underlying tools this project depends on (yt-dlp, Demucs, FFmpeg, PyTorch, and others listed in `pyproject.toml`). + +The author(s) of StemDeck provide this software "as is", without warranty of any kind, and accept no responsibility or liability for how it is used. + +--- + +## Community + +| Platform | Link | +|---|---| +| GitHub | [stemdeckapp/stemdeck](https://github.com/stemdeckapp/stemdeck) | +| Discord | [discord.gg/2MVsWqaPRe](https://discord.gg/2MVsWqaPRe) | +| Reddit | [r/StemDeckApp](https://www.reddit.com/r/StemDeckApp/) | +| Instagram | [@stemdeck](https://www.instagram.com/stemdeck) | +| X | [@StemDeckApp](https://x.com/StemDeckApp) | +| Website | [stemdeck.app](https://stemdeck.app) *(coming soon)* | + +--- + +## Environment Variables + +These are for development and testing. Release builds only recognize the variables marked "release". + +| Variable | Platform | Scope | Description | +|---|---|---|---| +| `STEMDECK_DATA_DIR` | all | release | Override the user data directory (default: platform-standard location) | +| `STEMDECK_ROOT` | all | release | Override the app root directory (default: derived from executable path) | +| `STEMDECK_PYTHON` | all | **debug builds only** | Override the Python executable path | +| `STEMDECK_FFMPEG_URL` | Windows, macOS | release | Override the FFmpeg download URL | +| `STEMDECK_FFPROBE_URL` | macOS | release | Override the ffprobe download URL | + +--- + +## Contributing + +Issues, feature suggestions, and pull requests are welcome. See open issues for what's planned. + +--- + +## Star History + + + + + + Star History Chart + + diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..2aa4350 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`stemdeckapp/stemdeck` +- 原始仓库:https://github.com/stemdeckapp/stemdeck +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..9b76a6f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,53 @@ +# Security Policy + +## Supported Versions + +StemDeck is in active alpha. Only the latest release receives security fixes - +there are no long-term-support branches yet. Please update to the newest +release before reporting an issue. + +| Version | Supported | +| --------------- | --------- | +| Latest release | Yes | +| Any older build | No | + +## Reporting a Vulnerability + +Please report security issues privately, not in a public issue. Open the +repository's **Security** tab and click **Report a vulnerability** (GitHub +Private Vulnerability Reporting). This keeps the details private until a fix is +available. + +Include where you can: + +- Affected version and operating system +- Steps to reproduce +- Impact (what an attacker could do) +- Any relevant logs or proof of concept + +What to expect: + +- Acknowledgement within about 5 business days (best-effort; small team). +- We confirm the report, assess severity, and keep you updated. +- Fixes ship in the next release. We credit reporters unless you prefer not to + be named. + +## Scope and threat model + +StemDeck is local-first and single-user by design: it runs on your own machine, +has no authentication, and is same-origin only. Reports that it "has no login" +or "no per-user access control" describe intended behavior, not vulnerabilities. + +We are most interested in reports about: + +- Malicious media files or URLs (SSRF, command or argument injection) +- Cross-site scripting (XSS) or Content-Security-Policy bypass in the desktop + webview +- Path traversal in the backend file APIs +- Integrity of downloaded binaries (FFmpeg, the runtime pack) + +## Good-faith research + +We will not pursue action against good-faith security research that respects +user privacy and avoids data destruction or service disruption. Thank you for +helping keep StemDeck users safe. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/config.py b/app/api/config.py new file mode 100644 index 0000000..76eee2d --- /dev/null +++ b/app/api/config.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from fastapi import APIRouter + +from app.core.config import STEM_NAMES + +router = APIRouter() + + +@router.get("/config") +def get_config() -> dict: + return {"stem_names": list(STEM_NAMES)} diff --git a/app/api/events.py b/app/api/events.py new file mode 100644 index 0000000..8f10b99 --- /dev/null +++ b/app/api/events.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import asyncio +import json +from collections.abc import AsyncIterator + +from fastapi import APIRouter, HTTPException +from fastapi.responses import StreamingResponse + +from app.core.config import JOB_ID_RE +from app.core.registry import get as registry_get + +router = APIRouter(tags=["events"]) + +# Close SSE connections that outlive this threshold to prevent zombie +# connections from accumulating when clients disconnect without a TCP RST. +_MAX_SSE_SECONDS = 4 * 3600 # 4 hours +# Hard cap on concurrent SSE connections to prevent resource exhaustion +# from tab leaks or aggressive reconnect loops. +_MAX_SSE_CONNECTIONS = 200 +# Counter is only mutated from the async event loop (no awaits between +# check+increment), so no lock is needed. +_sse_active = 0 + + +@router.get("/jobs/{job_id}/events") +async def job_events(job_id: str) -> StreamingResponse: + """Server-Sent Events stream of job state updates. Closes when the job + reaches a terminal status (done, error, cancelled) or after 4 hours.""" + global _sse_active + if not JOB_ID_RE.match(job_id): + raise HTTPException(status_code=404, detail="job not found") + job = registry_get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="job not found") + if _sse_active >= _MAX_SSE_CONNECTIONS: + raise HTTPException(status_code=503, detail="too many concurrent streams") + _sse_active += 1 + + async def stream() -> AsyncIterator[str]: + global _sse_active + try: + last = None + keepalive_at = 0 + loop = asyncio.get_running_loop() + deadline = loop.time() + _MAX_SSE_SECONDS + while loop.time() < deadline: + snapshot = job.to_state() + serialized = json.dumps(snapshot) + if serialized != last: + yield f"data: {serialized}\n\n" + last = serialized + keepalive_at = 0 + if snapshot["status"] in ("done", "error", "cancelled"): + return + keepalive_at += 1 + if keepalive_at >= 75: # ~15s + yield ": keepalive\n\n" + keepalive_at = 0 + await asyncio.sleep(0.2) + finally: + _sse_active -= 1 + + return StreamingResponse( + stream(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) diff --git a/app/api/jobs.py b/app/api/jobs.py new file mode 100644 index 0000000..db17a2c --- /dev/null +++ b/app/api/jobs.py @@ -0,0 +1,361 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import re +import shutil +import subprocess +import uuid +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, field_validator + +from app.core.config import JOB_ID_RE, JOBS_DIR, MAX_PENDING_JOBS, STEM_NAMES, ffprobe_executable +from app.core.models import Job +from app.core.registry import all_jobs as registry_all_jobs +from app.core.registry import get as registry_get +from app.core.registry import get_proc as registry_get_proc +from app.core.registry import persist as registry_persist +from app.core.registry import register_if_capacity as registry_register_if_capacity +from app.core.registry import remove as registry_remove +from app.core.settings import get_max_duration_sec +from app.pipeline import run_local_pipeline, run_pipeline +from app.pipeline.download import InvalidYouTubeURL, validate_youtube_url + +router = APIRouter(tags=["jobs"]) +logger = logging.getLogger("stemdeck.api") + +_ALLOWED_EXTS = frozenset((".mp3", ".wav", ".flac", ".mp4", ".m4a")) +_MAX_UPLOAD_BYTES = 400 * 1024 * 1024 # 400 MB +_WS_RE = re.compile(r"\s+") + + +def _sanitize_title(filename: str) -> str: + """Strip extension, normalize whitespace, cap at 120 chars.""" + stem = Path(filename).stem + return _WS_RE.sub(" ", stem).strip()[:120] + + +def _probe_duration(path: Path) -> float: + """Run ffprobe to get file duration in seconds.""" + result = subprocess.run( + [ + ffprobe_executable(), + "-v", + "quiet", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + str(path), + ], + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + raise RuntimeError(f"ffprobe failed: {result.stderr.strip()}") + try: + return float(result.stdout.strip()) + except ValueError as e: + raise RuntimeError(f"ffprobe returned non-numeric duration: {result.stdout!r}") from e + + +def _check_file_size(file_obj: object) -> int: + """Seek to end, return size, rewind. Operates on the SpooledTemporaryFile + backing a starlette UploadFile — synchronous, suitable for to_thread.""" + file_obj.seek(0, 2) # type: ignore[union-attr] + size = file_obj.tell() # type: ignore[union-attr] + file_obj.seek(0) # type: ignore[union-attr] + return size + + +def _copy_to_dest(src_file: object, dest: Path) -> None: + """Copy SpooledTemporaryFile contents to dest. Synchronous, run in thread.""" + with dest.open("wb") as out: + shutil.copyfileobj(src_file, out) # type: ignore[arg-type] + + +def _rmtree_job(job_id: str) -> None: + job_dir = JOBS_DIR / job_id + if not job_dir.is_dir(): + return + try: + shutil.rmtree(job_dir) + except Exception: + logger.warning("failed to remove job dir %s", job_dir, exc_info=True) + + +def _task_error_cb(task: asyncio.Task) -> None: + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + logger.error("pipeline task raised unhandled exception", exc_info=exc) + + +class JobRequest(BaseModel): + url: str + # Subset of stems to include in the post-processing "selected mix" + # audio file. None = all 6 (no extra mix produced; would equal the + # original). Unknown stem names are dropped silently rather than + # rejected, so a future model with extra stems doesn't break older + # clients pinning the old set. + stems: list[str] | None = None + + +@router.post("") +async def create_job(request: Request) -> dict[str, str]: + """Submit a YouTube URL (JSON body) or upload an audio file (multipart/form-data) + to start a stem-separation job. Returns the new job ID.""" + ct = request.headers.get("content-type", "") + if "multipart/form-data" in ct: + return await _create_local_job(request) + return await _create_youtube_job(request) + + +async def _create_youtube_job(request: Request) -> dict[str, str]: + try: + body = await request.json() + except Exception as e: + raise HTTPException(status_code=422, detail=f"Invalid JSON: {e}") from e + try: + payload = JobRequest(**body) + except Exception as e: + raise HTTPException(status_code=422, detail=str(e)) from e + + try: + url = validate_youtube_url(payload.url) + except InvalidYouTubeURL as e: + raise HTTPException(status_code=422, detail=str(e)) from e + + selected = [s for s in payload.stems if s in STEM_NAMES] if payload.stems else list(STEM_NAMES) + if not selected: + selected = list(STEM_NAMES) + + job = Job(id=uuid.uuid4().hex[:12], selected_stems=selected, source_url=url) + if not registry_register_if_capacity(job, MAX_PENDING_JOBS): + raise HTTPException(status_code=503, detail="Server busy, please try again later") + task = asyncio.create_task(run_pipeline(job, url, JOBS_DIR)) + task.add_done_callback(_task_error_cb) + return {"job_id": job.id} + + +async def _create_local_job(request: Request) -> dict[str, str]: + # Fast pre-check: if already at capacity, reject before touching disk. + # The real atomic check happens in register_if_capacity after the upload. + if sum(1 for j in registry_all_jobs().values() if j.status == "queued") >= MAX_PENDING_JOBS: + raise HTTPException(status_code=503, detail="Server busy, please try again later") + + # Quick pre-check on Content-Length to fail fast for obviously oversized + # uploads without buffering the whole body first. + cl_header = request.headers.get("content-length") + if cl_header: + try: + if int(cl_header) > _MAX_UPLOAD_BYTES + 4096: + raise HTTPException(status_code=422, detail="File exceeds 400 MB limit") + except ValueError: + pass + + form = await request.form() + upload = form.get("file") + stems_raw = form.get("stems", "[]") + + if upload is None or not hasattr(upload, "filename"): + raise HTTPException(status_code=422, detail="No file provided") + + filename: str = getattr(upload, "filename", "") or "" + ext = Path(filename).suffix.lower() + if ext not in _ALLOWED_EXTS: + raise HTTPException( + status_code=422, + detail=f"Unsupported file type '{ext}': accepted formats are .mp3, .wav, .flac, .mp4, and .m4a", + ) + + # Validate stems list from form field + try: + stems_list = json.loads(stems_raw) + if not isinstance(stems_list, list): + raise ValueError + except (json.JSONDecodeError, ValueError): + stems_list = [] + selected = [s for s in stems_list if s in STEM_NAMES] or list(STEM_NAMES) + + # Check actual file size (SpooledTemporaryFile is already buffered at this + # point; seek/tell are fast and don't re-read the body). + file_obj = upload.file # type: ignore[union-attr] + file_size = await asyncio.to_thread(_check_file_size, file_obj) + if file_size == 0: + raise HTTPException(status_code=422, detail="Uploaded file is empty") + if file_size > _MAX_UPLOAD_BYTES: + raise HTTPException(status_code=422, detail="File exceeds 400 MB limit") + + job_id = uuid.uuid4().hex[:12] + job_dir = JOBS_DIR / job_id + source_path = job_dir / f"source{ext}" + + job_dir.mkdir(parents=True, exist_ok=True) + try: + await asyncio.to_thread(_copy_to_dest, file_obj, source_path) + + # Duration check before registering the job so a violation leaves no + # registered job and no leftover directory. + try: + duration = await asyncio.to_thread(_probe_duration, source_path) + except Exception as e: + raise HTTPException(status_code=422, detail=f"Could not read file duration: {e}") from e + + max_duration = get_max_duration_sec() + if duration > max_duration: + raise HTTPException( + status_code=422, + detail=(f"File is {int(duration // 60)} min — limit is {max_duration // 60} min"), + ) + except HTTPException: + shutil.rmtree(job_dir, ignore_errors=True) + raise + + title = _sanitize_title(filename) + local_source_url = f"local:{title}" + job = Job( + id=job_id, + selected_stems=selected, + title=title, + duration_sec=duration, + source_url=local_source_url, + ) + if not registry_register_if_capacity(job, MAX_PENDING_JOBS): + shutil.rmtree(job_dir, ignore_errors=True) + raise HTTPException(status_code=503, detail="Server busy, please try again later") + task = asyncio.create_task(run_local_pipeline(job, source_path, JOBS_DIR)) + task.add_done_callback(_task_error_cb) + return {"job_id": job.id} + + +@router.get("") +def list_jobs() -> list[dict]: + """List all completed jobs in the library, sorted by creation time.""" + return [ + job.to_state() + for job in sorted(registry_all_jobs().values(), key=lambda j: j.created_at) + if job.status == "done" + ] + + +@router.get("/{job_id}") +def get_job(job_id: str) -> dict: + """Get the current state of a job by ID.""" + job = registry_get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="job not found") + return job.to_state() + + +@router.post("/{job_id}/cancel") +def cancel_job(job_id: str) -> dict: + """Request cancellation of a running job. Idempotent for terminal jobs.""" + job = registry_get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="job not found") + if job.status in ("done", "error", "cancelled"): + return job.to_state() + job.cancel_requested = True + proc = registry_get_proc(job_id) + if proc is not None and proc.poll() is None: + proc.terminate() + return job.to_state() + + +_SECTION_ID_RE = re.compile(r"^[a-zA-Z0-9_\-]{1,64}$") +_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$") + + +class SectionItem(BaseModel): + id: str + name: str + start: float + end: float + color: str + + @field_validator("id") + @classmethod + def _check_id(cls, v: str) -> str: + if not _SECTION_ID_RE.match(v): + raise ValueError("invalid section id") + return v + + @field_validator("name") + @classmethod + def _check_name(cls, v: str) -> str: + return v.strip()[:64] or "Section" + + @field_validator("color") + @classmethod + def _check_color(cls, v: str) -> str: + if not _COLOR_RE.match(v): + raise ValueError("invalid color") + return v + + @field_validator("start", "end") + @classmethod + def _check_time(cls, v: float) -> float: + if not (0 <= v < 86400): + raise ValueError("time out of range") + return round(v, 3) + + +class SectionsBody(BaseModel): + sections: list[SectionItem] + + +@router.patch("/{job_id}/sections") +def update_sections(job_id: str, body: SectionsBody) -> dict: + """Save named timeline sections (intro, verse, chorus, etc.) for a done job.""" + if not JOB_ID_RE.match(job_id): + raise HTTPException(status_code=404, detail="job not found") + job = registry_get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="job not found") + + validated = [s.model_dump() for s in body.sections] + job.sections = validated + + job_dir = (JOBS_DIR / job_id).resolve() + if not job_dir.is_relative_to(JOBS_DIR.resolve()): + raise HTTPException(status_code=404, detail="job not found") + meta_path = job_dir / "metadata.json" + + meta: dict = {} + if meta_path.is_file(): + try: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + pass + meta["sections"] = validated + try: + meta_path.write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8") + except OSError as exc: + logger.exception("failed to write sections for %s: %s", job_id, exc) + raise HTTPException(status_code=500, detail="failed to save sections") from exc + + registry_persist(JOBS_DIR) + + return {"job_id": job_id, "sections": validated} + + +@router.delete("/{job_id}") +def delete_job(job_id: str) -> dict[str, str]: + """Delete a completed or failed job and remove its stem files from disk.""" + if not JOB_ID_RE.match(job_id): + raise HTTPException(status_code=404, detail="job not found") + job = registry_get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="job not found") + if job.status not in ("done", "error", "cancelled"): + raise HTTPException(status_code=409, detail="job is still running") + _rmtree_job(job_id) + registry_remove(job_id) + registry_persist(JOBS_DIR) + return {"job_id": job_id, "status": "deleted"} diff --git a/app/api/qr.py b/app/api/qr.py new file mode 100644 index 0000000..e0087b0 --- /dev/null +++ b/app/api/qr.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import io + +import segno +from fastapi import APIRouter, HTTPException +from fastapi.responses import Response + +router = APIRouter() + +_MAX_LEN = 500 + + +@router.get("/qr") +def get_qr(url: str) -> Response: + if not url or len(url) > _MAX_LEN: + raise HTTPException(status_code=422, detail="url required (max 500 chars)") + qr = segno.make_qr(url, error="m") + buf = io.BytesIO() + qr.save(buf, kind="svg", scale=5, border=2, dark="#1a1206", light="#ffffff") + return Response( + content=buf.getvalue(), + media_type="image/svg+xml", + headers={"Cache-Control": "public, max-age=3600"}, + ) diff --git a/app/api/router.py b/app/api/router.py new file mode 100644 index 0000000..1fbfbe8 --- /dev/null +++ b/app/api/router.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from fastapi import APIRouter + +from app.api.config import router as config_router +from app.api.events import router as events_router +from app.api.jobs import router as jobs_router +from app.api.qr import router as qr_router +from app.api.stems import router as stems_router + +router = APIRouter() +router.include_router(config_router, tags=["config"]) +router.include_router(jobs_router, prefix="/jobs", tags=["jobs"]) +router.include_router(events_router, tags=["events"]) +router.include_router(stems_router, tags=["stems"]) +router.include_router(qr_router, tags=["qr"]) diff --git a/app/api/stems.py b/app/api/stems.py new file mode 100644 index 0000000..396baec --- /dev/null +++ b/app/api/stems.py @@ -0,0 +1,494 @@ +from __future__ import annotations + +import asyncio +import logging +import os +import re +import subprocess +import tempfile +import uuid +import zipfile +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import FileResponse, Response, StreamingResponse +from starlette.background import BackgroundTask + +from app.core.config import JOB_ID_RE, JOBS_DIR, STEM_NAMES, TIMEOUT_FFMPEG, ffmpeg_executable +from app.core.registry import get as registry_get + +logger = logging.getLogger("stemdeck.api") + +router = APIRouter(tags=["stems"]) + +# Stem files served by this endpoint: the 6 demucs stems + two +# pipeline-produced extras. "original" is the re-encoded source song +# (added when the user picked a strict subset), "mix" is the ffmpeg +# amix of the user's selected stems. +_ALLOWED_NAMES = frozenset(STEM_NAMES) | {"original", "mix"} + +# Lanes the dynamic mixdown may sum: the 6 stems plus "original" (the complement +# track shown when the user picked a subset). "mix" is excluded -- it is the +# static pre-render this endpoint replaces. Gains are linear; the studio caps a +# lane at 2.0, so this generous bound just rejects abusive values. +_MIXDOWN_NAMES = frozenset(STEM_NAMES) | {"original"} +_MIXDOWN_MAX_GAIN = 4.0 + +# Output encoders by container/extension, shared by the dynamic mixdown and the +# stems zip. WAV is lossless PCM, FLAC is lossless compressed, MP3 is VBR ~190 kbps. +_ENCODE_ARGS = { + "wav": ["-c:a", "pcm_s16le"], + "mp3": ["-q:a", "2"], + "flac": ["-c:a", "flac"], +} +MIXDOWN_CODECS = {ext: [*args, "-f", ext] for ext, args in _ENCODE_ARGS.items()} +MIXDOWN_MEDIA_TYPES = {"wav": "audio/wav", "mp3": "audio/mpeg", "flac": "audio/flac"} + + +def _validate_stem_path(job_id: str, name: str): + """Shared guard: validate job_id, name, job state, and path. Returns resolved Path.""" + if not JOB_ID_RE.match(job_id): + raise HTTPException(status_code=404, detail="job not found") + if name not in _ALLOWED_NAMES: + raise HTTPException(status_code=404, detail="unknown stem") + job = registry_get(job_id) + if job is None or job.status != "done": + raise HTTPException(status_code=404, detail="job not ready") + path = (JOBS_DIR / job_id / "stems" / f"{name}.wav").resolve() + if not path.is_file() or not path.is_relative_to(JOBS_DIR.resolve()): + raise HTTPException(status_code=404, detail="stem not found") + return path + + +def _parse_lane_gains(stems: str, gains: str) -> tuple[list[str], list[float]]: + """Parse and validate parallel comma-separated lane names and linear gains. + Shared by the audio mixdown and the MP4 video mux. Raises HTTPException + on malformed input, unknown lanes, or out-of-range gains.""" + names = [s for s in stems.split(",") if s] + raw_gains = [g for g in gains.split(",") if g] + if not names or len(names) != len(raw_gains): + raise HTTPException( + status_code=422, detail="stems and gains must be non-empty and equal length" + ) + try: + parsed_gains = [float(g) for g in raw_gains] + except ValueError: + raise HTTPException(status_code=422, detail="gains must be numbers") from None + if any(g < 0 or g > _MIXDOWN_MAX_GAIN for g in parsed_gains): + raise HTTPException(status_code=422, detail="gain out of range") + if not set(names) <= _MIXDOWN_NAMES: + raise HTTPException(status_code=422, detail="unknown stem requested") + return names, parsed_gains + + +async def _stream_ffmpeg(cmd: list[str]): + """Yield ffmpeg stdout in 64 KB chunks; kill process on client disconnect.""" + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + try: + while True: + chunk = await proc.stdout.read(65536) + if not chunk: + break + yield chunk + finally: + if proc.returncode is None: + proc.kill() + await proc.wait() + + +async def _ensure_cached_mp3(src: Path) -> Path: + """Transcode `src` (a stem WAV) to a sibling `.mp3`, cached on disk. + Re-encoding a full song on every request is the slow part of loading a track + on mobile (≈3s/stem × 6 in parallel); caching makes repeat loads instant. + Written atomically (temp + rename) so concurrent fetches can't serve a + partial file.""" + dest = src.with_suffix(".mp3") + if dest.is_file() and dest.stat().st_mtime >= src.stat().st_mtime: + return dest + tmp = dest.with_name(f".{dest.name}.{uuid.uuid4().hex}.tmp") + cmd = [ + ffmpeg_executable(), + "-nostdin", + "-loglevel", + "error", + "-y", + "-i", + str(src), + "-q:a", + "2", # VBR ~190 kbps + "-f", + "mp3", + str(tmp), + ] + proc = await asyncio.create_subprocess_exec( + *cmd, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE + ) + try: + _, stderr = await asyncio.wait_for(proc.communicate(), timeout=TIMEOUT_FFMPEG) + except (TimeoutError, asyncio.TimeoutError): + proc.kill() + await proc.wait() + tmp.unlink(missing_ok=True) + raise HTTPException(status_code=504, detail="mp3 transcode timed out") from None + if proc.returncode != 0: + tmp.unlink(missing_ok=True) + logger.warning( + "mp3 transcode failed for %s: %s", src.name, (stderr or b"").decode("utf-8", "replace") + ) + raise HTTPException(status_code=500, detail="mp3 transcode failed") + os.replace(tmp, dest) + return dest + + +@router.get("/jobs/{job_id}/stems/peaks.json") +async def get_stem_peaks(job_id: str) -> Response: + """Return pre-computed waveform peaks for all stems.""" + if not JOB_ID_RE.match(job_id): + raise HTTPException(status_code=404, detail="job not found") + job = registry_get(job_id) + if job is None or job.status != "done": + raise HTTPException(status_code=404, detail="job not ready") + path = (JOBS_DIR / job_id / "stems" / "peaks.json").resolve() + if not path.is_file() or not path.is_relative_to(JOBS_DIR.resolve()): + raise HTTPException(status_code=404, detail="peaks not found") + return FileResponse( + path, + media_type="application/json", + headers={"Cache-Control": "public, max-age=31536000, immutable"}, + ) + + +@router.api_route("/jobs/{job_id}/stems/{name}.wav", methods=["GET", "HEAD"], response_model=None) +async def get_stem( + job_id: str, + name: str, + start: float | None = Query(default=None, ge=0, description="Trim start in seconds"), + end: float | None = Query(default=None, gt=0, description="Trim end in seconds"), +) -> FileResponse | StreamingResponse: + """Download a WAV stem. Optional ?start=&end= trims to a time region.""" + path = _validate_stem_path(job_id, name) + + if start is None and end is None: + return FileResponse(path, media_type="audio/wav", filename=f"{name}.wav") + + if start is None or end is None or start >= end: + raise HTTPException( + status_code=422, + detail="start and end are both required and start must be less than end", + ) + + cmd = [ + ffmpeg_executable(), + "-nostdin", + "-loglevel", + "error", + "-ss", + str(start), + "-i", + str(path), + "-t", + str(end - start), + "-c:a", + "pcm_s16le", + "-f", + "wav", + "pipe:1", + ] + return StreamingResponse( + _stream_ffmpeg(cmd), + media_type="audio/wav", + headers={"Content-Disposition": f'attachment; filename="{name}_region.wav"'}, + ) + + +@router.get("/jobs/{job_id}/stems/{name}.mp3") +async def get_stem_mp3( + job_id: str, + name: str, + start: float | None = Query(default=None, ge=0, description="Trim start in seconds"), + end: float | None = Query(default=None, gt=0, description="Trim end in seconds"), +) -> Response: + """Stem as MP3 (VBR ~190 kbps). Full stems are cached to disk; ?start=&end= + streams a freshly-trimmed region (uncached).""" + path = _validate_stem_path(job_id, name) + + if (start is None) != (end is None) or (start is not None and start >= end): + raise HTTPException( + status_code=422, + detail="start and end are both required and start must be less than end", + ) + + # Full-stem requests (no trim) are cached to disk so repeat loads — the + # common case for the mobile player — are instant instead of re-encoding. + if start is None: + cached = await _ensure_cached_mp3(path) + return FileResponse( + cached, + media_type="audio/mpeg", + headers={ + "Content-Disposition": f'attachment; filename="{name}.mp3"', + # Stems are immutable once a job is done — let the phone cache + # them so a re-load is instant and offline-friendly. + "Cache-Control": "public, max-age=31536000, immutable", + }, + ) + + pre_seek = ["-ss", str(start)] if start is not None else [] + post_seek = ["-t", str(end - start)] if start is not None else [] + + cmd = [ + ffmpeg_executable(), + "-nostdin", + "-loglevel", + "error", + *pre_seek, + "-i", + str(path), + *post_seek, + "-q:a", + "2", # VBR ~190 kbps + "-f", + "mp3", + "pipe:1", + ] + filename = f"{name}_region.mp3" if start is not None else f"{name}.mp3" + return StreamingResponse( + _stream_ffmpeg(cmd), + media_type="audio/mpeg", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.get("/jobs/{job_id}/mixdown.{ext}", response_model=None) +async def get_mixdown( + job_id: str, + ext: str, + stems: str = Query(..., description="Comma-separated lane names to sum"), + gains: str = Query(..., description="Comma-separated linear gains, parallel to stems"), + start: float | None = Query(default=None, ge=0, description="Trim start in seconds"), + end: float | None = Query(default=None, gt=0, description="Trim end in seconds"), +) -> StreamingResponse: + """Render a fresh mixdown of the given lanes at the given gains, streamed as + WAV or MP3. Mirrors the studio mixer (per-stem volume, mute, solo) so the + exported file matches what is heard. The master fader is intentionally not + applied -- it is a monitoring level, not part of the mix. Optional ?start=&end= + trims to a loop region.""" + if ext not in ("wav", "mp3", "flac"): + raise HTTPException(status_code=404, detail="not found") + + names, parsed_gains = _parse_lane_gains(stems, gains) + if (start is None) != (end is None) or (start is not None and start >= end): + raise HTTPException( + status_code=422, + detail="start and end are both required and start must be less than end", + ) + + # Validates job_id (404), job done (404), and path traversal (404) per stem. + paths = [_validate_stem_path(job_id, name) for name in names] + + pre_seek = ["-ss", str(start)] if start is not None else [] + post_seek = ["-t", str(end - start)] if start is not None else [] + + cmd: list[str] = [ffmpeg_executable(), "-nostdin", "-loglevel", "error"] + for p in paths: + cmd += [*pre_seek, "-i", str(p)] + # Apply each lane's gain, then sum with amix (normalize=0 keeps levels faithful, + # matching collect.py). A single audible lane skips amix (a 1-input amix is a no-op). + filters = [f"[{i}:a]volume={g:.6f}[a{i}]" for i, g in enumerate(parsed_gains)] + n = len(paths) + if n > 1: + labels = "".join(f"[a{i}]" for i in range(n)) + filters.append(f"{labels}amix=inputs={n}:normalize=0[mix]") + out_label = "[mix]" + else: + out_label = "[a0]" + codec = MIXDOWN_CODECS[ext] + cmd += ["-filter_complex", ";".join(filters), "-map", out_label, *post_seek, *codec, "pipe:1"] + + media_type = MIXDOWN_MEDIA_TYPES[ext] + return StreamingResponse( + _stream_ffmpeg(cmd), + media_type=media_type, + headers={"Content-Disposition": f'attachment; filename="mixdown.{ext}"'}, + ) + + +def _safe_title(title: str | None) -> str: + """Sanitize a song title into a filename-safe slug (matches the frontend).""" + safe = re.sub(r"[^a-zA-Z0-9]+", "_", title or "") + safe = re.sub(r"_{2,}", "_", safe).strip("_")[:80].strip("_") + return safe or "stems" + + +@router.get("/jobs/{job_id}/video.mp4", response_model=None) +async def get_video_mixdown( + job_id: str, + stems: str = Query(..., description="Comma-separated lane names to sum"), + gains: str = Query(..., description="Comma-separated linear gains, parallel to stems"), +) -> StreamingResponse: + """Mux a fresh audio mixdown of the current mixer state with the job's preserved + video into an MP4 (issue #219). Mirrors get_mixdown's audio graph (encoded + as AAC) and stream-copies video.mp4 -- the silent video kept from an .mp4 upload + or the real video stream downloaded for a YouTube job. 404 when the job has no + video (SoundCloud / plain audio uploads). + + Streamed as fragmented MP4 (frag_keyframe+empty_moov) since the output pipe is + not seekable -- +faststart would require a seekable file. The full song is + exported; no region trim, to avoid A/V drift from stream-copy seeking.""" + if not JOB_ID_RE.match(job_id): + raise HTTPException(status_code=404, detail="job not found") + job = registry_get(job_id) + if job is None or job.status != "done": + raise HTTPException(status_code=404, detail="job not ready") + + video_path = (JOBS_DIR / job_id / "video.mp4").resolve() + if not video_path.is_file() or not video_path.is_relative_to(JOBS_DIR.resolve()): + raise HTTPException(status_code=404, detail="no video track for this job") + + names, parsed_gains = _parse_lane_gains(stems, gains) + # Validates job_id (404), job done (404), and path traversal (404) per stem. + paths = [_validate_stem_path(job_id, name) for name in names] + + cmd: list[str] = [ffmpeg_executable(), "-nostdin", "-loglevel", "error"] + for p in paths: + cmd += ["-i", str(p)] + cmd += ["-i", str(video_path)] + video_idx = len(paths) + # Per-lane gain then amix (normalize=0 keeps levels faithful). A single audible + # lane skips amix (a 1-input amix is a no-op), matching get_mixdown. + filters = [f"[{i}:a]volume={g:.6f}[a{i}]" for i, g in enumerate(parsed_gains)] + n = len(paths) + if n > 1: + labels = "".join(f"[a{i}]" for i in range(n)) + filters.append(f"{labels}amix=inputs={n}:normalize=0[mix]") + out_label = "[mix]" + else: + out_label = "[a0]" + cmd += [ + "-filter_complex", + ";".join(filters), + "-map", + out_label, + "-map", + f"{video_idx}:v", + "-c:v", + "copy", + "-c:a", + "aac", + "-b:a", + "192k", + "-shortest", + "-movflags", + "frag_keyframe+empty_moov", + "-f", + "mp4", + "pipe:1", + ] + + filename = f"{_safe_title(job.title)}_video.mp4" + return StreamingResponse( + _stream_ffmpeg(cmd), + media_type="video/mp4", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +def _build_stems_zip(sources: list[tuple[str, Path]], fmt: str, dest: Path) -> None: + """Blocking: write the stems into a ZIP. WAV files are stored as-is; MP3 and + FLAC are transcoded per stem via ffmpeg. ZIP_STORED throughout - audio doesn't + meaningfully compress, and STORED keeps the build fast. Runs in a thread.""" + if fmt == "wav": + with zipfile.ZipFile(dest, "w", zipfile.ZIP_STORED) as zf: + for name, p in sources: + zf.write(p, arcname=f"{name}.wav") + return + encode = _ENCODE_ARGS[fmt] + with tempfile.TemporaryDirectory() as td, zipfile.ZipFile(dest, "w", zipfile.ZIP_STORED) as zf: + for name, p in sources: + out = os.path.join(td, f"{name}.{fmt}") + cmd = [ + ffmpeg_executable(), + "-nostdin", + "-loglevel", + "error", + "-i", + str(p), + *encode, + "-f", + fmt, + out, + ] + proc = subprocess.run( # noqa: S603 — list args, no shell, trusted ffmpeg + cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + timeout=TIMEOUT_FFMPEG, + ) + if proc.returncode != 0: + tail = proc.stderr[-2000:].decode("utf-8", "replace") + raise RuntimeError(f"ffmpeg failed for {name}: {tail}") + zf.write(out, arcname=f"{name}.{fmt}") + + +@router.get("/jobs/{job_id}/stems/all.zip") +async def get_all_stems_zip( + job_id: str, + fmt: str = Query(default="wav", alias="format"), + stems: str | None = Query(default=None, description="Comma-separated stems; default all"), +) -> FileResponse: + """Bundle the requested stems into a single ZIP, named after the song. + + `stems` is the active subset selected in the DAW (whitelisted). When omitted, + every available stem is included.""" + if not JOB_ID_RE.match(job_id): + raise HTTPException(status_code=404, detail="job not found") + if fmt not in ("wav", "mp3", "flac"): + raise HTTPException(status_code=422, detail="format must be 'wav', 'mp3', or 'flac'") + job = registry_get(job_id) + if job is None or job.status != "done": + raise HTTPException(status_code=404, detail="job not ready") + + # Resolve the requested subset (whitelisted) or fall back to all stems. + if stems: + requested = {s for s in stems.split(",") if s} + if not requested <= set(STEM_NAMES): + raise HTTPException(status_code=422, detail="unknown stem requested") + wanted = [name for name in STEM_NAMES if name in requested] + else: + wanted = list(STEM_NAMES) + + jobs_root = JOBS_DIR.resolve() + stems_dir = (JOBS_DIR / job_id / "stems").resolve() + if not stems_dir.is_dir() or not stems_dir.is_relative_to(jobs_root): + raise HTTPException(status_code=404, detail="stems not found") + + sources: list[tuple[str, Path]] = [] + for name in wanted: + p = (stems_dir / f"{name}.wav").resolve() + if p.is_file() and p.is_relative_to(jobs_root): + sources.append((name, p)) + if not sources: + raise HTTPException(status_code=404, detail="no stems found") + + fd, tmp = tempfile.mkstemp(prefix="stemdeck_zip_", suffix=".zip") + os.close(fd) + tmp_path = Path(tmp) + try: + await asyncio.to_thread(_build_stems_zip, sources, fmt, tmp_path) + except Exception: + tmp_path.unlink(missing_ok=True) + logger.exception("failed to build stems zip for job %s", job_id) + raise HTTPException(status_code=500, detail="failed to build archive") from None + + filename = f"{_safe_title(job.title)}_stems.zip" + return FileResponse( + tmp_path, + media_type="application/zip", + filename=filename, + background=BackgroundTask(lambda: tmp_path.unlink(missing_ok=True)), + ) diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..7045309 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,121 @@ +import os +import re +import sys +from pathlib import Path + + +def _env_int(name: str, default: int) -> int: + raw = os.environ.get(name, "").strip() + try: + return int(raw) if raw else default + except ValueError: + return default + + +def _env_path(name: str, default: Path) -> Path: + raw = os.environ.get(name, "").strip() + return Path(raw).expanduser().resolve() if raw else default + + +def _detect_device() -> str: + """Pick best available Torch device for Demucs. Override via + STEMDECK_DEMUCS_DEVICE env var ('cuda' | 'mps' | 'cpu'). Apple Silicon + silently falls back to CPU otherwise -- demucs's CLI default is + "cuda if available else cpu" and macOS has no CUDA, leaving the + integrated GPU idle and processing 3-5x slower than necessary.""" + forced = os.environ.get("STEMDECK_DEMUCS_DEVICE", "").strip().lower() + if forced in ("cuda", "mps", "cpu"): + return forced + try: + import torch + + if torch.cuda.is_available(): + return "cuda" + if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available(): + return "mps" + except ImportError: + pass + return "cpu" + + +ROOT = Path(__file__).resolve().parent.parent.parent +STATIC_DIR = ROOT / "static" +STEM_NAMES: tuple[str, ...] = ("vocals", "drums", "bass", "guitar", "piano", "other") +JOB_ID_RE = re.compile(r"^[a-f0-9]{12}$") + +# Runtime knobs -- env-backed so Docker / desktop packaging / local dev can +# tune without a code edit. STEMDECK_DATA_DIR is the portable app root for +# mutable runtime data; when unset, dev behavior remains the repo-local jobs/ +# folder. +PORTABLE_DATA_DIR_ENABLED = bool(os.environ.get("STEMDECK_DATA_DIR", "").strip()) +DATA_DIR = _env_path("STEMDECK_DATA_DIR", ROOT) +JOBS_DIR = _env_path( + "STEMDECK_JOBS_DIR", + (DATA_DIR / "jobs") if PORTABLE_DATA_DIR_ENABLED else (ROOT / "jobs"), +) +CACHE_DIR = _env_path("STEMDECK_CACHE_DIR", DATA_DIR / "cache") +DOWNLOADS_DIR = _env_path("STEMDECK_DOWNLOADS_DIR", DATA_DIR / "downloads") +MODELS_DIR = _env_path("STEMDECK_MODELS_DIR", DATA_DIR / "models") +LOGS_DIR = _env_path("STEMDECK_LOGS_DIR", DATA_DIR / "logs") +FFMPEG_DIR = _env_path("STEMDECK_FFMPEG_DIR", DATA_DIR / "ffmpeg") +FFMPEG_BIN = _env_path( + "STEMDECK_FFMPEG", + FFMPEG_DIR / ("ffmpeg.exe" if sys.platform.startswith("win") else "ffmpeg"), +) +FFPROBE_BIN = _env_path( + "STEMDECK_FFPROBE", + FFMPEG_DIR / ("ffprobe.exe" if sys.platform.startswith("win") else "ffprobe"), +) +DEMUCS_MODEL = os.environ.get("STEMDECK_DEMUCS_MODEL", "htdemucs_6s").strip() or "htdemucs_6s" +DEMUCS_DEVICE = _detect_device() +MAX_DURATION_SEC = max(60, _env_int("STEMDECK_MAX_DURATION_SEC", 1200)) # 20 min default +JOB_TTL_SECONDS = max(300, _env_int("STEMDECK_JOB_TTL_SECONDS", 24 * 3600)) # 24 h default +MAX_PENDING_JOBS = max(1, min(50, _env_int("STEMDECK_MAX_PENDING_JOBS", 3))) +TIMEOUT_FFMPEG = _env_int("STEMDECK_TIMEOUT_FFMPEG", 300) +TIMEOUT_ANALYZE = _env_int("STEMDECK_TIMEOUT_ANALYZE", 120) +TIMEOUT_DEMUCS_STALL = _env_int("STEMDECK_TIMEOUT_DEMUCS_STALL", 1800) +# Max height for the MP4 video stream pulled from YouTube (issue #219). +# Capped to keep downloads reasonable; 1080p of a full song is large. +VIDEO_MAX_HEIGHT = max(144, _env_int("STEMDECK_VIDEO_MAX_HEIGHT", 720)) + + +def ffmpeg_executable() -> str: + """Return the preferred FFmpeg executable. + + In portable mode, setup places FFmpeg under DATA_DIR/ffmpeg. Prefer that + binary when present; otherwise fall back to PATH so local dev and Docker + keep working exactly as before. + """ + return str(FFMPEG_BIN) if FFMPEG_BIN.is_file() else "ffmpeg" + + +def ffprobe_executable() -> str: + """Return the preferred ffprobe executable (same bundled dir as ffmpeg).""" + return str(FFPROBE_BIN) if FFPROBE_BIN.is_file() else "ffprobe" + + +def configure_portable_environment() -> None: + """Keep generated caches inside the portable data folder when requested. + + This is intentionally best-effort. It only sets variables that are still + unset, so explicit caller/env choices win. + """ + if FFMPEG_DIR.is_dir(): + path = os.environ.get("PATH", "") + ffmpeg_path = str(FFMPEG_DIR) + if ffmpeg_path not in path.split(os.pathsep): + os.environ["PATH"] = ffmpeg_path + (os.pathsep + path if path else "") + + if PORTABLE_DATA_DIR_ENABLED: + os.environ.setdefault("XDG_CACHE_HOME", str(CACHE_DIR)) + os.environ.setdefault("TORCH_HOME", str(MODELS_DIR / "torch")) + + +def ensure_runtime_dirs() -> None: + paths = ( + (JOBS_DIR, CACHE_DIR, DOWNLOADS_DIR, MODELS_DIR, LOGS_DIR) + if PORTABLE_DATA_DIR_ENABLED + else (JOBS_DIR,) + ) + for path in paths: + path.mkdir(parents=True, exist_ok=True) diff --git a/app/core/models.py b/app/core/models.py new file mode 100644 index 0000000..26e4dc2 --- /dev/null +++ b/app/core/models.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import dataclasses +import time +from dataclasses import dataclass, field +from typing import Any, Literal + + +class JobCancelled(Exception): + """Raised inside a pipeline stage when the job's cancel flag is set.""" + + +JobStatus = Literal[ + "queued", "downloading", "analyzing", "separating", "processing", "done", "error", "cancelled" +] + + +def _set(job: Job, **fields: object) -> None: + """Mutate Job fields. SSE polling picks up the change automatically.""" + for k, v in fields.items(): + if k == "stage": + job.stage_message = v # type: ignore[assignment] + else: + setattr(job, k, v) + + +@dataclass +class Job: + id: str + status: JobStatus = "queued" + progress: float = 0.0 + stage_message: str = "Queued" + title: str | None = None + duration_sec: float | None = None + thumbnail: str | None = None + bpm: int | None = None + key: str | None = None + scale: str | None = None # "Major" / "Natural Minor" + key_confidence: int | None = None # 0-100 percent + lufs: float | None = None # ITU-R BS.1770 integrated loudness (dB) + peak_db: float | None = None # sample peak in dBFS (close to true peak) + dynamic_range: float | None = None # peak_db - integrated LUFS (dB) + tempo_stability: int | None = None # 0-100, beat interval consistency + stem_presence: dict[str, int] | None = None # per-stem RMS 0-100 + sections: list[dict] | None = None # [{id, name, start, end, color}] + tags: list[str] | None = None # YouTube tags + categories, lowercased, max 8 + stems: list[dict[str, str]] = field(default_factory=list) + # Subset of stems the user chose at submit. The pipeline produces all + # 6 regardless (Demucs htdemucs_6s is fixed), but after collect we + # mix down only the selected ones into mix.wav so the user can + # download a single track containing just their chosen stems. + selected_stems: list[str] = field(default_factory=list) + mix_url: str | None = None # populated when a strict subset was selected + source_url: str | None = None # original URL or "local:" for file uploads + # True when a silent video track (video.mp4) was preserved from an .mp4 + # upload, enabling the "Export Mix (with video)" MP4 export. + has_video: bool = False + error: str | None = None + # Set by POST /api/jobs/{id}/cancel; consumed by pipeline stages. + # Not surfaced via to_state() -- it's internal control state. + cancel_requested: bool = False + # Wall-clock timestamps for metadata-based sweep -- more predictable + # than directory mtime, which can be touched by unrelated FS events. + created_at: float = field(default_factory=time.time) + + def to_state(self) -> dict[str, Any]: + return { + "job_id": self.id, + "status": self.status, + "progress": self.progress, + "stage": self.stage_message, + "title": self.title, + "duration": self.duration_sec, + "thumbnail": self.thumbnail, + "bpm": self.bpm, + "key": self.key, + "scale": self.scale, + "key_confidence": self.key_confidence, + "lufs": self.lufs, + "peak_db": self.peak_db, + "dynamic_range": self.dynamic_range, + "tempo_stability": self.tempo_stability, + "stem_presence": self.stem_presence, + "sections": self.sections, + "tags": self.tags, + "stems": self.stems, + "selected_stems": self.selected_stems, + "mix_url": self.mix_url, + "source_url": self.source_url, + "has_video": self.has_video, + "error": self.error, + "created_at": self.created_at, + } + + def to_record(self) -> dict[str, Any]: + return {field: getattr(self, field) for field in _JOB_FIELDS} + + @classmethod + def from_record(cls, data: dict[str, Any]) -> Job: + fields = {key: value for key, value in data.items() if key in _JOB_FIELDS} + job_id = str(fields.pop("id", "")).strip() + if not job_id: + raise ValueError("job record missing id") + job = cls(id=job_id) + for key, value in fields.items(): + setattr(job, key, value) + job.cancel_requested = False + return job + + +_JOB_FIELDS = frozenset(f.name for f in dataclasses.fields(Job) if f.name != "cancel_requested") diff --git a/app/core/registry.py b/app/core/registry.py new file mode 100644 index 0000000..06df682 --- /dev/null +++ b/app/core/registry.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import json +import logging +import subprocess +import threading +from pathlib import Path + +from app.core.config import JOB_ID_RE, STEM_NAMES +from app.core.models import Job + +logger = logging.getLogger("stemdeck.registry") + +REGISTRY_VERSION = 1 + +_jobs: dict[str, Job] = {} +# Active subprocesses keyed by job_id (currently only Demucs). Lets +# POST /cancel terminate the running process from the API thread instead +# of waiting for the pipeline thread to notice the cancel flag. +_procs: dict[str, subprocess.Popen] = {} +_lock = threading.Lock() +_REGISTRY_FILE = "registry.json" +_TERMINAL = {"done"} + + +def register(job: Job) -> Job: + with _lock: + _jobs[job.id] = job + return job + + +def register_if_capacity(job: Job, max_pending: int) -> bool: + """Atomically check pending count and register if under capacity. + Returns True if registered, False if the queue is full.""" + with _lock: + pending = sum(1 for j in _jobs.values() if j.status == "queued") + if pending >= max_pending: + return False + _jobs[job.id] = job + return True + + +def get(job_id: str) -> Job | None: + with _lock: + return _jobs.get(job_id) + + +def remove(job_id: str) -> None: + with _lock: + _jobs.pop(job_id, None) + _procs.pop(job_id, None) + + +def all_jobs() -> dict[str, Job]: + """Return a snapshot of the registry for sweep / cleanup.""" + with _lock: + return dict(_jobs) + + +def _migrate(data: dict) -> dict: + """Upgrade registry JSON to REGISTRY_VERSION incrementally. + Each block transforms v(n) → v(n+1) so older snapshots always catch up.""" + version = data.get("version", 0) + if version < 1: + # v0 → v1: version field was absent; no structural change needed. + data["version"] = 1 + version = 1 + # Future migrations go here as `if version < N:` blocks. + return data + + +def persist(jobs_dir: Path) -> None: + """Persist terminal jobs so completed library entries survive restarts.""" + try: + jobs_dir.mkdir(parents=True, exist_ok=True) + except OSError: + logger.warning("cannot create jobs dir %s; skipping persist", jobs_dir, exc_info=True) + return + with _lock: + records = [ + job.to_record() + for job in sorted(_jobs.values(), key=lambda item: item.created_at) + if job.status in _TERMINAL + ] + payload = json.dumps({"version": REGISTRY_VERSION, "jobs": records}, indent=2) + "\n" + path = jobs_dir / _REGISTRY_FILE + tmp = path.with_suffix(".json.tmp") + tmp.write_text(payload, encoding="utf-8") + tmp.replace(path) + + +def restore(jobs_dir: Path) -> None: + """Load persisted jobs and recover completed orphan jobs from disk.""" + jobs_dir.mkdir(parents=True, exist_ok=True) + path = jobs_dir / _REGISTRY_FILE + if path.is_file(): + try: + data = _migrate(json.loads(path.read_text(encoding="utf-8"))) + to_add = {} + for record in data.get("jobs", []): + job = Job.from_record(record) + if JOB_ID_RE.match(job.id) and job.status in _TERMINAL and job.title: + to_add[job.id] = job + with _lock: + _jobs.update(to_add) + except (OSError, json.JSONDecodeError, TypeError, ValueError): + logger.warning("failed to load registry from %s", path, exc_info=True) + + with _lock: + known = set(_jobs) + changed = False + for job_dir in jobs_dir.iterdir(): + if not job_dir.is_dir() or not JOB_ID_RE.match(job_dir.name) or job_dir.name in known: + continue + recovered = _recover_done_job(job_dir) + if recovered is not None: + with _lock: + _jobs[recovered.id] = recovered + changed = True + if changed: + persist(jobs_dir) + + +def _recover_done_job(job_dir: Path) -> Job | None: + stems_dir = job_dir / "stems" + if not stems_dir.is_dir(): + return None + stems = [ + {"name": name, "url": f"/api/jobs/{job_dir.name}/stems/{name}.wav"} + for name in ("original", *STEM_NAMES) + if (stems_dir / f"{name}.wav").is_file() + ] + if not stems: + return None + mix_url = None + if (stems_dir / "mix.wav").is_file(): + mix_url = f"/api/jobs/{job_dir.name}/stems/mix.wav" + selected = [stem["name"] for stem in stems if stem["name"] in STEM_NAMES] or list(STEM_NAMES) + meta_path = job_dir / "metadata.json" + if not meta_path.is_file(): + return None + meta: dict = {} + try: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + pass + return Job( + id=job_dir.name, + status="done", + progress=1.0, + stage_message="Done", + stems=stems, + selected_stems=selected, + mix_url=mix_url, + created_at=job_dir.stat().st_mtime, + title=meta.get("title"), + thumbnail=meta.get("thumbnail"), + duration_sec=meta.get("duration_sec"), + bpm=meta.get("bpm"), + key=meta.get("key"), + scale=meta.get("scale"), + key_confidence=meta.get("key_confidence"), + lufs=meta.get("lufs"), + peak_db=meta.get("peak_db"), + dynamic_range=meta.get("dynamic_range"), + tempo_stability=meta.get("tempo_stability"), + stem_presence=meta.get("stem_presence"), + sections=meta.get("sections"), + tags=meta.get("tags"), + ) + + +def set_proc(job_id: str, proc: subprocess.Popen | None) -> None: + with _lock: + if proc is None: + _procs.pop(job_id, None) + else: + _procs[job_id] = proc + + +def get_proc(job_id: str) -> subprocess.Popen | None: + with _lock: + return _procs.get(job_id) diff --git a/app/core/settings.py b/app/core/settings.py new file mode 100644 index 0000000..1b7e9f7 --- /dev/null +++ b/app/core/settings.py @@ -0,0 +1,140 @@ +"""Runtime, user-toggleable settings (persisted to disk). + +These are read live (unlike the env-var constants in config.py, which are fixed +at startup), so the Settings UI can change them without a restart: + +- `allow_network` — whether StemDeck answers requests from other devices. +- `max_duration_sec` — longest track accepted for processing. +- `video_max_height` — max video resolution for MP4 export / YouTube pulls. + +Defaults fall back to the config.py constants (which honor their env vars), so +nothing changes until the user overrides a value. +""" + +from __future__ import annotations + +import json +import logging +import os +import threading + +from app.core.config import DATA_DIR, MAX_DURATION_SEC, VIDEO_MAX_HEIGHT + +_log = logging.getLogger("stemdeck.settings") + +_SETTINGS_PATH = DATA_DIR / "settings.json" +_LOCK = threading.RLock() +_state: dict | None = None # whole settings dict, loaded lazily + +# Clamp bounds. Max track length is capped at 20 min (the product ceiling). +_DURATION_MIN, _DURATION_MAX = 60, 1200 # 1 min .. 20 min +_HEIGHT_MIN, _HEIGHT_MAX = 144, 2160 +_PORT_MIN, _PORT_MAX = 1024, 65535 +DEFAULT_PORT = 8000 + + +def _default_allow_network() -> bool: + # STEMDECK_ALLOW_NETWORK takes precedence when set explicitly. + # Otherwise: desktop keeps network off (user opts in via UI toggle); + # server/Docker deployments open it by default since network access is + # the entire point of a headless deployment. + env = os.environ.get("STEMDECK_ALLOW_NETWORK") + if env is not None: + return env.strip() == "1" + return os.environ.get("STEMDECK_DESKTOP") != "1" + + +def _load() -> dict: + try: + data = json.loads(_SETTINGS_PATH.read_text(encoding="utf-8")) + if isinstance(data, dict): + return data + except FileNotFoundError: + pass # no settings file yet — first run; use defaults + except Exception: + # Corrupt/unreadable file: fall back to defaults rather than crash. + _log.warning("could not read settings from %s", _SETTINGS_PATH, exc_info=True) + return {} + + +def _ensure() -> dict: + global _state + if _state is None: + _state = _load() + return _state + + +def _save() -> None: + try: + _SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) + _SETTINGS_PATH.write_text(json.dumps(_ensure()), encoding="utf-8") + except Exception: + # Persistence is best-effort (read-only FS, permissions): the in-memory + # value still applies for this session, so don't fail the request. + _log.warning("could not persist settings to %s", _SETTINGS_PATH, exc_info=True) + + +def _num(v: object) -> int | None: + return int(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else None + + +# ── allow_network ── +def get_allow_network() -> bool: + with _LOCK: + v = _ensure().get("allow_network") + return v if isinstance(v, bool) else _default_allow_network() + + +def set_allow_network(value: bool) -> bool: + with _LOCK: + _ensure()["allow_network"] = bool(value) + _save() + return bool(value) + + +# ── max_duration_sec ── +def get_max_duration_sec() -> int: + with _LOCK: + v = _num(_ensure().get("max_duration_sec")) + return max(_DURATION_MIN, min(_DURATION_MAX, v)) if v is not None else MAX_DURATION_SEC + + +def set_max_duration_sec(value: int) -> int: + with _LOCK: + clamped = max(_DURATION_MIN, min(_DURATION_MAX, int(value))) + _ensure()["max_duration_sec"] = clamped + _save() + return clamped + + +# ── video_max_height ── +def get_video_max_height() -> int: + with _LOCK: + v = _num(_ensure().get("video_max_height")) + return max(_HEIGHT_MIN, min(_HEIGHT_MAX, v)) if v is not None else VIDEO_MAX_HEIGHT + + +def set_video_max_height(value: int) -> int: + with _LOCK: + clamped = max(_HEIGHT_MIN, min(_HEIGHT_MAX, int(value))) + _ensure()["video_max_height"] = clamped + _save() + return clamped + + +# ── port ── +# The preferred port the server binds on launch. The desktop launcher reads this +# (default 8000) before spawning the backend; a self-hosted server's --port wins. +# Changing it needs a restart — the socket is bound at startup. +def get_port() -> int: + with _LOCK: + v = _num(_ensure().get("port")) + return max(_PORT_MIN, min(_PORT_MAX, v)) if v is not None else DEFAULT_PORT + + +def set_port(value: int) -> int: + with _LOCK: + clamped = max(_PORT_MIN, min(_PORT_MAX, int(value))) + _ensure()["port"] = clamped + _save() + return clamped diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..483baff --- /dev/null +++ b/app/main.py @@ -0,0 +1,376 @@ +from __future__ import annotations + +import asyncio +import ctypes +import functools +import logging +import os +import re +import signal +import socket +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as package_version + +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import FileResponse, PlainTextResponse +from fastapi.staticfiles import StaticFiles + +from app.api.router import router +from app.core.config import ( + DEMUCS_DEVICE, + DEMUCS_MODEL, + FFMPEG_BIN, + JOBS_DIR, + STATIC_DIR, + configure_portable_environment, + ensure_runtime_dirs, +) +from app.core.registry import restore as restore_registry +from app.core.settings import ( + get_allow_network, + get_max_duration_sec, + get_port, + get_video_max_height, + set_allow_network, + set_max_duration_sec, + set_port, + set_video_max_height, +) +from app.pipeline.collect import sweep_old_jobs + +# Show our INFO-level logs through uvicorn's root handler. Without this, +# Python's default root level (WARNING) silently drops every +# logger.info(...) call across the app, including the analyze +# diagnostics ("chroma:", "key candidates:"). +logging.getLogger("stemdeck").setLevel(logging.INFO) +logging.getLogger("stemdeck").info("demucs config: model=%s device=%s", DEMUCS_MODEL, DEMUCS_DEVICE) + +configure_portable_environment() + +# Pre-import librosa so the first job submission doesn't pay the 1-2 s +# cost of numpy/scipy/numba lazy initialization. Adds ~1 s to server +# boot in exchange for snappier first-job UX. Best-effort: if librosa +# isn't installed, analyze() degrades gracefully on its own. +try: + import librosa # noqa: F401 -- intentional warm-up import +except ImportError: + pass + +_log = logging.getLogger("stemdeck") + + +def _process_exists(pid: int) -> bool: + if os.name != "nt": + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + ERROR_INVALID_PARAMETER = 87 + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if handle: + kernel32.CloseHandle(handle) + return True + return ctypes.get_last_error() != ERROR_INVALID_PARAMETER + + +def app_version() -> str: + # Version is git-tag-derived via hatch-vcs (#169). Prefer installed package + # metadata (set at install/build from the tag); fall back to the generated + # app/_version.py for non-installed runs, then a dev placeholder. + try: + return package_version("stemdeck") + except PackageNotFoundError: + pass + try: + from app._version import __version__ + + return str(__version__) + except Exception: + return "0.0.0-dev" + + +def _sweep_disabled() -> bool: + """The desktop app is a personal, user-curated library (folders + Trash), + with its track list persisted permanently in ~/Documents/StemDeck. The 24h + job TTL sweep -- a sensible disk-hygiene default for the shared server/Docker + deployment -- would wrongly purge stems the user kept, leaving orphaned + library entries that ask to "re-upload to restore". + + So skip the sweep under the desktop shell (STEMDECK_DESKTOP=1), or when a + self-hosted deployment opts into a persistent library + (STEMDECK_PERSIST_LIBRARY=1 -- set by default in run.sh). The user manages + disk via Trash. Shared/Docker deployments that set neither keep the sweep.""" + return ( + os.environ.get("STEMDECK_DESKTOP") == "1" + or os.environ.get("STEMDECK_PERSIST_LIBRARY") == "1" + ) + + +async def _sweep_loop() -> None: + if _sweep_disabled(): + _log.info("job TTL sweep disabled (persistent library; user-managed)") + return + while True: + try: + await asyncio.to_thread(sweep_old_jobs, JOBS_DIR) + except Exception: + _log.warning("sweep failed", exc_info=True) + await asyncio.sleep(3600) + + +async def _desktop_parent_watchdog(parent_pid: int) -> None: + while True: + if not _process_exists(parent_pid): + _log.info("desktop parent process exited; stopping backend") + # Send SIGTERM to ourselves so uvicorn runs its shutdown sequence + # instead of bypassing cleanup with os._exit(). + os.kill(os.getpid(), signal.SIGTERM) + return + await asyncio.sleep(1) + + +@asynccontextmanager +async def lifespan(_: FastAPI) -> AsyncIterator[None]: + _background_tasks = set() + t = asyncio.create_task(_sweep_loop()) + _background_tasks.add(t) + t.add_done_callback(_background_tasks.discard) + if os.environ.get("STEMDECK_DESKTOP") == "1": + parent_pid = os.environ.get("STEMDECK_PARENT_PID") + if parent_pid: + try: + parent_pid_int = int(parent_pid) + except ValueError: + _log.warning("invalid STEMDECK_PARENT_PID=%r", parent_pid) + else: + if parent_pid_int > 0 and parent_pid_int != os.getpid(): + wt = asyncio.create_task(_desktop_parent_watchdog(parent_pid_int)) + _background_tasks.add(wt) + wt.add_done_callback(_background_tasks.discard) + yield + + +# Phones hitting the self-hosted server URL get the mobile UI; everything +# else (desktop browsers, and the Tauri webviews, which all report desktop +# user-agents) gets the DAW. Tablets are intentionally treated as desktop — +# the DAW layout is usable there. "Mobi" is the cross-browser marker for a +# phone form factor (Chrome/Firefox/Safari all include it); the rest cover +# vendors that don't. +_MOBILE_UA_RE = re.compile( + r"Mobi|Android|iPhone|iPod|IEMobile|BlackBerry|Opera Mini", re.IGNORECASE +) + + +def _is_mobile_ua(user_agent: str) -> bool: + return bool(user_agent) and _MOBILE_UA_RE.search(user_agent) is not None + + +app = FastAPI( + title="StemDeck", + description="Paste a YouTube URL or upload an audio file, get audio stems split into a DAW-style player.", + version=app_version(), + lifespan=lifespan, +) + + +@app.get("/", include_in_schema=False) +def index(request: Request) -> FileResponse: + """Serve the mobile shell to phones, the DAW to everyone else. `?ui=mobile` + / `?ui=desktop` forces either one (handy for testing from a desktop). This + route is registered before the StaticFiles mount at "/", so it wins for the + bare path while the mount still serves every other asset.""" + ui = request.query_params.get("ui") + if ui == "mobile": + mobile = True + elif ui == "desktop": + mobile = False + else: + mobile = _is_mobile_ua(request.headers.get("user-agent", "")) + page = "mobile/index.html" if mobile else "index.html" + return FileResponse(STATIC_DIR / page) + + +@app.get("/health", include_in_schema=False) +def health_root() -> dict[str, object]: + return health() + + +@app.get("/api/health", tags=["health"]) +def health() -> dict[str, object]: + return { + "name": "StemDeck", + "status": "ok", + "version": app_version(), + "ffmpeg_configured": FFMPEG_BIN.is_file(), + "demucs_model": DEMUCS_MODEL, + "demucs_device": DEMUCS_DEVICE, + } + + +def _is_lan_ipv4(ip: str) -> bool: + """A reachable IPv4 LAN address to show another device: not IPv6 (link-local + needs a zone index and won't work in a browser), not loopback, not the + 169.254.x auto-config range.""" + if ":" in ip: # IPv6 + return False + if _is_loopback(ip) or ip.startswith("169.254."): + return False + parts = ip.split(".") + return len(parts) == 4 and all(p.isdigit() for p in parts) + + +def _settings_payload() -> dict[str, object]: + return { + "allow_network": get_allow_network(), + "max_duration_sec": get_max_duration_sec(), + "video_max_height": get_video_max_height(), + "port": get_port(), + } + + +@app.get("/api/settings", tags=["settings"]) +def get_settings(request: Request) -> dict[str, object]: + # LAN addresses other devices can use — loopback excluded (only works on the + # host). The port is whatever this request came in on. + port = request.url.port or 8000 + addresses = sorted(f"http://{ip}:{port}" for ip in _local_ips() if _is_lan_ipv4(ip)) + return {**_settings_payload(), "lan_addresses": addresses} + + +@app.post("/api/settings", tags=["settings"]) +async def update_settings(request: Request) -> dict[str, object]: + """Update runtime settings. Reachable from the host machine always; from a + LAN device only while network access is currently on (the gate below), so a + phone can't change settings once the owner turned access off.""" + try: + body = await request.json() + except Exception: + body = {} + if "allow_network" in body: + set_allow_network(bool(body["allow_network"])) + for key, setter in ( + ("max_duration_sec", set_max_duration_sec), + ("video_max_height", set_video_max_height), + ("port", set_port), + ): + if key in body: + try: + setter(int(body[key])) + except (TypeError, ValueError): + raise HTTPException(status_code=422, detail=f"{key} must be an integer") from None + return _settings_payload() + + +# Content-Security-Policy. Defense-in-depth so an injected string in the webview +# can't run script (and, in the desktop app, reach the exposed Tauri IPC) — #171. +# script-src has no 'unsafe-inline'/'eval': all JS is same-origin modules and the +# inline scripts/onclick were moved out. 'unsafe-inline' is allowed for *styles* +# only (the UI sets many style attributes). Allowances: +# connect-src -> same-origin API/SSE, the GitHub update check, Tauri IPC +# img-src https: -> remote YouTube/SoundCloud thumbnails +# style/font -> the Google Fonts +_CSP = ( + "default-src 'self'; " + "script-src 'self'; " + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + "font-src 'self' https://fonts.gstatic.com data:; " + "img-src 'self' data: blob: https:; " + "media-src 'self' blob: data:; " + # data:/blob: are required by multitrack.js (it fetches a data: URI during + # track init); without them Multitrack.create throws and no audio loads + # (#186). They are inline/same-origin schemes, not network endpoints, so + # they add no exfiltration channel — script-src below stays locked. + "connect-src 'self' https://api.github.com ipc: http://ipc.localhost data: blob:; " + "object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'" +) + + +# Force browsers to revalidate static assets on every request. Without +# this the JS/CSS modules can stick in disk cache across server +# restarts -- updated HTML loads against stale modules and the form +# silently breaks. `must-revalidate` keeps 304s working (cheap) while +# guaranteeing the latest mtime is honored. +@app.middleware("http") +async def security_and_cache_headers(request: Request, call_next): + response = await call_next(request) + response.headers["Content-Security-Policy"] = _CSP + if not request.url.path.startswith("/api"): + response.headers["Cache-Control"] = "no-cache, must-revalidate" + return response + + +def _is_loopback(host: str | None) -> bool: + if not host: + return False + if host.startswith("::ffff:"): # IPv4-mapped IPv6 + host = host[7:] + return host in {"127.0.0.1", "::1", "localhost"} or host.startswith("127.") + + +@functools.lru_cache(maxsize=1) +def _local_ips() -> frozenset[str]: + """The machine's own interface IPs. Used so the host always reaches the app + even via its LAN address (e.g. 192.168.x.x), not just 127.0.0.1 — turning + network access off must never cut the host off from its own server.""" + ips: set[str] = set() + try: + hostname = socket.gethostname() + for info in socket.getaddrinfo(hostname, None): + ips.add(info[4][0]) + except Exception: + # Best-effort: name resolution can fail on odd hostnames/configs; we + # still try the outbound-socket probe below and fall back to loopback. + _log.debug("hostname IP enumeration failed", exc_info=True) + try: # primary outbound IP, robust when the hostname doesn't resolve them all + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ips.add(s.getsockname()[0]) + s.close() + except Exception: + # Best-effort: no default route / offline — just return what we have. + _log.debug("outbound IP probe failed", exc_info=True) + return frozenset(ips) + + +def _is_host_request(host: str | None) -> bool: + """True when the request originates from the machine StemDeck runs on — + whether via loopback or one of its own interface addresses.""" + if _is_loopback(host): + return True + if not host: + return False + h = host[7:] if host.startswith("::ffff:") else host + return h in _local_ips() + + +# Network availability gate (Settings → "Make StemDeck available on your +# network"). Added after the headers middleware so it is the OUTERMOST layer and +# short-circuits before anything else. It NEVER stops the server — it only +# refuses requests from OTHER devices when availability is off. The host machine +# (loopback or its own LAN IP) is always served, so the app keeps working +# locally regardless of this setting. +@app.middleware("http") +async def network_gate(request: Request, call_next): + if not get_allow_network(): + client_host = request.client.host if request.client else None + if not _is_host_request(client_host): + return PlainTextResponse( + "StemDeck is not available on the network. Enable it in Settings on the host machine.", + status_code=403, + ) + return await call_next(request) + + +app.include_router(router, prefix="/api") +app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static") + +ensure_runtime_dirs() +restore_registry(JOBS_DIR) diff --git a/app/pipeline/__init__.py b/app/pipeline/__init__.py new file mode 100644 index 0000000..5df310a --- /dev/null +++ b/app/pipeline/__init__.py @@ -0,0 +1,3 @@ +from app.pipeline.runner import run_local_pipeline, run_pipeline + +__all__ = ["run_pipeline", "run_local_pipeline"] diff --git a/app/pipeline/analyze.py b/app/pipeline/analyze.py new file mode 100644 index 0000000..ab33cb0 --- /dev/null +++ b/app/pipeline/analyze.py @@ -0,0 +1,331 @@ +from __future__ import annotations + +import logging +import subprocess +from pathlib import Path + +from app.core.config import JOBS_DIR, TIMEOUT_ANALYZE, ffmpeg_executable +from app.core.models import Job, _set + +logger = logging.getLogger("stemdeck.analyze") + +# Albrecht-Shanahan key profiles, derived from a corpus of popular music +# (Albrecht & Shanahan, 2013). Critically, the minor profile here weights +# b7 high (3.48) and M7 low (0.81) — the opposite of Temperley/Kostka-Payne, +# which were derived from Bach chorales and bias toward harmonic minor's +# leading tone. Pop/rock uses natural minor: the b7 is the diatonic +# seventh and rings out constantly (e.g. open D in "Come As You Are", +# which is in E minor and uses D as the b7). Values rescaled so that the +# tonic weight is ≈5 to match the prior code's magnitude. +_MAJOR_PROFILE = ( + 5.47, + 0.14, + 2.55, + 0.14, + 3.15, + 2.16, + 0.37, + 4.92, + 0.21, + 1.84, + 0.18, + 1.86, +) +_MINOR_PROFILE = ( + 5.06, + 0.14, + 2.42, + 2.42, + 0.35, + 1.96, + 0.35, + 4.16, + 2.53, + 0.28, + 2.67, + 0.62, +) +_PITCHES = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B") + +# When the best-major and best-minor scores are this close, we prefer +# minor. Pop/rock has a strong minor-mode prior; the algorithm often +# walks toward the relative major because of an ostinato bass note +# (e.g. "Come As You Are" hammers the open D string in an E minor song), +# and minor is the better default when the call is genuinely ambiguous. +_MINOR_TIE_BREAK_FRAC = 0.05 + + +def _correlate(profile: tuple[float, ...], chroma: list[float], shift: int) -> float: + n = len(profile) + rotated = [chroma[(i + shift) % n] for i in range(n)] + mean_p = sum(profile) / n + mean_c = sum(rotated) / n + num = sum((profile[i] - mean_p) * (rotated[i] - mean_c) for i in range(n)) + denom_p = sum((profile[i] - mean_p) ** 2 for i in range(n)) ** 0.5 + denom_c = sum((rotated[i] - mean_c) ** 2 for i in range(n)) ** 0.5 + if denom_p == 0 or denom_c == 0: + return 0.0 + return num / (denom_p * denom_c) + + +def _detect_key(chroma_mean: list[float]) -> tuple[str, str, int]: + """Find the best-matching key by combining profile correlation with + root prominence. The Pearson correlation alone is fooled by relative + keys whose diatonic notes happen to overlap with the song's loud + pitches but whose own tonic is weak (e.g. picking A minor for an + E-minor song because E is its 5th and D is its 4th). Weighting by + the candidate root's chroma value forces the algorithm to also + confirm 'is this proposed tonic actually loud in the audio?'. + Logs the chroma vector and top-5 candidates for diagnostics. + + Returns (label, scale_name, confidence_pct). + - label: e.g. "G# maj" + - scale_name: "Major" or "Natural Minor" + - confidence_pct: 0-100, derived from the gap between the winning + candidate and the runner-up, normalized so a clear + win ranks high and a near-tie ranks low.""" + raw: list[tuple[float, float, str, int]] = [] # (weighted, pearson, label, root_idx) + for shift in range(12): + root_strength = chroma_mean[shift] + pearson_maj = _correlate(_MAJOR_PROFILE, chroma_mean, shift) + pearson_min = _correlate(_MINOR_PROFILE, chroma_mean, shift) + # Multiplicative root weighting. Pearson can be negative; when + # it is, a low-chroma root makes things less negative (closer to + # zero), which is actually the desired ordering. + raw.append((pearson_maj * root_strength, pearson_maj, f"{_PITCHES[shift]} maj", shift)) + raw.append((pearson_min * root_strength, pearson_min, f"{_PITCHES[shift]} min", shift)) + raw.sort(key=lambda x: x[0], reverse=True) + + # Diagnostic log: chroma profile + top 5 candidates with both raw + # and weighted scores. Lets us see what the algorithm is "hearing". + chroma_str = ", ".join(f"{_PITCHES[i]}={chroma_mean[i]:.3f}" for i in range(12)) + top5_str = ", ".join( + f"{label}={weighted:+.3f}(p{pearson:+.2f}*r{chroma_mean[idx]:.2f})" + for weighted, pearson, label, idx in raw[:5] + ) + logger.debug("chroma: %s", chroma_str) + logger.debug("key candidates (top 5): %s", top5_str) + + # Pick best major and best minor for the tie-break, both by the + # weighted score. + best_maj = next(c for c in raw if c[2].endswith("maj")) + best_min = next(c for c in raw if c[2].endswith("min")) + + gap = abs(best_maj[0] - best_min[0]) + threshold = max(abs(best_maj[0]), abs(best_min[0])) * _MINOR_TIE_BREAK_FRAC + # Near-tie -> prefer minor (pop/rock prior); clear winner -> use it. + winner = (best_maj if best_maj[0] > best_min[0] else best_min) if gap > threshold else best_min + + # Confidence: gap between the winner and the runner-up that *isn't* + # the relative major/minor of the winner (those will always be near- + # ties with the algorithm's profile-correlation approach, so they + # tell us nothing about real ambiguity). Normalize so a healthy 0.15 + # gap = 100% confident; tiny gap = 0%. + runner_up = next(c for c in raw if c[2] != winner[2]) + confidence_score = winner[0] - runner_up[0] + confidence_pct = max(0, min(100, round(confidence_score / 0.15 * 100))) + + label = winner[2] + scale_name = "Major" if label.endswith("maj") else "Natural Minor" + return label, scale_name, confidence_pct + + +def _measure_loudness(y: object, sr: int) -> tuple[float | None, float | None]: + """Compute integrated loudness (LUFS, BS.1770) and sample peak (dBFS) + of the loaded mono signal. Returns (lufs, peak_db); either may be + None on failure or silence. We use sample peak rather than oversampled + true peak -- the difference is typically <1 dB and not worth the 4x + resample cost for a display field.""" + import numpy as np + + if y is None or getattr(y, "size", 0) == 0: + return None, None + + peak_lin = float(np.abs(y).max()) + peak_db = 20.0 * float(np.log10(peak_lin)) if peak_lin > 1e-9 else None + + lufs: float | None = None + try: + import pyloudnorm as pyln + + meter = pyln.Meter(sr) # BS.1770-4 with default 400ms blocks + lufs_raw = float(meter.integrated_loudness(y)) + # pyloudnorm returns -inf for silence; surface as None instead so + # the frontend can hide the field rather than render "-inf LUFS". + if np.isfinite(lufs_raw): + lufs = lufs_raw + except (ImportError, ValueError) as e: + # ValueError fires if the clip is shorter than the gating window. + logger.warning("LUFS measurement failed: %s", e) + return lufs, peak_db + + +def _load_audio_ffmpeg( + source: Path, sr: int = 22050, duration: float = 180.0 +) -> tuple[object, int] | None: + """Decode `source` to a mono float32 numpy array at `sr` via ffmpeg. + Bypasses librosa's deprecated audioread fallback (which fires a + FutureWarning on .webm/.m4a/.opus inputs because soundfile can't + read those directly). Returns (samples, sr) or None on failure.""" + import numpy as np + + # Defence in depth: even though `source` is constructed by the server + # (never user-typed), confirm it's a real file inside JOBS_DIR before + # handing it to a subprocess. Belt-and-suspenders against a future + # caller change that would let a path slip in from elsewhere. + resolved = source.resolve() + jobs_resolved = JOBS_DIR.resolve() + if not resolved.is_file(): + logger.warning("analyze source is not a file: %s", source) + return None + if not resolved.is_relative_to(jobs_resolved): + logger.warning( + "analyze source escapes JOBS_DIR (%s not under %s)", + resolved, + jobs_resolved, + ) + return None + + cmd = [ + ffmpeg_executable(), + "-nostdin", + "-loglevel", + "error", + "-i", + str(resolved), + "-ac", + "1", # mono + "-ar", + str(sr), # resample + "-f", + "f32le", # raw 32-bit float little-endian + "-t", + str(duration), # cap input duration + "-", # write to stdout + ] + try: + proc = subprocess.run(cmd, capture_output=True, check=True, timeout=TIMEOUT_ANALYZE) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e: + logger.warning("ffmpeg decode failed for %s: %s", source, e) + return None + y = np.frombuffer(proc.stdout, dtype=np.float32) + if y.size == 0: + return None + return y, sr + + +def compute_stem_presence(stems_dir: Path, selected_stems: list[str]) -> dict[str, int]: + """Load each extracted stem WAV, compute mean absolute amplitude, normalize + to 0-100. Only the stems that were selected (and therefore extracted) are + measured; the rest are omitted from the returned dict.""" + import numpy as np + + result: dict[str, int] = {} + rms_values: dict[str, float] = {} + + for name in selected_stems: + wav_path = stems_dir / f"{name}.wav" + if not wav_path.is_file(): + continue + loaded = _load_audio_ffmpeg(wav_path, sr=22050, duration=180.0) + if loaded is None: + continue + y, _ = loaded + rms_values[name] = float(np.sqrt(np.mean(y**2))) + + if not rms_values: + return result + + max_rms = max(rms_values.values()) + if max_rms < 1e-9: + return {name: 0 for name in rms_values} + + for name, rms in rms_values.items(): + result[name] = max(0, min(100, round(rms / max_rms * 100))) + + return result + + +def analyze(job: Job, source: Path) -> tuple[int | None, str | None]: + """Best-effort BPM and key detection. On failure, returns (None, None) + and leaves job fields untouched -- the chips stay as placeholders.""" + logger.info("analyze: entering for job %s, source=%s", job.id, source) + _set(job, status="analyzing", progress=0.0, stage="Analyzing audio...") + try: + import librosa + except ImportError: + logger.warning("librosa not installed -- skipping BPM/key analysis") + return None, None + + try: + # Analyse the first 180 s. Decode via ffmpeg directly into numpy + # to avoid librosa's deprecated audioread fallback for + # .webm/.m4a/.opus inputs. + loaded = _load_audio_ffmpeg(source, sr=22050, duration=180.0) + if loaded is None: + return None, None + y, sr = loaded + + # Harmonic / percussive separation. Beat tracking sees a cleaner + # onset envelope on the percussive component; chroma sees a + # cleaner pitch profile on the harmonic component (no cymbal + # smear, no kick fundamentals leaking in). + y_harmonic, y_percussive = librosa.effects.hpss(y) + + tempo_arr, beat_frames = librosa.beat.beat_track(y=y_percussive, sr=sr) + try: + tempo = float(tempo_arr[0]) # type: ignore[index] + except (TypeError, IndexError): + tempo = float(tempo_arr) + bpm = int(round(tempo)) if tempo > 0 else None + + # chroma_cqt is constant-Q based — better pitch resolution than + # chroma_stft, especially in the bass register where the open + # strings of a guitar live. + chroma = librosa.feature.chroma_cqt(y=y_harmonic, sr=sr) + chroma_mean = chroma.mean(axis=1).tolist() + if any(chroma_mean): + key, scale, key_confidence = _detect_key(chroma_mean) + else: + key, scale, key_confidence = None, None, None + + # LUFS / peak. Computed on the same 22 kHz mono buffer; this + # loses a few dB of accuracy vs full-sample-rate stereo, but + # it's good enough for a UI display and adds ~50 ms to analyze. + lufs, peak_db = _measure_loudness(y, sr) + + dynamic_range: float | None = None + if lufs is not None and peak_db is not None: + dynamic_range = round(peak_db - lufs, 1) + + # Beat interval coefficient of variation → stability 0-100. + # CV = std/mean of inter-beat intervals; CV=0 is perfectly metronomic. + tempo_stability: int | None = None + import numpy as np + + beat_times = librosa.frames_to_time(beat_frames, sr=sr) + if len(beat_times) > 2: + intervals = np.diff(beat_times) + mean_iv = float(intervals.mean()) + if mean_iv > 0: + cv = float(intervals.std() / mean_iv) + tempo_stability = max(0, min(100, round((1 - min(cv, 1)) * 100))) + + _set( + job, + bpm=bpm, + key=key, + scale=scale, + key_confidence=key_confidence, + lufs=lufs, + peak_db=peak_db, + dynamic_range=dynamic_range, + tempo_stability=tempo_stability, + progress=1.0, + stage="Analysis complete", + ) + return bpm, key + except Exception as e: + logger.exception("analyze failed for job %s", job.id) + _set(job, stage=f"Analysis skipped ({e})") + return None, None diff --git a/app/pipeline/collect.py b/app/pipeline/collect.py new file mode 100644 index 0000000..6f99597 --- /dev/null +++ b/app/pipeline/collect.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +import json +import logging +import shutil +import subprocess +import time +from pathlib import Path + +import numpy as np +import soundfile as sf + +from app.core.config import ( + DEMUCS_MODEL, + JOB_TTL_SECONDS, + STEM_NAMES, + TIMEOUT_FFMPEG, + ffmpeg_executable, +) +from app.core.models import Job +from app.core.registry import all_jobs as registry_all +from app.core.registry import persist as registry_persist +from app.core.registry import remove as registry_remove +from app.core.registry import set_proc + +logger = logging.getLogger("stemdeck.collect") + + +def _rmtree(path: Path) -> None: + try: + shutil.rmtree(path) + except FileNotFoundError: + pass + except Exception: + logger.warning("failed to remove %s", path, exc_info=True) + + +def _run_ffmpeg(job: Job, cmd: list[str]) -> bool: + """Run an ffmpeg command, registering the subprocess with the job + registry so POST /api/jobs/{id}/cancel can terminate it. Returns + True on success, False on failure or external termination. + + Without registering the proc, an in-flight ffmpeg amix would block + cancellation for up to its 300s timeout -- the cancel flag is set + but the runner can't see it until subprocess.run returns. With + set_proc, the cancel API can call proc.terminate() directly and + communicate() returns within ~1s with a non-zero returncode.""" + proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + set_proc(job.id, proc) + try: + try: + _, stderr = proc.communicate(timeout=TIMEOUT_FFMPEG) + except subprocess.TimeoutExpired: + proc.kill() + proc.communicate() + logger.warning("ffmpeg timed out for job %s", job.id) + return False + if proc.returncode != 0: + tail = (stderr or b"").decode(errors="replace").splitlines()[-3:] + logger.warning( + "ffmpeg exit %s for job %s: %s", + proc.returncode, + job.id, + " | ".join(tail) or "(no stderr)", + ) + return False + return True + finally: + set_proc(job.id, None) + + +_TERMINAL = frozenset(("done", "error", "cancelled")) + + +def collect(job: Job, stems_root: Path, job_dir: Path) -> list[str]: + """Move Demucs-emitted stems into the job's stems/ dir and clean up + the demucs intermediate dir. Does NOT delete the source download -- + cleanup_source() is called by the runner after any post-processing + that needs to re-encode the source (e.g. building original.wav).""" + target_dir = job_dir / "stems" + target_dir.mkdir(exist_ok=True) + found: list[str] = [] + for name in STEM_NAMES: + src = stems_root / f"{name}.wav" + if src.exists(): + shutil.move(str(src), target_dir / f"{name}.wav") + found.append(name) + _rmtree(job_dir / DEMUCS_MODEL) + if not found: + raise RuntimeError("no stems produced by demucs") + return found + + +def cleanup_source(job_dir: Path) -> None: + """Delete the source audio file. Called after collect AND after any + post-processing that re-encodes the source (make_original_track). + The source is 100-300 MB, so getting rid of it is the bulk of disk + reclaim per job; only the stems remain.""" + for f in job_dir.glob("source.*"): + f.unlink(missing_ok=True) + + +def make_original_track(job: Job, job_dir: Path, stems_dir: Path) -> Path | None: + """Build the "Original" backing track at stems/original.wav as the + sum of the stems the user did NOT select. This way the studio can + play (original + each selected stem) and reconstruct the full song + without doubling the selected stems -- which is what would happen + if "original" were the raw source download (drum hits in original + + isolated drums.wav = drums at 2x amplitude). + + Skipped when the user kept all 6 stems (no complement to mix) or + when none of the unselected stem WAVs are on disk.""" + unselected = [s for s in STEM_NAMES if s not in job.selected_stems] + inputs = [stems_dir / f"{name}.wav" for name in unselected] + inputs = [p for p in inputs if p.exists()] + if not inputs: + return None + out = stems_dir / "original.wav" + cmd: list[str] = [ + ffmpeg_executable(), + "-y", + "-nostdin", + "-loglevel", + "error", + ] + for p in inputs: + cmd += ["-i", str(p)] + if len(inputs) == 1: + # Single complement stem -- copy as-is so we still produce a + # canonical mix.wav-shaped output without invoking amix on a + # 1-input graph (which is a no-op anyway). + cmd += ["-c:a", "pcm_s16le", str(out)] + else: + filter_inputs = "".join(f"[{i}:a]" for i in range(len(inputs))) + cmd += [ + "-filter_complex", + f"{filter_inputs}amix=inputs={len(inputs)}:normalize=0", + "-c:a", + "pcm_s16le", + str(out), + ] + return out if _run_ffmpeg(job, cmd) else None + + +def make_selected_mix(job: Job, stems_dir: Path, found: list[str]) -> Path | None: + """If the user picked a strict subset of stems at submit time, + sum those stems with ffmpeg amix into mix.wav. Returns the output + path on success, or None when there's nothing to mix. + + Returns the existing single stem path (no ffmpeg) if exactly one + stem was selected -- copying it to mix.wav would be 30 MB of + duplicate data. The caller uses the returned path's name for the + download URL, so a single-stem selection points the Download Mix + button directly at the existing stem file. + + amix normalize=0 keeps stem amplitudes as-is. Demucs separations + sum back to (close to) the original signal, so a 2-stem subset + fits comfortably below 0 dBFS without normalization headroom.""" + selected = [s for s in job.selected_stems if s in found] + if not selected: + return None + if len(selected) == 1: + return stems_dir / f"{selected[0]}.wav" + inputs = [stems_dir / f"{name}.wav" for name in selected] + out = stems_dir / "mix.wav" + cmd: list[str] = [ + ffmpeg_executable(), + "-y", + "-nostdin", + "-loglevel", + "error", + ] + for p in inputs: + cmd += ["-i", str(p)] + filter_inputs = "".join(f"[{i}:a]" for i in range(len(inputs))) + cmd += [ + "-filter_complex", + f"{filter_inputs}amix=inputs={len(inputs)}:normalize=0", + "-c:a", + "pcm_s16le", + str(out), + ] + return out if _run_ffmpeg(job, cmd) else None + + +_PEAK_POINTS = 1500 # matches OVERVIEW_WAVE_POINTS in player.js + + +def compute_stem_peaks(stems_dir: Path, stem_names: list[str]) -> None: + """Compute and cache [min, max] waveform peaks for each stem. + Failure is non-fatal — missing peaks.json degrades to client-side decode.""" + peaks: dict[str, list[list[float]]] = {} + for name in stem_names: + path = stems_dir / f"{name}.wav" + if not path.is_file(): + continue + try: + data, _ = sf.read(path, dtype="float32", always_2d=True) + ch = data[:, 0] + n = len(ch) + if n == 0: + continue + chunk = max(1, n // _PEAK_POINTS) + result: list[list[float]] = [] + for i in range(0, n, chunk): + block = ch[i : i + chunk] + result.append([float(np.min(block)), float(np.max(block))]) + peaks[name] = result[:_PEAK_POINTS] + except Exception: + logger.warning("could not compute peaks for %s/%s", stems_dir.name, name, exc_info=True) + + if not peaks: + return + + try: + tmp = stems_dir / "peaks.json.tmp" + tmp.write_text(json.dumps(peaks), encoding="utf-8") + tmp.replace(stems_dir / "peaks.json") + except Exception: + logger.warning("could not write peaks.json for %s", stems_dir.name, exc_info=True) + + +def sweep_old_jobs(jobs_dir: Path) -> None: + """Delete job directories older than JOB_TTL_SECONDS and remove them from + the in-memory registry. Called hourly from the background sweep loop + started at app startup. + + Prefers Job.created_at over directory mtime (which can be touched by + unrelated filesystem events), and never deletes the directory of an + active (non-terminal) registered job even if its timestamp looks old. + Falls back to mtime for orphan directories left over from a previous + server run, since the registry is in-memory only.""" + cutoff = time.time() - JOB_TTL_SECONDS + if not jobs_dir.is_dir(): + return + jobs = registry_all() + removed = False + for d in jobs_dir.iterdir(): + if not d.is_dir(): + continue + job = jobs.get(d.name) + if job is not None: + if job.status not in _TERMINAL: + continue # never delete an active job's working dir + if job.created_at >= cutoff: + continue + elif d.stat().st_mtime >= cutoff: + continue + _rmtree(d) + registry_remove(d.name) + removed = True + if removed: + registry_persist(jobs_dir) diff --git a/app/pipeline/download.py b/app/pipeline/download.py new file mode 100644 index 0000000..6562b28 --- /dev/null +++ b/app/pipeline/download.py @@ -0,0 +1,316 @@ +from __future__ import annotations + +import logging +import re +import time +import urllib.parse +from pathlib import Path + +from yt_dlp import YoutubeDL + +from app.core.config import FFMPEG_DIR +from app.core.models import Job, JobCancelled, _set +from app.core.settings import get_max_duration_sec, get_video_max_height + +logger = logging.getLogger("stemdeck.download") + +_MAX_RETRIES = 3 +_RETRY_BACKOFF = (2, 4, 8) # seconds between attempts + +# Errors worth retrying — transient network blips. +_RETRIABLE = ( + "connection reset", + "ssl", + "timed out", + "network is unreachable", + "temporary failure", + "unable to download", + "read timed out", + "remotedisconnected", + "broken pipe", + "connection refused", +) + +# Errors that will never succeed on retry — reject immediately. +_NON_RETRIABLE = ( + "private video", + "video unavailable", + "has been removed", + "http error 404", + "http error 403", + "not available in your country", + "age-restricted", +) + + +def _is_retriable(exc: Exception) -> bool: + msg = str(exc).lower() + if any(s in msg for s in _NON_RETRIABLE): + return False + return any(s in msg for s in _RETRIABLE) + + +_VIDEO_ID_RE = re.compile(r"^[A-Za-z0-9_-]{11}$") +_YOUTUBE_HOSTS = frozenset( + ( + "youtube.com", + "www.youtube.com", + "m.youtube.com", + "music.youtube.com", + "youtu.be", + ) +) +# Note: on.soundcloud.com (the share shortener) is intentionally excluded — it +# redirects to arbitrary targets, which is an SSRF vector once handed to yt-dlp +# (#173). Users must paste the full soundcloud.com URL. +_SOUNDCLOUD_HOSTS = frozenset(("soundcloud.com", "www.soundcloud.com")) +_ALLOWED_HOSTS = _YOUTUBE_HOSTS | _SOUNDCLOUD_HOSTS + +# Restrict yt-dlp to the extractors we actually support. Crucially this excludes +# the "generic" extractor, so even a URL that slips past host validation cannot +# make yt-dlp fetch an arbitrary host/redirect target (#173). +_ALLOWED_EXTRACTORS = ["youtube", "soundcloud"] + + +class InvalidYouTubeURL(ValueError): + """Raised at the API boundary for URLs we won't hand to yt-dlp.""" + + +def validate_youtube_url(url: str) -> str: + """Reject anything that isn't an http(s) URL on a known supported host. + YouTube URLs are normalized to single-video form; SoundCloud URLs are + passed through as-is. Gives callers a clean 422 instead of a yt-dlp + extractor stack trace.""" + if not isinstance(url, str) or not url.strip(): + raise InvalidYouTubeURL("URL is required") + url = url.strip() + try: + parsed = urllib.parse.urlparse(url) + except Exception as e: + raise InvalidYouTubeURL(f"could not parse URL: {e}") from e + if parsed.scheme not in ("http", "https"): + raise InvalidYouTubeURL("URL must use http or https") + host = (parsed.hostname or "").lower() + if host not in _ALLOWED_HOSTS: + raise InvalidYouTubeURL(f"unsupported host: {host or '(empty)'}") + + if host in _SOUNDCLOUD_HOSTS: + return url + + normalized = normalize_youtube_url(url) + # normalize_youtube_url returns the original on playlist-only URLs with + # no derivable seed video. We always expect the canonical watch?v=... form. + if not normalized.startswith("https://www.youtube.com/watch?v="): + raise InvalidYouTubeURL("could not extract a video ID from URL") + return normalized + + +def normalize_youtube_url(url: str) -> str: + """Coerce a YouTube URL to a single-video form so yt-dlp doesn't end up in + the playlist extractor. Pass non-YouTube URLs through unchanged. + + Cases handled: + * `watch?v=X&list=...` -> `watch?v=X` (drop the playlist context) + * `?list=RD&start_radio=1` -> `watch?v=` (Radio + playlists embed the seed in the list ID; YouTube refuses to view the + playlist directly with "This playlist type is unviewable.") + * `youtu.be/` -> `watch?v=` + * `youtube.com/shorts/` -> `watch?v=` + Everything else (PL/OL/algorithmic playlists with no derivable seed) is + left alone -- yt-dlp will surface its own error. + """ + try: + parsed = urllib.parse.urlparse(url) + except Exception: + return url + host = (parsed.hostname or "").lower() + for prefix in ("www.", "m.", "music."): + if host.startswith(prefix): + host = host[len(prefix) :] + break + if host not in ("youtube.com", "youtu.be"): + return url + + qs = urllib.parse.parse_qs(parsed.query) + if (vid := (qs.get("v") or [None])[0]) and _VIDEO_ID_RE.match(vid): + return f"https://www.youtube.com/watch?v={vid}" + + if ( + (lst := (qs.get("list") or [None])[0]) + and lst.startswith("RD") + and _VIDEO_ID_RE.match(lst[2:13]) + ): + return f"https://www.youtube.com/watch?v={lst[2:13]}" + + if host == "youtu.be": + vid = parsed.path.lstrip("/") + if _VIDEO_ID_RE.match(vid): + return f"https://www.youtube.com/watch?v={vid}" + + if host == "youtube.com" and parsed.path.startswith("/shorts/"): + vid = parsed.path[len("/shorts/") :].lstrip("/").split("/")[0] + if _VIDEO_ID_RE.match(vid): + return f"https://www.youtube.com/watch?v={vid}" + + return url + + +def _download_video_track(job: Job, url: str, job_dir: Path) -> None: + """Best-effort: download a video-only H.264/MP4 stream to video.mp4 for the + MP4 export (issue #219). The audio source is downloaded separately as + usual; this is a second, additive fetch so the audio pipeline is untouched. + + Video-only MP4 needs no ffmpeg merge, so this can't break an audio-only job: + any failure (no progressive MP4 video, network error, unsupported codec) is + logged and swallowed, leaving has_video False. A cancel mid-download raises + JobCancelled, which the runner treats like any other cancellation. + + Capped at VIDEO_MAX_HEIGHT to keep downloads reasonable -- a full song at + 1080p is large, and the MP4 export doesn't need it.""" + + def vhook(d: dict) -> None: + if job.cancel_requested: + raise JobCancelled() + if d.get("status") == "downloading": + total = d.get("total_bytes") or d.get("total_bytes_estimate") + if total: + p = float(d.get("downloaded_bytes", 0)) / float(total) + _set(job, stage=f"Fetching video {int(p * 100)}%") + + # Prefer H.264 (avc1) so the exported MP4 plays everywhere -- YouTube also + # serves AV1/VP9 in mp4 containers, which many players (Safari/iOS, older + # devices) can't decode. Fall back to any <=cap mp4 only if no avc1 exists. + max_height = get_video_max_height() + ydl_opts = { + "format": ( + f"bestvideo[height<={max_height}][vcodec^=avc1]" + f"/bestvideo[height<={max_height}][ext=mp4]" + ), + "outtmpl": str(job_dir / "video.%(ext)s"), + "quiet": True, + "noprogress": True, + "noplaylist": True, + "allowed_extractors": _ALLOWED_EXTRACTORS, + "progress_hooks": [vhook], + } + # Point yt-dlp at the bundled ffmpeg in case a DASH stream needs remuxing; + # in portable builds ffmpeg is not on PATH. + if FFMPEG_DIR.is_dir(): + ydl_opts["ffmpeg_location"] = str(FFMPEG_DIR) + + _set(job, stage="Fetching video...") + try: + with YoutubeDL(ydl_opts) as ydl: + ydl.extract_info(url, download=True) + except JobCancelled: + raise + except Exception as exc: + if job.cancel_requested: + raise JobCancelled() from exc + logger.warning("[%s] video track unavailable (audio-only): %s", job.id, exc) + + video = job_dir / "video.mp4" + if video.is_file() and video.stat().st_size > 0: + job.has_video = True + else: + # Drop any partial/non-mp4 leftover so the export endpoint sees nothing. + for f in job_dir.glob("video.*"): + f.unlink(missing_ok=True) + + +def download(job: Job, url: str, job_dir: Path) -> Path: + url = normalize_youtube_url(url) + logger.info("[%s] download starting: %s", job.id, url) + _set(job, status="downloading", progress=0.0, stage="Processing...") + + # Fetch metadata first (no download) so we can reject videos that are + # too long before wasting bandwidth and disk. + with YoutubeDL( + {"quiet": True, "noplaylist": True, "allowed_extractors": _ALLOWED_EXTRACTORS} + ) as ydl: + meta = ydl.extract_info(url, download=False) or {} + duration = meta.get("duration") or 0 + max_duration = get_max_duration_sec() + if duration > max_duration: + mins = max_duration // 60 + raise RuntimeError(f"Video is {int(duration // 60)} min -- limit is {mins} min") + + def hook(d: dict) -> None: + # yt-dlp calls this on each chunk; raising here aborts the download. + # The runner unwraps yt-dlp's DownloadError and routes to JobCancelled. + if job.cancel_requested: + raise JobCancelled() + if d.get("status") == "downloading": + total = d.get("total_bytes") or d.get("total_bytes_estimate") + if total: + p = float(d.get("downloaded_bytes", 0)) / float(total) + _set(job, progress=p, stage=f"Downloading {int(p * 100)}%") + elif d.get("status") == "finished": + _set(job, progress=1.0, stage="Download complete") + + # YouTube jobs additionally fetch the real video stream (below) for the + # MP4 export (issue #219). SoundCloud is audio-only and excluded. + is_youtube = url.startswith("https://www.youtube.com/") + + # No postprocessors -- Demucs reads the raw audio container (webm/m4a/opus/...) + # directly via torchaudio + ffmpeg. Skipping the WAV transcode saves the slowest + # part of the download pipeline and a lot of disk. + ydl_opts = { + "format": "bestaudio/best", + "outtmpl": str(job_dir / "source.%(ext)s"), + "quiet": True, + "noprogress": True, + "noplaylist": True, + "allowed_extractors": _ALLOWED_EXTRACTORS, + "progress_hooks": [hook], + } + info: dict = {} + for attempt in range(_MAX_RETRIES + 1): + try: + with YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(url, download=True) or {} + break + except Exception as exc: + if job.cancel_requested: + raise JobCancelled() from exc + if attempt < _MAX_RETRIES and _is_retriable(exc): + wait = _RETRY_BACKOFF[attempt] + logger.warning( + "[%s] download attempt %d/%d failed (%s), retrying in %ds", + job.id, + attempt + 1, + _MAX_RETRIES, + exc, + wait, + ) + _set(job, stage=f"Network error — retrying ({attempt + 1}/{_MAX_RETRIES})...") + time.sleep(wait) + else: + raise + + _set( + job, + title=info.get("title") or meta.get("title"), + duration_sec=info.get("duration") or duration, + thumbnail=info.get("thumbnail") or meta.get("thumbnail"), + ) + + raw_tags = [ + t.strip().lower() + for t in (info.get("tags") or []) + (info.get("categories") or []) + if isinstance(t, str) and t.strip() + ] + seen: set[str] = set() + deduped = [t for t in raw_tags if not (t in seen or seen.add(t))] # type: ignore[func-returns-value] + _set(job, tags=deduped[:8] or None) + + # Best-effort: fetch the real video stream for the MP4 export. + # Non-fatal -- on any failure the job proceeds audio-only. + if is_youtube: + _download_video_track(job, url, job_dir) + + candidates = sorted(job_dir.glob("source.*")) + if not candidates: + raise RuntimeError("yt-dlp finished but no source file was produced") + logger.info("[%s] download complete: %s", job.id, candidates[0].name) + return candidates[0] diff --git a/app/pipeline/runner.py b/app/pipeline/runner.py new file mode 100644 index 0000000..032eee7 --- /dev/null +++ b/app/pipeline/runner.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import shutil +import subprocess +from pathlib import Path + +from app.core.config import TIMEOUT_FFMPEG +from app.core.models import Job, JobCancelled, _set +from app.core.registry import persist as persist_registry +from app.pipeline.analyze import analyze, compute_stem_presence +from app.pipeline.collect import ( + cleanup_source, + collect, + compute_stem_peaks, + make_original_track, + make_selected_mix, +) +from app.pipeline.download import download +from app.pipeline.separate import separate + +logger = logging.getLogger("stemdeck.pipeline") + + +def _rmtree(path: Path) -> None: + try: + shutil.rmtree(path) + except FileNotFoundError: + pass + except Exception: + logger.warning("failed to remove %s", path, exc_info=True) + + +# Only one heavy job runs at a time -- Demucs is GPU/CPU-hungry. +_pipeline_lock = asyncio.Semaphore(1) + + +def _check_cancel(job: Job) -> None: + if job.cancel_requested: + raise JobCancelled() + + +def _extract_video_track(job: Job, source: Path, job_dir: Path) -> None: + """For an .mp4 upload, preserve a silent video-only track at + video.mp4 so the studio can later mux it with a custom stem mix + into an MP4 (issue #219). Stream-copies the video (no + re-encode) -- fast and lossless. + + Best-effort: an .mp4 with no video stream (audio-only container) + fails harmlessly and leaves has_video false.""" + from app.core.config import ffmpeg_executable + + dest = job_dir / "video.mp4" + cmd = [ + ffmpeg_executable(), + "-nostdin", + "-loglevel", + "error", + "-i", + str(source), + "-an", # drop audio -- the mix is added at export time + "-c:v", + "copy", + "-movflags", + "+faststart", + "-y", + str(dest), + ] + result = subprocess.run(cmd, capture_output=True, timeout=TIMEOUT_FFMPEG) + if result.returncode != 0 or not dest.is_file() or dest.stat().st_size == 0: + dest.unlink(missing_ok=True) + logger.info("no video track preserved for job %s (source has no video stream?)", job.id) + return + job.has_video = True + + +def _prepare_local_source(job: Job, source: Path, job_dir: Path) -> Path: + """Transcode any local upload to 16-bit 44.1 kHz stereo WAV before + handing it to Demucs. Normalises MP3 and non-standard WAV formats + (24-bit, 32-bit float, high sample rate, multi-channel) that Demucs + would otherwise process silently and output as silence. + + For .mp4 uploads, first preserves a silent video.mp4 for later + MP4 export. Deletes the original source file after a + successful transcode.""" + from app.core.config import ffmpeg_executable + + dest = job_dir / "source.wav" + if source.resolve() == dest.resolve(): + return source + + _set(job, stage="Preparing audio...") + if source.suffix.lower() == ".mp4": + _extract_video_track(job, source, job_dir) + cmd = [ + ffmpeg_executable(), + "-nostdin", + "-loglevel", + "error", + "-i", + str(source), + "-ar", + "44100", + "-ac", + "2", + "-sample_fmt", + "s16", + "-y", + str(dest), + ] + result = subprocess.run(cmd, capture_output=True, timeout=TIMEOUT_FFMPEG) + if result.returncode != 0: + raise RuntimeError( + "ffmpeg transcode failed: " + result.stderr.decode("utf-8", errors="replace").strip() + ) + source.unlink(missing_ok=True) + return dest + + +def _run_common(job: Job, source: Path, job_dir: Path) -> None: + """Analyze → separate → collect → mix. Shared by both YouTube and local + upload pipelines after their respective source acquisition steps.""" + _check_cancel(job) + analyze(job, source) + _check_cancel(job) + stems_root = separate(job, source, job_dir) + found = collect(job, stems_root, job_dir) + stems_dir = job_dir / "stems" + job.stem_presence = compute_stem_presence(stems_dir, found) + # Source (100-300 MB or the local upload) is no longer needed after + # collect; delete it before the ffmpeg amix steps in case scratch space + # is tight. + cleanup_source(job_dir) + job.stems = [{"name": name, "url": f"/api/jobs/{job.id}/stems/{name}.wav"} for name in found] + _check_cancel(job) + _set(job, stage="Mixing tracks...") + original_path = make_original_track(job, job_dir, stems_dir) + if original_path is not None: + job.stems.insert( + 0, + { + "name": "original", + "url": f"/api/jobs/{job.id}/stems/original.wav", + }, + ) + _check_cancel(job) + mix_path = make_selected_mix(job, stems_dir, found) + if mix_path is not None: + job.mix_url = f"/api/jobs/{job.id}/stems/{mix_path.name}" + _check_cancel(job) + + all_stem_names = [s["name"] for s in job.stems] + if mix_path is not None and mix_path.stem not in all_stem_names: + all_stem_names.append(mix_path.stem) + compute_stem_peaks(stems_dir, all_stem_names) + + +def _run_blocking(job: Job, url: str, job_dir: Path) -> None: + _check_cancel(job) + source = download(job, url, job_dir) + _run_common(job, source, job_dir) + + +def _run_local_blocking(job: Job, source_path: Path, job_dir: Path) -> None: + _check_cancel(job) + source = _prepare_local_source(job, source_path, job_dir) + _run_common(job, source, job_dir) + + +def _write_metadata(job: Job, job_dir: Path) -> None: + meta = { + "title": job.title, + "thumbnail": job.thumbnail, + "duration_sec": job.duration_sec, + "bpm": job.bpm, + "key": job.key, + "scale": job.scale, + "key_confidence": job.key_confidence, + "lufs": job.lufs, + "peak_db": job.peak_db, + "dynamic_range": job.dynamic_range, + "tempo_stability": job.tempo_stability, + "stem_presence": job.stem_presence, + "tags": job.tags, + "has_video": job.has_video, + } + try: + (job_dir / "metadata.json").write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8") + except OSError: + logger.warning("could not write metadata.json for job %s", job.id, exc_info=True) + + +async def _run_async( + job: Job, + job_dir: Path, + jobs_dir: Path, + blocking_fn, + *fn_args: object, + error_msg: str = "Audio processing failed. Please try again.", +) -> None: + """Common async wrapper: acquires the pipeline lock, runs blocking_fn in a + thread, then handles success / cancel / error outcomes uniformly.""" + try: + async with _pipeline_lock: + await asyncio.to_thread(blocking_fn, job, *fn_args, job_dir) + except Exception as e: + if not isinstance(e, JobCancelled) and not job.cancel_requested: + logger.exception("pipeline failed for job %s: %s", job.id, e) + _set(job, status="error", stage="Error: Processing failed", error=error_msg) + persist_registry(jobs_dir) + _rmtree(job_dir) + return + logger.info( + "pipeline cancelled%s for job %s", + " (wrapped)" if not isinstance(e, JobCancelled) else "", + job.id, + ) + _set(job, status="cancelled", stage="Cancelled") + persist_registry(jobs_dir) + _rmtree(job_dir) + return + _set(job, status="done", progress=1.0, stage="Done") + _write_metadata(job, job_dir) + persist_registry(jobs_dir) + + +async def run_pipeline(job: Job, url: str, jobs_dir: Path) -> None: + job_dir = jobs_dir / job.id + try: + job_dir.mkdir(parents=True, exist_ok=True) + except Exception as e: + logger.exception("pipeline failed for job %s: %s", job.id, e) + _set( + job, + status="error", + stage="Error: Processing failed", + error="Audio processing failed. Please try another video.", + ) + persist_registry(jobs_dir) + return + await _run_async( + job, + job_dir, + jobs_dir, + _run_blocking, + url, + error_msg="Audio processing failed. Please try another video.", + ) + + +async def run_local_pipeline(job: Job, source_path: Path, jobs_dir: Path) -> None: + """Run the stem-separation pipeline for a locally uploaded file. + The job directory and source file are already present on disk (created + by the API handler before this task is scheduled).""" + job_dir = jobs_dir / job.id + await _run_async(job, job_dir, jobs_dir, _run_local_blocking, source_path) diff --git a/app/pipeline/separate.py b/app/pipeline/separate.py new file mode 100644 index 0000000..cf26dae --- /dev/null +++ b/app/pipeline/separate.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import logging +import os +import re +import subprocess +import sys +import threading +import time +from pathlib import Path + +from app.core.config import DEMUCS_DEVICE, DEMUCS_MODEL, TIMEOUT_DEMUCS_STALL +from app.core.models import Job, JobCancelled, _set +from app.core.registry import set_proc + +logger = logging.getLogger("stemdeck.pipeline") + +_PCT_RE = re.compile(r"(\d{1,3})%") +# Terminate demucs if stderr produces no output for this many seconds. +# GPU processing can be silent for minutes; 30 min covers legitimate pauses +# while still catching genuine hangs (GPU deadlock, OOM stall, etc.). + + +def separate(job: Job, source: Path, job_dir: Path) -> Path: + _set(job, status="separating", progress=0.0, stage="Separating stems...") + + cmd = [ + sys.executable, + "-m", + "demucs", + "-n", + DEMUCS_MODEL, + "-d", + DEMUCS_DEVICE, + "-o", + str(job_dir), + str(source), + ] + env = os.environ.copy() + try: + import certifi + + env.setdefault("SSL_CERT_FILE", certifi.where()) + env.setdefault("REQUESTS_CA_BUNDLE", certifi.where()) + except ModuleNotFoundError: + pass + + proc = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + bufsize=0, + env=env, + ) + if proc.stderr is None: + raise RuntimeError("demucs subprocess has no stderr pipe") + set_proc(job.id, proc) + + # tqdm uses \r to redraw -- read char-by-char and split on \r or \n. + # Keep the last few non-progress lines so we can surface them if demucs + # exits non-zero (otherwise the only signal would be a bare exit code). + buf = "" + tail: list[str] = [] + last_output: list[float] = [time.monotonic()] + # Event set by the reader loop when the process exits normally so the + # watchdog can wake up immediately instead of waiting out its 30 s sleep. + _done_evt = threading.Event() + + def _watchdog() -> None: + while not _done_evt.wait(timeout=30): + if proc.poll() is not None: + return + if time.monotonic() - last_output[0] > TIMEOUT_DEMUCS_STALL: + logger.warning( + "demucs stalled for %ss with no output, terminating job %s", + TIMEOUT_DEMUCS_STALL, + job.id, + ) + proc.terminate() + return + + wt = threading.Thread(target=_watchdog, daemon=True) + wt.start() + try: + while True: + ch = proc.stderr.read(1) + if not ch: + break + last_output[0] = time.monotonic() + if ch in ("\r", "\n"): + line = buf.strip() + buf = "" + if not line: + continue + m = _PCT_RE.search(line) + if m: + pct = max(0, min(100, int(m.group(1)))) + _set(job, progress=pct / 100.0, stage=f"Separating {pct}%") + else: + tail.append(line) + if len(tail) > 40: + tail.pop(0) + else: + buf += ch + + proc.wait() + finally: + _done_evt.set() + set_proc(job.id, None) + wt.join(timeout=2) + + # POST /cancel calls proc.terminate() directly, which causes the read loop + # above to hit EOF and proc.wait() to return a nonzero status. Translate + # that into JobCancelled before the generic "demucs failed" path. + if job.cancel_requested: + raise JobCancelled() + if proc.returncode != 0: + detail = "\n".join(tail[-15:]) if tail else "(no stderr captured)" + logger.error("[%s] demucs exited %s; tail:\n%s", job.id, proc.returncode, detail) + last = tail[-1] if tail else f"exit status {proc.returncode}" + raise RuntimeError(f"demucs failed: {last}") + + stems_root = job_dir / DEMUCS_MODEL / source.stem + if not stems_root.is_dir(): + raise RuntimeError(f"demucs output not found at {stems_root}") + return stems_root diff --git a/build/Dockerfile b/build/Dockerfile new file mode 100644 index 0000000..6b837cc --- /dev/null +++ b/build/Dockerfile @@ -0,0 +1,112 @@ +# Two-stage build: +# * builder -- has gcc/build-essential/curl/unzip/pip etc.; produces +# an isolated /opt/venv with all Python deps installed +# and a fetched deno binary. +# * runner -- only the runtime needs: Python (slim), ffmpeg, +# ca-certificates, the venv copied from the builder, +# and our app code. No compilers, no pip cache, no +# download tooling -- shaves ~150-300 MB off the +# single-stage image and reduces attack surface. + +# ─── Stage 1: builder ──────────────────────────────────────────── +FROM python:3.12-slim AS builder + +ENV PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +# Build deps for pip wheels that fall back to source compilation +# (numpy/scipy ship manylinux wheels, but smaller deps occasionally +# need a compiler). Plus curl/unzip to fetch deno. +RUN apt-get update \ + && apt-get upgrade -y \ + && apt-get install -y --no-install-recommends \ + build-essential \ + curl \ + ca-certificates \ + unzip \ + && rm -rf /var/lib/apt/lists/* + +# deno -- yt-dlp uses it as the JS runtime for YouTube format +# extraction. Staged here so the runner doesn't need curl/unzip. +ARG TARGETARCH +RUN case "${TARGETARCH:-$(dpkg --print-architecture)}" in \ + amd64) DENO_ARCH=x86_64-unknown-linux-gnu ;; \ + arm64) DENO_ARCH=aarch64-unknown-linux-gnu ;; \ + *) echo "Unsupported arch: ${TARGETARCH}" && exit 1 ;; \ + esac \ + && curl -fsSL -o /tmp/deno.zip "https://github.com/denoland/deno/releases/latest/download/deno-${DENO_ARCH}.zip" \ + && unzip /tmp/deno.zip -d /usr/local/bin \ + && rm /tmp/deno.zip \ + && /usr/local/bin/deno --version + +# Isolated venv -- the runner stage copies /opt/venv wholesale, +# inheriting all installed deps without dragging in pip caches, +# /var/lib/apt state, or build tools. +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +WORKDIR /app +COPY pyproject.toml ./ +COPY app/ ./app/ +COPY static/ ./static/ +# The version is git-derived (hatch-vcs), but the build context has no .git, so +# pin it explicitly: `docker build --build-arg VERSION=0.7.0-alpha.4 ...` +# (defaults to 0.0.0 for ad-hoc local builds). #169 +ARG VERSION=0.0.0 +RUN SETUPTOOLS_SCM_PRETEND_VERSION="${VERSION#v}" pip install . --timeout 300 + +# ─── Stage 2: runner ───────────────────────────────────────────── +FROM python:3.12-slim AS runner + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PATH="/opt/venv/bin:$PATH" \ + TORCH_HOME=/cache/torch \ + XDG_CACHE_HOME=/cache + +# Runtime-only OS deps. ffmpeg has many shared libs (libav*, libsw*, +# libpostproc), so we install via apt rather than copy-pasting binaries +# and chasing transitive .so dependencies. ca-certificates is needed +# for HTTPS to YouTube. +RUN apt-get update \ + && apt-get upgrade -y \ + && apt-get install -y --no-install-recommends \ + ffmpeg \ + ca-certificates \ + gosu \ + && rm -rf /var/lib/apt/lists/* + +# Deno binary fetched in stage 1 (single self-contained file). +COPY --from=builder /usr/local/bin/deno /usr/local/bin/deno + +# Pre-built Python virtualenv with all our deps. The PATH env above +# means `python`, `uvicorn`, `pip`, etc. resolve into the venv with +# no further configuration. +COPY --from=builder /opt/venv /opt/venv + +# Application code -- copied to /app rather than only relied-on via +# site-packages because app/core/config.py derives STATIC_DIR from +# __file__'s parent chain, and the cwd-rooted /app/app version sits +# earlier on sys.path than the venv install of `app`. Without the +# COPY, STATIC_DIR would resolve into site-packages where static/ +# wasn't included by hatchling's wheel target. +WORKDIR /app +COPY app/ ./app/ +COPY static/ ./static/ + +# Create the app user and own all non-bind-mounted paths. /app/jobs is a +# bind mount at runtime -- the entrypoint script re-chowns it to app:app +# before dropping privileges, handling the case where Docker creates the +# host directory as root on first run. +RUN groupadd --system --gid 1001 app \ + && useradd --system --uid 1001 --gid app --home /app --no-create-home app \ + && mkdir -p /cache /jobs \ + && chown -R app:app /app /cache /jobs + +COPY build/docker-entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 8000 +ENTRYPOINT ["/entrypoint.sh"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/build/docker-compose.yml b/build/docker-compose.yml new file mode 100644 index 0000000..2201d45 --- /dev/null +++ b/build/docker-compose.yml @@ -0,0 +1,26 @@ +# Explicit name so compose doesn't derive it from the parent directory +# (which is now `build/`, giving misleading object names like +# `build_default` for the network). +name: stemdeck + +services: + stemdeck: + # Compose file lives in build/, so the build context is the parent + # (project root) and the Dockerfile sits next to this compose file. + build: + context: .. + dockerfile: build/Dockerfile + image: stemdeck + container_name: stemdeck + ports: + - "8000:8000" + volumes: + # Stems and downloaded audio land in /jobs on the + # host so you can grab them without going through the container. + - ../jobs:/app/jobs + # Demucs model weights (~170 MB) survive container rebuilds. + - stemdeck-cache:/cache + restart: unless-stopped + +volumes: + stemdeck-cache: diff --git a/build/docker-entrypoint.sh b/build/docker-entrypoint.sh new file mode 100644 index 0000000..99e6b92 --- /dev/null +++ b/build/docker-entrypoint.sh @@ -0,0 +1,22 @@ +#!/bin/sh +set -e +# Run as the requested UID/GID so files written to the mounted appdata paths are +# owned consistently. This matches the Unraid/NAS convention (defaults there are +# nobody:users = 99:100). When PUID/PGID are unset, fall back to the image's +# original non-root app user (1001), preserving prior behaviour. +PUID="${PUID:-1001}" +PGID="${PGID:-1001}" + +# Chown the only paths the app writes to before dropping privileges: +# /app/jobs registry.json, downloaded audio, and stems +# /cache torch/Demucs model weights (TORCH_HOME, XDG_CACHE_HOME) +# /app/settings.json best-effort settings persistence (created on demand) +# App code and the venv under /app stay world-readable, so a different UID can +# still import and run them. Re-chowning is also what fixes a bind mount that +# Docker created as root on first run. +chown -R "${PUID}:${PGID}" /app/jobs /cache 2>/dev/null || true +touch /app/settings.json 2>/dev/null && chown "${PUID}:${PGID}" /app/settings.json 2>/dev/null || true + +# Drop to the target user and exec the CMD. gosu accepts a numeric UID:GID even +# when no matching named user exists. +exec gosu "${PUID}:${PGID}" "$@" diff --git a/ca_profile.xml b/ca_profile.xml new file mode 100644 index 0000000..b8ecce8 --- /dev/null +++ b/ca_profile.xml @@ -0,0 +1,12 @@ + + + + StemDeck splits any song into isolated stems (vocals, drums, bass, guitar, + piano, other) and plays them back in a DAW-style multitrack player, for + practice and transcription. This repository maintains the official StemDeck + self-hosted server container. For help, open an issue on GitHub. + + https://raw.githubusercontent.com/stemdeckapp/stemdeck/main/desktop/src-tauri/icons/icon.png + https://github.com/stemdeckapp/stemdeck + https://github.com/stemdeckapp/stemdeck/issues + diff --git a/desktop/package-lock.json b/desktop/package-lock.json new file mode 100644 index 0000000..aa7a082 --- /dev/null +++ b/desktop/package-lock.json @@ -0,0 +1,247 @@ +{ + "name": "stemdeck-desktop", + "version": "0.1.0-alpha.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "stemdeck-desktop", + "version": "0.1.0-alpha.0", + "devDependencies": { + "@tauri-apps/cli": "^2.0.0" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.1.tgz", + "integrity": "sha512-rpEbaJ/HzNb6fwsquwoAbq29/Vt4gADhS423A8fdkwL4edJ0wZmoB8ar7O6JPDL834MUKOCm/rrJ7c9oAaEaYQ==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.1", + "@tauri-apps/cli-darwin-x64": "2.11.1", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.1", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.1", + "@tauri-apps/cli-linux-arm64-musl": "2.11.1", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.1", + "@tauri-apps/cli-linux-x64-gnu": "2.11.1", + "@tauri-apps/cli-linux-x64-musl": "2.11.1", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.1", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.1", + "@tauri-apps/cli-win32-x64-msvc": "2.11.1" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.1.tgz", + "integrity": "sha512-6eEKMBXsQPCuM1EmvrjT2+aBuxWQuFdKdW8pzNuNQtpq45nEEpBlD5gr8pUeAyOU1DQKlkFaEc/MPBxb/Pfjtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.1.tgz", + "integrity": "sha512-LQUO7exfRWjWALNhetph5guWpMeHphRpokOLk0OIbTTExaNwJNFu3I4vb+CCM/4G/QGoZe/5XikZOJdNEFP1ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.1.tgz", + "integrity": "sha512-5i/awiBCRRhOUG8yjn0fMHXIWD5Ez8eEk5LtvOxyQrKuJkRaZDvnbIjZbE183blAwkoA4xN3aO/prJiqscl02Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.1.tgz", + "integrity": "sha512-9LrwDw3S9Fygtw/Q6WDhOP+3svJRGAsejeE+GKrc0eO1ThMVhwi2LL6hw4dlKw93IfS7VY1G19sWGxJ/NcU4nA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.1.tgz", + "integrity": "sha512-mNA5dbbqPqDUdTIwdUYYuhO2GvIe9UnB2r0VU2njxBOS3Opbx4gKNC5yP0Iu4rYmEmqdlwry9VzGZQ3wq9dyFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.1.tgz", + "integrity": "sha512-fZj3Gwq+6fUs305T5WQiD5iSGJw+j/4w/HGmk4sHDAcy+rp9zU5eaxB7nOyz5/I/nkNAuKPqfp6uIbiUBXkBCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.1.tgz", + "integrity": "sha512-XFxGxOvHM7jjeD6ozCKdGfhzJ7lERYDGZl1/Kb4fsvchaJsfLJ981TlyTG8Qy/gFq+f5GitH3bfrX9JAkjPEyw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.1.tgz", + "integrity": "sha512-d5C2/Zm+68v7R9wTuTCjRQEVrWjcdMkJBZ1+rXse+QdMMlTB9+u9PDNDLw9PQflWxYLaYZ7tjxxL9Nb9II6PbA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.1.tgz", + "integrity": "sha512-YdeVWFAR1pTXzUU6NLstPq4G6OLxuDrXCXEBdmBH+5EZIDXUx0D2kJlz3+YjpazkKvAzYpgziTsyRagls0OfRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.1.tgz", + "integrity": "sha512-VBGkuH0eB9K9LLSMv361Gzr5Ou72sCS4+ztpmkWEQ+wd/amhcYOsf3X6qn1RJZDzIhiOYHJEOysZUC3baD01rA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.1.tgz", + "integrity": "sha512-b3ORhIAKgp9ZYY+zBt7b7r0kLU2kjvyGF0+MS2SBym3emsweGPybEqocJcmtMuxyBhkOKHP4CiuEJEDuAlTx6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + } + } +} diff --git a/desktop/package.json b/desktop/package.json new file mode 100644 index 0000000..3154a7d --- /dev/null +++ b/desktop/package.json @@ -0,0 +1,14 @@ +{ + "name": "stemdeck-desktop", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "tauri": "tauri", + "dev": "tauri dev", + "build": "tauri build" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.0.0" + } +} diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock new file mode 100644 index 0000000..0bfa8fa --- /dev/null +++ b/desktop/src-tauri/Cargo.lock @@ -0,0 +1,5294 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.11.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.2.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash 0.2.0", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.2+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.11.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.6", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.11+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.39.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "721da970c312655cde9b4ffe0547f20a8494866a4af5ff51f18b7c633d0c870b" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.11.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05839ce67618e14a09b286535c0d9c94e85ef25469b0e13cb4f844e5593eb19" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stemdeck" +version = "0.0.0" +dependencies = [ + "flate2", + "libc", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2", + "tar", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-store", + "tempfile", + "zip", + "zstd", +] + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4" +dependencies = [ + "bitflags 2.11.1", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d059f2527558d9dba6f186dec4772610e1aecfd3f94002397613e7e648752b66" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.3", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be9aa8c59a894f76c29a002501c589de5eb4987a5913d62a6e0a47f320901988" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e4e8230d565106aa19dfbaa01a7ed01abf78047fe0577a83377224bd1bf20e" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.117", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc8de2cddbbc33dbdf4c84f170121886595efdbcc9cb4b3d76342b79d082cedc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8d5f58bfd0cdcfdbc0a68dc08b354eea2afc551b421de91b07b69e0dd769d57" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-store" +version = "2.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c72dda16786eb4a3f903e43a17b64d8d78dc0f00fe2aa4b757c28f617a8630b" +dependencies = [ + "dunce", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e42bbcb76237351fbaa02f08d808c537dc12eb5a6eabbf3e517b50056334d95" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cadb13dad0c681e1e0a2c49ae488f0e2906ded3d57e7a0017f4aaf46e387117" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55f61d2bf7188fbcf2b0ed095b67a6bc498f713c939314bb19eb700118a573b7" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf 0.11.3", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.2+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.2", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.2", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.2", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28f0d049ccfaa566e14e9663d304d8577427b368cb4710a20528690287a738b" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +dependencies = [ + "phf 0.13.1", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.14.0", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml new file mode 100644 index 0000000..c18b23e --- /dev/null +++ b/desktop/src-tauri/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "stemdeck" +# Placeholder — stamped from the git tag at build time (Woodpecker / make-app.sh). #169 +version = "0.0.0" +description = "StemDeck desktop launcher" +authors = ["StemDeck contributors"] +edition = "2021" +rust-version = "1.88" + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +flate2 = "1" +libc = "0.2" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "blocking"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +tar = "0.4" +tauri = { version = "2", features = [] } +tauri-plugin-dialog = "2" +tauri-plugin-store = "2" +zip = { version = "2", default-features = false, features = ["deflate"] } +zstd = "0.13" + +[dev-dependencies] +tempfile = "3" diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/desktop/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json new file mode 100644 index 0000000..29dff59 --- /dev/null +++ b/desktop/src-tauri/capabilities/default.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Default StemDeck desktop window permissions", + "windows": ["main"], + "permissions": ["core:default", "dialog:allow-save"] +} diff --git a/desktop/src-tauri/icons/icon.icns b/desktop/src-tauri/icons/icon.icns new file mode 100644 index 0000000..6653ee4 Binary files /dev/null and b/desktop/src-tauri/icons/icon.icns differ diff --git a/desktop/src-tauri/icons/icon.ico b/desktop/src-tauri/icons/icon.ico new file mode 100644 index 0000000..3b7e21a Binary files /dev/null and b/desktop/src-tauri/icons/icon.ico differ diff --git a/desktop/src-tauri/icons/icon.png b/desktop/src-tauri/icons/icon.png new file mode 100644 index 0000000..9307d05 Binary files /dev/null and b/desktop/src-tauri/icons/icon.png differ diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs new file mode 100644 index 0000000..bcd2fd3 --- /dev/null +++ b/desktop/src-tauri/src/main.rs @@ -0,0 +1,2754 @@ +use flate2::read::GzDecoder; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + env, fs, + io::{Read, Write}, + net::{TcpListener, TcpStream}, + path::{Path, PathBuf}, + process::{Child, Command, Output, Stdio}, + sync::Mutex, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; +use tar::Archive; +use tauri::{Emitter, Manager}; +use tauri_plugin_store::StoreExt; +#[cfg(windows)] +use zip::ZipArchive; + +const SETUP_VERSION: u64 = 1; +// Windows FFmpeg comes from BtbN's GitHub build (served via GitHub's CDN, far +// faster worldwide than the old gyan.dev single mirror -- #248). Unlike gyan.dev, +// which published a per-file `{url}.sha256` companion, BtbN publishes ONE combined +// `checksums.sha256` listing every asset as ` ` lines; we fetch it +// and pick the line for our archive's basename. The `latest` tag is rolling (the +// `n8.1-latest` asset is rebuilt in place), so the checksum is fetched fresh each +// run and verified -- the same trust model as the old gyan flow. A compile-time pin +// is not possible without self-hosting the archive. +const DEFAULT_WINDOWS_FFMPEG_URL: &str = + "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n8.1-latest-win64-gpl-8.1.zip"; +#[cfg(windows)] +const DEFAULT_WINDOWS_FFMPEG_CHECKSUMS_URL: &str = + "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/checksums.sha256"; +// macOS FFmpeg is pinned to a specific evermeet build and verified by SHA256 +// before it is extracted or executed (#172). evermeet publishes no .sha256 +// companion (only a GPG signature and a size), so unlike the Windows BtbN +// path we cannot fetch the hash at runtime -- instead we pin the hash of a +// specific versioned zip, captured at build time from evermeet's TLS endpoint +// (the download size matched evermeet's signed release info). Bump the version +// and BOTH hashes together when updating FFmpeg. The rolling getrelease/latest +// URL is intentionally avoided so the pinned hash stays valid. +const DEFAULT_MACOS_FFMPEG_URL: &str = "https://evermeet.cx/ffmpeg/ffmpeg-8.1.1.zip"; +#[cfg(target_os = "macos")] +const DEFAULT_MACOS_FFPROBE_URL: &str = "https://evermeet.cx/ffmpeg/ffprobe-8.1.1.zip"; +#[cfg(target_os = "macos")] +const DEFAULT_MACOS_FFMPEG_SHA256: &str = + "4610988e2f54c243c50da73a09e4e2c36d9bb77546f9aa6c84cb328dcb1a98c1"; +#[cfg(target_os = "macos")] +const DEFAULT_MACOS_FFPROBE_SHA256: &str = + "aeade29dee3c3844e9bcc974f4ae4b29cc4f87994177d77003a8589fa531009e"; +// Linux: a static amd64 build (ffmpeg + ffprobe in one .tar.xz) downloaded at +// first launch, mirroring the Windows/macOS model so we never redistribute +// FFmpeg ourselves. Overridable via STEMDECK_FFMPEG_URL. The archive unpacks to +// ffmpeg--amd64-static/{ffmpeg,ffprobe}; extraction uses the system `tar`. +#[cfg(all(unix, not(target_os = "macos")))] +const DEFAULT_LINUX_FFMPEG_URL: &str = + "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz"; + +struct BackendHandles { + child: Child, + url: String, +} + +struct BackendStateInner { + handles: Option, + /// True while start_backend is executing; prevents concurrent starts (#145). + starting: bool, + /// PID of an in-progress pip subprocess; killed by stop_backend on window close (#140). + pip_pid: Option, +} + +impl Default for BackendStateInner { + fn default() -> Self { + BackendStateInner { + handles: None, + starting: false, + pip_pid: None, + } + } +} + +struct BackendState { + inner: Mutex, +} + +impl Default for BackendState { + fn default() -> Self { + BackendState { + inner: Mutex::new(BackendStateInner::default()), + } + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RuntimeProbe { + app_root: String, + data_dir: String, + python_path: Option, + python_ready: bool, + ffmpeg_path: Option, + ffmpeg_ready: bool, + /// Persisted from previous setup run; None means GPU step hasn't run yet. + torch_device: Option, +} + +#[derive(Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +struct RuntimeManifest { + version: String, + arch: String, + runtime_url: String, + runtime_sha256: String, + runtime_size: Option, + archive_name: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RuntimePackStatus { + manifest_ready: bool, + manifest_path: Option, + runtime_ready: bool, + runtime_dir: String, + backend_ready: bool, + python_ready: bool, + archive_path: Option, + archive_ready: bool, + installed_version: Option, + manifest: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RuntimeArchive { + archive_path: String, + sha256: String, + size: u64, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct DownloadProgress { + received: u64, + total: Option, +} + +#[derive(Serialize)] +struct BackendStarted { + url: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct AssetStatus { + ffmpeg_ready: bool, + ffmpeg_path: Option, + model_ready: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct GpuSetup { + gpu_detected: bool, + gpu_name: Option, + cuda_version: Option, + torch_device: String, + cuda_verified: bool, +} + +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_store::Builder::default().build()) + .setup(|app| { + let data_dir = match local_data_dir() { + Ok(d) => d, + Err(e) => { + eprintln!("[stemdeck] could not resolve data_dir, skipping version check: {e}"); + return Ok(()); + } + }; + let _ = fs::create_dir_all(&data_dir); + + let version_file = data_dir.join("last_version.txt"); + let migration_flag = data_dir.join("store_migration_done"); + let current = env!("CARGO_PKG_VERSION"); + let last = fs::read_to_string(&version_file).unwrap_or_default(); + + if last.trim() != current { + if migration_flag.exists() { + #[cfg(target_os = "macos")] + clear_webkit_data(); + } + // Only update the version file if write succeeds. If it fails, skip + // cleanup — a missing version file would otherwise cause every launch + // to wipe WebKit data. + if let Err(e) = fs::write(&version_file, current) { + eprintln!("[stemdeck] failed to write version file, skipping cleanup: {e}"); + } + } + let _ = app; // suppress unused warning + Ok(()) + }) + .manage(BackendState::default()) + .invoke_handler(tauri::generate_handler![ + probe_runtime, + ensure_workspace, + runtime_pack_status, + download_runtime_pack, + verify_runtime_pack, + extract_runtime_pack, + ensure_external_assets, + ensure_torch_device, + start_backend, + local_ip, + open_url, + save_audio_file, + store_get, + store_set, + mark_store_migration_done, + ]) + .build(tauri::generate_context!()) + .expect("failed to build StemDeck desktop app") + .run(|app_handle, event| match event { + tauri::RunEvent::WindowEvent { + event: tauri::WindowEvent::CloseRequested { .. }, + .. + } => { + let state = app_handle.state::(); + stop_backend(&state); + app_handle.exit(0); + } + _ => {} + }); +} + +/// Returns ~/Documents/StemDeck/, creating it if needed. +/// All user-facing content (library metadata + stem audio) lives here so it is +/// visible in Finder, eligible for iCloud backup, and survives app reinstalls. +fn documents_stemdeck_dir(app: &tauri::AppHandle) -> Result { + let documents = app.path().document_dir().map_err(|e| e.to_string())?; + let dir = documents.join("StemDeck"); + fs::create_dir_all(&dir).map_err(|e| format!("failed to create ~/Documents/StemDeck: {e}"))?; + Ok(dir) +} + +/// Returns ~/Documents/StemDeck/user-data.json (library metadata store). +fn documents_store_path(app: &tauri::AppHandle) -> Result { + Ok(documents_stemdeck_dir(app)?.join("user-data.json")) +} + +/// Returns ~/Documents/StemDeck/jobs/ (stem audio files). +/// Falls back to data_dir/jobs if document_dir is unavailable. +fn documents_dir_for_jobs(app: &tauri::AppHandle) -> PathBuf { + match documents_stemdeck_dir(app) { + Ok(dir) => { + let jobs = dir.join("jobs"); + let _ = fs::create_dir_all(&jobs); + jobs + } + Err(_) => local_data_dir() + .map(|d| d.join("jobs")) + .unwrap_or_else(|_| PathBuf::from("jobs")), + } +} + +/// Get a value from the persistent user-data store. +#[tauri::command] +fn store_get(app: tauri::AppHandle, key: String) -> Result, String> { + let path = documents_store_path(&app)?; + let store = app.store(path).map_err(|e| e.to_string())?; + Ok(store.get(&key)) +} + +/// Set a value in the persistent user-data store and immediately flush to disk. +#[tauri::command] +fn store_set(app: tauri::AppHandle, key: String, value: serde_json::Value) -> Result<(), String> { + let path = documents_store_path(&app)?; + let store = app.store(path).map_err(|e| e.to_string())?; + store.set(key, value); + store.save().map_err(|e| e.to_string()) +} + +/// Called by JS after the one-time localStorage → store migration completes. +/// Writing this flag allows the setup hook to safely clear stale WebKit data +/// on subsequent version upgrades. +#[tauri::command] +fn mark_store_migration_done() { + match local_data_dir() { + Ok(d) => { + if let Err(e) = fs::write(d.join("store_migration_done"), "") { + eprintln!("[stemdeck] failed to write migration flag: {e}"); + } + } + Err(e) => eprintln!("[stemdeck] could not write migration flag: {e}"), + } +} + +/// Delete stale WebKit data directories on macOS so a new app version starts +/// with a clean WebView. Only called after the JS store migration is confirmed +/// (store_migration_done flag exists), ensuring no user data is lost. +#[cfg(target_os = "macos")] +fn clear_webkit_data() { + let home = match std::env::var("HOME") { + Ok(h) => h, + Err(_) => return, + }; + let targets = [ + format!("{home}/Library/WebKit/app.stemdeck.desktop"), + format!("{home}/Library/WebKit/stemdeck"), + ]; + for path in &targets { + if let Err(e) = fs::remove_dir_all(path) { + if e.kind() != std::io::ErrorKind::NotFound { + eprintln!("[stemdeck] WebKit cleanup failed for {path}: {e}"); + } + } + } +} + +/// Returns current runtime state: Python path, FFmpeg path, and persisted torch device. +#[tauri::command] +fn probe_runtime() -> Result { + let root = app_root()?; + let data_dir = local_data_dir()?; + let python = python_path(&root); + if let Some(path) = python.as_deref() { + patch_pyvenv_cfg(path); + } + let ffmpeg = resolve_existing_ffmpeg(&data_dir); + let torch_device = read_config_str(&data_dir, "torchDevice"); + Ok(RuntimeProbe { + app_root: root.display().to_string(), + data_dir: data_dir.display().to_string(), + python_ready: python.as_ref().is_some_and(|p| python_stdlib_ok(p)), + python_path: python.map(|p| p.display().to_string()), + ffmpeg_ready: ffmpeg.is_some(), + ffmpeg_path: ffmpeg.map(|p| p.display().to_string()), + torch_device, + }) +} + +/// Read a single string field from data/config.json, returning None on any error. +fn read_config_str(data_dir: &std::path::Path, key: &str) -> Option { + let text = fs::read_to_string(data_dir.join("config.json")).ok()?; + let value: serde_json::Value = serde_json::from_str(&text).ok()?; + value.get(key)?.as_str().map(|s| s.to_string()) +} + +/// Returns the current state of the bundled Python runtime pack (manifest, archive, install). +#[tauri::command] +fn runtime_pack_status() -> Result { + let root = app_root()?; + let data_dir = local_data_dir()?; + let runtime_dir = runtime_dir(&data_dir); + let backend_dir = runtime_dir.join("backend"); + let python = runtime_python_path(&data_dir); + let manifest_path = runtime_manifest_path(&root); + let manifest = manifest_path + .as_deref() + .and_then(|path| read_runtime_manifest(path).ok()); + let archive_path = manifest + .as_ref() + .map(|item| runtime_archive_path(&data_dir, item)); + let installed_version = read_runtime_install_manifest(&runtime_dir) + .and_then(|value| value.get("version")?.as_str().map(|text| text.to_string())); + + Ok(RuntimePackStatus { + manifest_ready: manifest.is_some(), + manifest_path: manifest_path.map(|path| path.display().to_string()), + runtime_ready: backend_dir.join("app").is_dir() && python.is_file(), + runtime_dir: runtime_dir.display().to_string(), + backend_ready: backend_dir.join("app").is_dir(), + python_ready: python.is_file(), + archive_ready: archive_path.as_ref().is_some_and(|path| path.is_file()), + archive_path: archive_path.map(|path| path.display().to_string()), + installed_version, + manifest, + }) +} + +/// Downloads the Python runtime pack archive, emitting progress events to the frontend. +#[tauri::command] +async fn download_runtime_pack(app_handle: tauri::AppHandle) -> Result { + ensure_workspace()?; + let root = app_root()?; + let data_dir = local_data_dir()?; + let manifest = load_runtime_manifest(&root)?; + validate_runtime_manifest(&manifest)?; + let archive = runtime_archive_path(&data_dir, &manifest); + if let Some(parent) = archive.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("failed to create {}: {e}", parent.display()))?; + } + download_file_with_progress(&manifest.runtime_url, &archive, &app_handle).await?; + verify_runtime_archive(&manifest, &archive) +} + +/// Verifies the SHA256 of a previously downloaded runtime pack archive. +#[tauri::command] +fn verify_runtime_pack() -> Result { + let root = app_root()?; + let data_dir = local_data_dir()?; + let manifest = load_runtime_manifest(&root)?; + validate_runtime_manifest(&manifest)?; + let archive = runtime_archive_path(&data_dir, &manifest); + verify_runtime_archive(&manifest, &archive) +} + +/// Extracts the verified runtime pack archive and atomically swaps it into place. +#[tauri::command] +fn extract_runtime_pack() -> Result { + ensure_workspace()?; + let root = app_root()?; + let data_dir = local_data_dir()?; + let manifest = load_runtime_manifest(&root)?; + validate_runtime_manifest(&manifest)?; + let archive = runtime_archive_path(&data_dir, &manifest); + verify_runtime_archive(&manifest, &archive)?; + + let runtime = runtime_dir(&data_dir); + let tmp = data_dir.join("runtime.tmp"); + let old = data_dir.join("runtime.old"); + if tmp.exists() { + fs::remove_dir_all(&tmp).map_err(|e| format!("failed to remove {}: {e}", tmp.display()))?; + } + fs::create_dir_all(&tmp).map_err(|e| format!("failed to create {}: {e}", tmp.display()))?; + + extract_tar_archive(&archive, &tmp)?; + let extracted = tmp.join("runtime"); + if !extracted.join("backend").join("app").is_dir() { + return Err("runtime archive did not contain runtime/backend/app".to_string()); + } + if !extracted + .join("python") + .join("bin") + .join("python") + .is_file() + { + return Err("runtime archive did not contain runtime/python/bin/python".to_string()); + } + + let install_manifest = serde_json::json!({ + "version": manifest.version, + "arch": manifest.arch, + "runtimeUrl": manifest.runtime_url, + "runtimeSha256": manifest.runtime_sha256, + "installedAt": unix_timestamp(), + }); + fs::write( + extracted.join("runtime-manifest.json"), + serde_json::to_string_pretty(&install_manifest) + .map_err(|e| format!("failed to serialize runtime install manifest: {e}"))? + + "\n", + ) + .map_err(|e| format!("failed to write runtime manifest: {e}"))?; + + if old.exists() { + fs::remove_dir_all(&old).map_err(|e| format!("failed to remove {}: {e}", old.display()))?; + } + if runtime.exists() { + fs::rename(&runtime, &old) + .map_err(|e| format!("failed to move existing runtime aside: {e}"))?; + } + fs::rename(&extracted, &runtime).map_err(|e| format!("failed to install runtime: {e}"))?; + + // Cleanup is non-fatal; log warnings rather than silently discarding errors. + if let Err(e) = fs::remove_dir_all(&tmp) { + if let Ok(d) = local_data_dir() { + append_to_setup_log(&d, &format!("cleanup warning: {}: {e}", tmp.display())); + } + } + if let Err(e) = fs::remove_dir_all(&old) { + if let Ok(d) = local_data_dir() { + append_to_setup_log(&d, &format!("cleanup warning: {}: {e}", old.display())); + } + } + + let python = runtime.join("python").join("bin").join("python"); + patch_pyvenv_cfg(&python); + runtime_pack_status() +} + +/// Creates required data directories and runs any pending data migrations. +#[tauri::command] +fn ensure_workspace() -> Result<(), String> { + let root = app_root()?; + let data = local_data_dir()?; + + // Recover from an interrupted runtime swap: if runtime/ is absent but + // runtime.old/ exists, a previous extract_runtime_pack was killed between + // the two rename steps. Restore the previous install so setup can retry. + { + let runtime_path = runtime_dir(&data); + let old_path = data.join("runtime.old"); + if !runtime_path.exists() && old_path.is_dir() { + let _ = fs::rename(&old_path, &runtime_path); + } + } + + migrate_legacy_data(&root, &data); + fs::create_dir_all(&data).map_err(|e| format!("failed to create data dir: {e}"))?; + for dir in ["cache", "downloads", "ffmpeg", "jobs", "logs", "models"] { + fs::create_dir_all(data.join(dir)) + .map_err(|e| format!("failed to create data/{dir}: {e}"))?; + } + if is_cpu_only_package(&root, &data) { + fs::write(data.join("cpu-only"), "") + .map_err(|e| format!("failed to write data/cpu-only: {e}"))?; + } + let config = data.join("config.json"); + if !config.exists() { + fs::write( + &config, + "{\n \"setupVersion\": 1,\n \"ffmpegReady\": false,\n \"modelReady\": false\n}\n", + ) + .map_err(|e| format!("failed to write {}: {e}", config.display()))?; + } + Ok(()) +} + +/// Downloads FFmpeg/ffprobe if absent and writes their paths to config.json. +#[tauri::command] +fn ensure_external_assets() -> Result { + ensure_workspace()?; + let data_dir = local_data_dir()?; + let ffmpeg = ensure_ffmpeg(&data_dir)?; + write_setup_config(&data_dir, &ffmpeg)?; + Ok(AssetStatus { + ffmpeg_ready: true, + ffmpeg_path: Some(ffmpeg.display().to_string()), + model_ready: false, + }) +} + +/// Spawns the Python/uvicorn backend, waits for it to become healthy, and returns its URL. +#[tauri::command] +fn start_backend( + app_handle: tauri::AppHandle, + state: tauri::State, +) -> Result { + // Always bind all interfaces; whether other devices are actually served is + // controlled live by the backend's network gate (Settings → "Make StemDeck + // available on your network"), which defaults off and always allows + // loopback. The WebView itself connects via 127.0.0.1 regardless. + let bind_host = "0.0.0.0"; + // Gate concurrent calls: return immediately if already running or starting (#145). + { + let mut inner = state.inner.lock().map_err(|e| e.to_string())?; + if let Some(ref h) = inner.handles { + return Ok(BackendStarted { url: h.url.clone() }); + } + if inner.starting { + return Err("Backend startup already in progress".to_string()); + } + inner.starting = true; + } + + // Spawn and wait for health outside the lock; update state atomically on completion. + let spawn_result = (|| { + let root = app_root()?; + let backend_dir = backend_dir(&root)?; + let data_dir = local_data_dir()?; + let python = python_path(&root).filter(|p| p.is_file()).ok_or_else(|| { + "Python runtime not found. Expected python/ or .venv/ under StemDeck.".to_string() + })?; + patch_pyvenv_cfg(&python); + let (port, port_guard) = reserve_port(configured_port())?; + let url = format!("http://127.0.0.1:{port}"); + let log_path = data_dir.join("logs").join("backend.log"); + let (stdout, stderr) = prepare_backend_stdio(&log_path).unwrap_or_else(|_| { + // Logging should help diagnose startup; it should not prevent startup. + (Stdio::null(), Stdio::null()) + }); + + // On macOS and Linux, python-build-standalone detects its own prefix by + // walking up from bin/ — PYTHONHOME is not needed and actively breaks + // startup when mis-computed (it would point at python/bin, whose + // lib/python3.X has no stdlib, so even `encodings` fails to import). + // Only Windows, whose portable venv keeps the stdlib under base/Lib, + // needs PYTHONHOME to locate the bundled stdlib. + // Compute before moving python into Command::new. + #[cfg(windows)] + let pythonhome = python + .parent() + .and_then(|bin_dir| bin_dir.parent().map(|venv| (venv, bin_dir))) + .and_then(|(venv, bin_dir)| bundled_python_home(venv, bin_dir).map(|(home, _)| home)); + + let mut cmd = Command::new(python); + cmd.args([ + "-m", + "uvicorn", + "app.main:app", + "--host", + bind_host, + "--port", + &port.to_string(), + ]); + #[cfg(windows)] + if let Some(ref pythonhome) = pythonhome { + cmd.env("PYTHONHOME", pythonhome); + } + + // Jobs (stem audio files) live in ~/Documents/StemDeck/jobs/ so the user's + // library is visible in Finder, backed up by iCloud, and survives app reinstalls. + let jobs_dir = documents_dir_for_jobs(&app_handle); + + cmd.current_dir(&backend_dir) + .env("STEMDECK_DATA_DIR", &data_dir) + .env("STEMDECK_JOBS_DIR", &jobs_dir) + .env("STEMDECK_DESKTOP", "1") + .env("STEMDECK_PARENT_PID", std::process::id().to_string()) + .env("PYTHONUNBUFFERED", "1") + .env("XDG_CACHE_HOME", data_dir.join("cache")) + .env("TORCH_HOME", data_dir.join("models").join("torch")) + .stdout(stdout) + .stderr(stderr); + + if let Some(ffmpeg_dir) = ffmpeg_dir_if_present(&data_dir) { + let existing = env::var_os("PATH").unwrap_or_default(); + let mut paths = vec![ffmpeg_dir]; + paths.extend(env::split_paths(&existing)); + let joined = env::join_paths(paths).map_err(|e| e.to_string())?; + cmd.env("PATH", joined); + } + + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + + let mut child = cmd + .spawn() + .map_err(|e| format!("failed to start backend: {e}"))?; + // Release the reserved port immediately after spawn so uvicorn can bind it. + drop(port_guard); + + if let Err(err) = wait_for_health(port, Duration::from_secs(90), &log_path) { + let _ = child.kill(); + let _ = child.wait(); + return Err(err); + } + + Ok((child, url)) + })(); + + // Atomically update state: clear starting flag whether spawn succeeded or failed. + let mut inner = state.inner.lock().map_err(|e| e.to_string())?; + inner.starting = false; + match spawn_result { + Ok((child, url)) => { + inner.handles = Some(BackendHandles { + child, + url: url.clone(), + }); + Ok(BackendStarted { url }) + } + Err(e) => Err(e), + } +} + +/// Best-effort primary LAN IPv4, shown in Settings so the user knows the address +/// to open StemDeck from another device. Uses the "connect a UDP socket" trick: +/// no packets are sent — connect() just makes the OS pick the source IP for the +/// default route. Returns None when offline / no route. +#[tauri::command] +fn local_ip() -> Option { + use std::net::UdpSocket; + let sock = UdpSocket::bind("0.0.0.0:0").ok()?; + sock.connect("8.8.8.8:80").ok()?; + match sock.local_addr().ok()?.ip() { + std::net::IpAddr::V4(v4) if !v4.is_loopback() => Some(v4.to_string()), + _ => None, + } +} + +/// Detects GPU hardware, installs CUDA torch if needed, and persists the chosen device. +#[tauri::command] +fn ensure_torch_device(state: tauri::State) -> Result { + let root = app_root()?; + let data_dir = local_data_dir()?; + + // CPU-only portable build: skip GPU detection and pip entirely. + if is_cpu_only_package(&root, &data_dir) { + persist_torch_device(&data_dir, "cpu"); + return Ok(GpuSetup { + gpu_detected: false, + gpu_name: None, + cuda_version: None, + torch_device: "cpu".to_string(), + cuda_verified: false, + }); + } + + let python = python_path(&root) + .filter(|p| p.is_file()) + .ok_or_else(|| "Python not found".to_string())?; + patch_pyvenv_cfg(&python); + + #[cfg(target_os = "macos")] + { + let mps_available = verify_mps_torch(&python); + let device = if mps_available { "mps" } else { "cpu" }; + persist_torch_device(&data_dir, device); + Ok(GpuSetup { + gpu_detected: mps_available, + gpu_name: if mps_available { + Some("Apple Silicon (MPS)".to_string()) + } else { + None + }, + cuda_version: None, + torch_device: device.to_string(), + cuda_verified: false, + }) + } + + #[cfg(not(target_os = "macos"))] + { + let setup = match detect_nvidia_gpu() { + Some((gpu_name, cuda_version, compute_cap)) => { + let index_url = cuda_index_url(compute_cap.as_deref(), &cuda_version); + install_cuda_torch(&python, &index_url, &state)?; + let cuda_verified = verify_cuda_torch(&python); + GpuSetup { + gpu_detected: true, + gpu_name: Some(gpu_name), + cuda_version: Some(cuda_version), + torch_device: if cuda_verified { "cuda" } else { "cpu" }.to_string(), + cuda_verified, + } + } + None => GpuSetup { + gpu_detected: false, + gpu_name: None, + cuda_version: None, + torch_device: "cpu".to_string(), + cuda_verified: false, + }, + }; + // Persist so subsequent launches skip this step entirely. + persist_torch_device(&data_dir, &setup.torch_device); + Ok(setup) + } +} + +fn is_cpu_only_package(root: &Path, data_dir: &Path) -> bool { + root.join("cpu-only").is_file() || data_dir.join("cpu-only").is_file() +} + +fn persist_torch_device(data_dir: &std::path::Path, device: &str) { + let _ = update_setup_config( + data_dir, + [("torchDevice", serde_json::Value::String(device.to_string()))], + ); +} + +#[cfg(target_os = "macos")] +fn verify_mps_torch(python: &Path) -> bool { + Command::new(python) + .args([ + "-c", + "import torch; exit(0 if getattr(torch.backends, 'mps', None) and torch.backends.mps.is_available() else 1)", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +#[cfg(not(target_os = "macos"))] +fn nvidia_smi_exe() -> &'static str { + // nvidia-smi.exe lives in System32 on Windows but Tauri child processes + // inherit a stripped PATH that may not include it. + #[cfg(windows)] + { + const SYSTEM32: &str = r"C:\Windows\System32\nvidia-smi.exe"; + if std::path::Path::new(SYSTEM32).is_file() { + return SYSTEM32; + } + } + "nvidia-smi" +} + +#[cfg(not(target_os = "macos"))] +fn detect_nvidia_gpu() -> Option<(String, String, Option)> { + let smi = nvidia_smi_exe(); + let mut cmd = Command::new(smi); + cmd.args(["--query-gpu=name", "--format=csv,noheader"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + hide_console_window(&mut cmd); + let name_out = command_output_with_timeout(cmd, Duration::from_secs(10), "nvidia-smi").ok()?; + if !name_out.status.success() { + return None; + } + let gpu_name = String::from_utf8_lossy(&name_out.stdout).trim().to_string(); + if gpu_name.is_empty() { + return None; + } + + // Read CUDA version from the standard nvidia-smi header. + let mut smi_cmd = Command::new(smi); + smi_cmd.stdout(Stdio::piped()).stderr(Stdio::null()); + hide_console_window(&mut smi_cmd); + let smi_out = + command_output_with_timeout(smi_cmd, Duration::from_secs(10), "nvidia-smi").ok()?; + let smi_text = String::from_utf8_lossy(&smi_out.stdout); + let cuda_version = parse_cuda_version(&smi_text).unwrap_or_else(|| "12.4".to_string()); + + // Read the GPU's compute capability (e.g. "12.0" for Blackwell sm_120, + // "8.9" for Ada). Drives the wheel choice: stock torch 2.6 cu12x wheels + // have no sm_120 kernels, so Blackwell needs a cu128 / torch 2.7 build. + // Failure here is non-fatal — we fall back to the CUDA-version heuristic. + let compute_cap = detect_compute_cap(smi); + + Some((gpu_name, cuda_version, compute_cap)) +} + +#[cfg(not(target_os = "macos"))] +fn detect_compute_cap(smi: &str) -> Option { + let mut cmd = Command::new(smi); + cmd.args(["--query-gpu=compute_cap", "--format=csv,noheader"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + hide_console_window(&mut cmd); + let out = command_output_with_timeout(cmd, Duration::from_secs(10), "nvidia-smi").ok()?; + if !out.status.success() { + return None; + } + // Multi-GPU systems print one line per GPU; take the first. + let cap = String::from_utf8_lossy(&out.stdout) + .lines() + .next() + .unwrap_or("") + .trim() + .to_string(); + if cap.is_empty() || cap == "N/A" { + None + } else { + Some(cap) + } +} + +#[cfg(not(target_os = "macos"))] +fn parse_cuda_version(smi_output: &str) -> Option { + for line in smi_output.lines() { + if let Some(pos) = line.find("CUDA Version:") { + let rest = &line[pos + "CUDA Version:".len()..]; + let v = rest + .trim() + .split_whitespace() + .next()? + .trim_matches('|') + .trim(); + if !v.is_empty() && v != "N/A" { + return Some(v.to_string()); + } + } + } + None +} + +#[cfg(not(target_os = "macos"))] +fn cuda_tag(cuda_version: &str) -> &'static str { + let parts: Vec = cuda_version + .splitn(2, '.') + .filter_map(|p| p.parse().ok()) + .collect(); + match parts.as_slice() { + [12, minor] if *minor >= 4 => "cu124", + [12, _] => "cu121", + [11, _] => "cu118", + _ => "cu124", + } +} + +/// Pick the PyTorch wheel tag. Keyed primarily on the GPU's compute capability: +/// Blackwell (sm_100 / sm_120, major >= 10) has no kernels in the stock torch +/// 2.6 cu12x wheels and needs a cu128 / torch 2.7 build (#217). Everything else +/// falls back to the driver-CUDA-version heuristic. +#[cfg(not(target_os = "macos"))] +fn wheel_tag(compute_cap: Option<&str>, cuda_version: &str) -> &'static str { + if let Some(cap) = compute_cap { + if let Some(major) = cap.split('.').next().and_then(|m| m.parse::().ok()) { + if major >= 10 { + return "cu128"; + } + } + } + cuda_tag(cuda_version) +} + +#[cfg(not(target_os = "macos"))] +fn cuda_index_url(compute_cap: Option<&str>, cuda_version: &str) -> String { + format!( + "https://download.pytorch.org/whl/{}", + wheel_tag(compute_cap, cuda_version) + ) +} + +#[cfg(not(target_os = "macos"))] +fn cuda_tag_from_url(index_url: &str) -> &str { + index_url.rsplit('/').next().unwrap_or("cu124") +} + +/// Update pyvenv.cfg to the bundled Python runtime. Windows venv launchers read +/// this file before Python starts, so stale build-machine paths can prevent the +/// backend from emitting any log output at all. +fn patch_pyvenv_cfg(python: &Path) { + let Some(bin_dir) = python.parent() else { + return; + }; + let Some(venv_root) = bin_dir.parent() else { + return; + }; + let Some((home_dir, bundled_python)) = bundled_python_home(venv_root, bin_dir) else { + return; + }; + let cfg_path = venv_root.join("pyvenv.cfg"); + let Ok(content) = fs::read_to_string(&cfg_path) else { + return; + }; + let home_str = home_dir.display().to_string(); + let python_str = bundled_python.display().to_string(); + let patched: String = content + .lines() + .map(|line| { + let trimmed = line.trim_start(); + if trimmed.starts_with("home") && trimmed[4..].trim_start().starts_with('=') { + format!("home = {home_str}") + } else if trimmed.starts_with("executable") + && trimmed["executable".len()..].trim_start().starts_with('=') + { + format!("executable = {python_str}") + } else { + line.to_string() + } + }) + .collect::>() + .join("\n"); + let patched = if content.ends_with('\n') { + patched + "\n" + } else { + patched + }; + let _ = fs::write(&cfg_path, patched); +} + +fn prepare_backend_stdio(log_path: &Path) -> Result<(Stdio, Stdio), String> { + if let Some(parent) = log_path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("failed to create backend log directory: {e}"))?; + } + fs::File::create(log_path) + .map_err(|e| format!("failed to create backend log {}: {e}", log_path.display()))?; + let stdout = fs::OpenOptions::new() + .create(true) + .append(true) + .open(log_path) + .map_err(|e| { + format!( + "failed to open backend stdout log {}: {e}", + log_path.display() + ) + })?; + let stderr = fs::OpenOptions::new() + .create(true) + .append(true) + .open(log_path) + .map_err(|e| { + format!( + "failed to open backend stderr log {}: {e}", + log_path.display() + ) + })?; + Ok((Stdio::from(stdout), Stdio::from(stderr))) +} + +fn bundled_python_home(venv_root: &Path, bin_dir: &Path) -> Option<(PathBuf, PathBuf)> { + let executable = if cfg!(windows) { + "python.exe" + } else { + "python" + }; + + if cfg!(windows) { + let base_home = venv_root.join("base"); + let base_python = base_home.join(executable); + if base_python.is_file() && base_home.join("Lib").join("os.py").is_file() { + return Some((base_home, base_python)); + } + + let legacy_root_python = venv_root.join(executable); + if legacy_root_python.is_file() && venv_root.join("Lib").join("os.py").is_file() { + let launcher = bin_dir.join(executable); + if launcher.is_file() { + return Some((bin_dir.to_path_buf(), launcher)); + } + } + } else if python_stdlib_present(venv_root) { + let launcher = bin_dir.join(executable); + if launcher.is_file() { + return Some((bin_dir.to_path_buf(), launcher)); + } + } + + None +} + +fn python_stdlib_ok(python: &Path) -> bool { + if !python.is_file() { + return false; + } + let mut cmd = Command::new(python); + cmd.args(["-c", "import encodings"]); + // Only Windows needs PYTHONHOME: its portable venv keeps the stdlib under + // base/Lib. macOS and Linux use python-build-standalone, which auto-detects + // its prefix from bin/ — setting PYTHONHOME there points at the wrong dir + // and breaks the import (parity with start_backend). + #[cfg(windows)] + { + let venv_root = python.parent().and_then(|b| b.parent()); + let pythonhome = venv_root.map(|venv| { + let base = venv.join("base"); + if base.join("Lib").join("os.py").is_file() { + return base; + } + venv.to_path_buf() + }); + if let Some(ref home) = pythonhome { + cmd.env("PYTHONHOME", home); + } + } + cmd.stdout(Stdio::null()).stderr(Stdio::null()); + cmd.status().map(|s| s.success()).unwrap_or(false) +} + +fn python_stdlib_present(venv_root: &Path) -> bool { + if venv_root.join("Lib").join("os.py").is_file() { + return true; + } + let lib = venv_root.join("lib"); + let Ok(entries) = fs::read_dir(lib) else { + return false; + }; + entries + .filter_map(Result::ok) + .any(|entry| entry.path().join("os.py").is_file()) +} + +/// Maps known pip/OS failure patterns to actionable user messages. +/// Pure function — caller is responsible for logging the raw stderr before calling. +fn classify_cuda_install_error(stderr: &str) -> String { + let lower = stderr.to_ascii_lowercase(); + + if lower.contains("missing dependencies for socks") || lower.contains("pysocks") { + return "CUDA install failed: a SOCKS proxy is active on your system. \ + Disable it temporarily and click Retry." + .to_string(); + } + if lower.contains("no space left on device") + || lower.contains("not enough space on the disk") + || lower.contains("disk quota exceeded") + { + return "CUDA install failed: not enough disk space. Free up space and click Retry." + .to_string(); + } + if lower.contains("access is denied") || lower.contains("permissionerror") { + return "CUDA install failed: permission denied — antivirus software may be blocking \ + the install. Try adding StemDeck to your AV exclusions and click Retry." + .to_string(); + } + if lower.contains("could not connect") || lower.contains("connection timed out") { + return "CUDA install failed: could not reach download.pytorch.org. \ + Check your internet connection and click Retry." + .to_string(); + } + + // Unknown error — full stderr is already in setup.log; surface a generic message + // rather than leaking raw pip output (file paths, stack traces) to the UI. + "CUDA install failed — see logs/setup.log for details.".to_string() +} + +#[cfg(not(target_os = "macos"))] +fn install_cuda_torch(python: &Path, index_url: &str, state: &BackendState) -> Result<(), String> { + // Skip only when CUDA torch is already active — torch.version.cuda is + // None for CPU-only wheels, so this correctly re-installs when needed. + if verify_cuda_torch(python) { + return Ok(()); + } + + // Fix the build machine's Python path baked into pyvenv.cfg before pip + // runs — pip validates the `home` entry and fails if it doesn't exist. + patch_pyvenv_cfg(python); + + // Use the explicit local-version suffix (e.g. torch==2.6.0+cu124) so pip + // treats the CUDA wheel as a distinct version from the CPU-only 2.6.0 + // wheel and doesn't skip the install as "already satisfied". + // + // Blackwell (cu128) only has wheels for torch 2.7+; every other tag stays + // on the validated 2.6.0 line (#217). torchaudio.save() routes through + // soundfile here, so minor torchaudio codec changes don't affect us. + // cu128 uses 2.8.0: 2.7.1 shipped incomplete sm_120 kernels for Blackwell + // (RTX 5000 series), causing verify_cuda_torch to fail (#239). + let tag = cuda_tag_from_url(index_url); + let torch_version = if tag == "cu128" { "2.8.0" } else { "2.6.0" }; + let torch_spec = format!("torch=={torch_version}+{tag}"); + let torchaudio_spec = format!("torchaudio=={torch_version}+{tag}"); + // --ignore-installed: overwrites even a corrupted/partial install that + // has no RECORD file. --no-deps: only replace torch/torchaudio wheels. + let mut command = Command::new(python); + command + .args([ + "-m", + "pip", + "install", + &torch_spec, + &torchaudio_spec, + "--index-url", + index_url, + "--ignore-installed", + "--no-deps", + "--quiet", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + + let child = command + .spawn() + .map_err(|e| format!("failed to start CUDA torch install: {e}"))?; + + // Track the pip PID so stop_backend can kill it if the window is closed + // while CUDA torch is installing, preventing venv corruption (#140). + if let Ok(mut inner) = state.inner.lock() { + inner.pip_pid = Some(child.id()); + } + let output = + child_output_with_timeout(child, Duration::from_secs(20 * 60), "CUDA torch install"); + if let Ok(mut inner) = state.inner.lock() { + inner.pip_pid = None; + } + let output = output?; + + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + // Write full stderr to setup.log before mapping — eprintln! is silent in + // GUI mode on Windows (no console), so file logging is the only reliable + // diagnostic path in the deployed app. + if let Ok(data_dir) = local_data_dir() { + let log_path = data_dir.join("logs").join("setup.log"); + if let Some(parent) = log_path.parent() { + let _ = fs::create_dir_all(parent); + } + if let Ok(mut f) = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + { + let _ = writeln!( + f, + "[stemdeck] CUDA torch install failed. stderr:\n{}", + stderr.trim() + ); + } + } + Err(classify_cuda_install_error(&stderr)) + } +} + +#[cfg(not(target_os = "macos"))] +fn verify_cuda_torch(python: &Path) -> bool { + // Don't trust torch.cuda.is_available() alone: it returns True even when the + // installed wheel has no kernels for the device (e.g. sm_120 on a cu124 + // build), which then crashes mid-extraction with "no kernel image is + // available" (#217). Force a real kernel launch so an incompatible wheel is + // caught here and the app falls back to CPU cleanly. + let result = Command::new(python) + .args([ + "-c", + "import torch; \ + exit(1) if not torch.cuda.is_available() else None; \ + (torch.ones(8, device='cuda') * 2).sum().item(); \ + torch.cuda.synchronize(); \ + exit(0)", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output(); + + match result { + Ok(out) if out.status.success() => true, + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr); + if !stderr.trim().is_empty() { + if let Ok(data_dir) = local_data_dir() { + let log_path = data_dir.join("logs").join("setup.log"); + if let Some(parent) = log_path.parent() { + let _ = fs::create_dir_all(parent); + } + if let Ok(mut f) = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + { + let _ = writeln!( + f, + "[stemdeck] CUDA verify failed. stderr:\n{}", + stderr.trim() + ); + } + } + } + false + } + Err(_) => false, + } +} + +/// Opens an http/https URL in the system browser. Rejects non-http schemes. +#[tauri::command] +fn open_url(url: String) -> Result<(), String> { + if !url.starts_with("https://") && !url.starts_with("http://") { + return Err("only http/https URLs are permitted".to_string()); + } + #[cfg(windows)] + { + // Use explorer.exe directly to avoid cmd.exe interpreting '&' in query strings. + let mut cmd = Command::new("explorer.exe"); + cmd.arg(&url); + cmd.spawn() + .map_err(|e| format!("failed to open URL: {e}"))?; + } + #[cfg(target_os = "macos")] + { + Command::new("open") + .arg(&url) + .spawn() + .map_err(|e| format!("failed to open URL: {e}"))?; + } + #[cfg(all(unix, not(target_os = "macos")))] + { + Command::new("xdg-open") + .arg(&url) + .spawn() + .map_err(|e| format!("failed to open URL: {e}"))?; + } + Ok(()) +} + +/// Prompts the user for a save path, then streams a localhost audio URL to disk. +#[tauri::command] +async fn save_audio_file( + app: tauri::AppHandle, + url: String, + filename: String, +) -> Result<(), String> { + if !url.starts_with("http://") && !url.starts_with("https://") { + return Err("only http/https URLs are permitted".to_string()); + } + // Restrict to localhost to prevent SSRF from a compromised WebView (#138). + let parsed_url = reqwest::Url::parse(&url).map_err(|_| "invalid URL".to_string())?; + let host = parsed_url.host_str().unwrap_or(""); + if host != "127.0.0.1" && host != "localhost" { + return Err("only localhost URLs are permitted".to_string()); + } + + use tauri_plugin_dialog::DialogExt; + let dest = app + .dialog() + .file() + .set_file_name(&filename) + .blocking_save_file(); + let Some(file_path) = dest else { + return Ok(()); // user cancelled + }; + let dest = file_path.into_path().map_err(|e| e.to_string())?; + + // Stream response to disk to avoid buffering a large audio file in memory (#139). + // 5-minute timeout covers large WAV exports over a slow loopback. + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(300)) + .build() + .map_err(|e| format!("failed to build client: {e}"))?; + let mut resp = client + .get(&url) + .send() + .await + .map_err(|e| format!("fetch failed: {e}"))?; + if !resp.status().is_success() { + return Err(format!("backend returned HTTP {}", resp.status())); + } + let tmp = dest.with_extension("audio.download"); + let mut file = + std::fs::File::create(&tmp).map_err(|e| format!("failed to create temp file: {e}"))?; + while let Some(chunk) = resp + .chunk() + .await + .map_err(|e| format!("read failed: {e}"))? + { + file.write_all(&chunk) + .map_err(|e| format!("write failed: {e}"))?; + } + file.sync_all().map_err(|e| format!("flush failed: {e}"))?; + drop(file); + std::fs::rename(&tmp, &dest).map_err(|e| format!("rename failed: {e}"))?; + Ok(()) +} + +fn stop_backend(state: &BackendState) { + let (handles, pip_pid) = match state.inner.lock() { + Ok(mut guard) => (guard.handles.take(), guard.pip_pid.take()), + Err(_) => return, + }; + + // Kill any in-progress pip subprocess so it doesn't corrupt the venv + // if the window is closed during CUDA torch installation (#140). + #[cfg(unix)] + if let Some(pid) = pip_pid { + // SAFETY: pid was stored immediately after spawn; we send SIGTERM best-effort. + unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) }; + } + + let Some(mut handles) = handles else { return }; + + // Drain the backend on a background thread so we don't block the Tauri + // RunEvent main thread for up to 3 seconds (#144). + thread::spawn(move || { + // Send SIGTERM first so uvicorn can drain in-progress requests + // before we escalate to SIGKILL. + #[cfg(unix)] + { + // SAFETY: child was spawned by us and has not yet been waited on; + // its PID is valid for the lifetime of the Child handle. + unsafe { libc::kill(handles.child.id() as libc::pid_t, libc::SIGTERM) }; + let deadline = Instant::now() + Duration::from_secs(3); + while Instant::now() < deadline { + if handles.child.try_wait().ok().flatten().is_some() { + return; + } + thread::sleep(Duration::from_millis(100)); + } + } + let _ = handles.child.kill(); + let _ = handles.child.wait(); + }); +} + +/// Returns the persistent user data directory for StemDeck. +/// On Windows: %LocalAppData%\StemDeck +/// On macOS: ~/Library/Application Support/StemDeck +/// On Linux: $XDG_DATA_HOME/stemdeck or ~/.local/share/stemdeck +/// Can be overridden by STEMDECK_DATA_DIR for development. +fn local_data_dir() -> Result { + if let Ok(path) = env::var("STEMDECK_DATA_DIR") { + return Ok(PathBuf::from(path)); + } + #[cfg(windows)] + { + let base = env::var("LOCALAPPDATA") + .map_err(|_| "LOCALAPPDATA environment variable not set".to_string())?; + Ok(PathBuf::from(base).join("StemDeck")) + } + #[cfg(target_os = "macos")] + { + let home = env::var("HOME").map_err(|_| "HOME environment variable not set".to_string())?; + Ok(PathBuf::from(home) + .join("Library") + .join("Application Support") + .join("StemDeck")) + } + #[cfg(all(unix, not(target_os = "macos")))] + { + if let Ok(xdg) = env::var("XDG_DATA_HOME") { + return Ok(PathBuf::from(xdg).join("stemdeck")); + } + let home = env::var("HOME").map_err(|_| "HOME environment variable not set".to_string())?; + Ok(PathBuf::from(home) + .join(".local") + .join("share") + .join("stemdeck")) + } +} + +/// Appends a timestamped line to data/logs/setup.log (best-effort; never fails the caller). +fn append_to_setup_log(data_dir: &Path, msg: &str) { + let log = data_dir.join("logs").join("setup.log"); + if let Some(p) = log.parent() { + let _ = fs::create_dir_all(p); + } + if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(&log) { + let _ = writeln!(f, "[stemdeck] {msg}"); + } +} + +/// One-time migration: move legacy data/models/jobs/ffmpeg from the install +/// directory into the new per-user data directory on the user's first launch +/// after upgrading to a version that uses local_data_dir(). +fn migrate_legacy_data(root: &Path, data_dir: &Path) { + let old = root.join("data"); + // Only migrate if the old location exists and the new one doesn't yet. + if !old.is_dir() || data_dir.exists() { + return; + } + for name in ["models", "jobs", "ffmpeg", "logs", "cache"] { + let src = old.join(name); + if src.is_dir() { + // rename is a cheap move on the same volume; ignore errors silently + // so a cross-volume failure doesn't block startup. + let _ = fs::rename(&src, data_dir.join(name)); + } + } + for name in ["config.json", "cpu-only"] { + let src = old.join(name); + if src.exists() { + let _ = fs::copy(&src, data_dir.join(name)); + } + } +} + +fn runtime_dir(data_dir: &Path) -> PathBuf { + data_dir.join("runtime") +} + +fn runtime_python_path(data_dir: &Path) -> PathBuf { + runtime_dir(data_dir) + .join("python") + .join("bin") + .join("python") +} + +fn runtime_manifest_path(root: &Path) -> Option { + [ + root.join("runtime-manifest.json"), + root.join("desktop") + .join("ui") + .join("runtime-manifest.json"), + ] + .into_iter() + .find(|path| path.is_file()) +} + +fn load_runtime_manifest(root: &Path) -> Result { + let path = runtime_manifest_path(root) + .ok_or_else(|| format!("runtime-manifest.json not found under {}", root.display()))?; + read_runtime_manifest(&path) +} + +fn read_runtime_manifest(path: &Path) -> Result { + let text = + fs::read_to_string(path).map_err(|e| format!("failed to read {}: {e}", path.display()))?; + serde_json::from_str(&text).map_err(|e| format!("failed to parse {}: {e}", path.display())) +} + +fn read_runtime_install_manifest(runtime_dir: &Path) -> Option { + let text = fs::read_to_string(runtime_dir.join("runtime-manifest.json")).ok()?; + serde_json::from_str(&text).ok() +} + +fn validate_runtime_manifest(manifest: &RuntimeManifest) -> Result<(), String> { + if manifest.runtime_url.trim().is_empty() { + return Err("runtime manifest has an empty runtimeUrl".to_string()); + } + let sha = manifest.runtime_sha256.trim(); + if sha.len() != 64 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err("runtime manifest must include a 64-character runtimeSha256".to_string()); + } + Ok(()) +} + +fn runtime_archive_path(data_dir: &Path, manifest: &RuntimeManifest) -> PathBuf { + let name = manifest + .archive_name + .clone() + .or_else(|| manifest.runtime_url.rsplit('/').next().map(str::to_string)) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| format!("StemDeck-runtime-macOS-{}.tar.zst", manifest.arch)); + data_dir.join("downloads").join(name) +} + +async fn download_file_with_progress( + url: &str, + target: &Path, + app_handle: &tauri::AppHandle, +) -> Result<(), String> { + let tmp = target.with_extension("download"); + if tmp.exists() { + fs::remove_file(&tmp).map_err(|e| format!("failed to remove {}: {e}", tmp.display()))?; + } + + // file:// and bare-path shortcuts are development-only; not available in + // release builds so a compromised manifest cannot bypass the download (#136). + #[cfg(debug_assertions)] + if let Some(path) = url.strip_prefix("file://") { + fs::copy(Path::new(path), &tmp) + .map_err(|e| format!("failed to copy runtime pack from {url}: {e}"))?; + return fs::rename(&tmp, target) + .map_err(|e| format!("failed to move runtime pack to {}: {e}", target.display())); + } + #[cfg(debug_assertions)] + if Path::new(url).is_file() { + fs::copy(Path::new(url), &tmp) + .map_err(|e| format!("failed to copy runtime pack from {url}: {e}"))?; + return fs::rename(&tmp, target) + .map_err(|e| format!("failed to move runtime pack to {}: {e}", target.display())); + } + + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(15)) + .timeout(Duration::from_secs(30 * 60)) + .build() + .map_err(|e| format!("failed to build HTTP client: {e}"))?; + let mut response = client + .get(url) + .send() + .await + .map_err(|e| { + if e.is_connect() || e.is_timeout() { + format!("Could not reach the download server. Check your internet connection and try again. ({})", e) + } else { + format!("failed to start download from {url}: {e}") + } + })?; + if !response.status().is_success() { + return Err(format!( + "failed to download runtime pack from {url}: HTTP {}", + response.status() + )); + } + + let total = response.content_length(); + let mut file = + fs::File::create(&tmp).map_err(|e| format!("failed to create {}: {e}", tmp.display()))?; + let mut received: u64 = 0; + let mut last_emit = Instant::now(); + + while let Some(chunk) = response + .chunk() + .await + .map_err(|e| format!("download error: {e}"))? + { + file.write_all(&chunk) + .map_err(|e| format!("failed to write to {}: {e}", tmp.display()))?; + received += chunk.len() as u64; + if last_emit.elapsed() >= Duration::from_millis(150) { + let _ = app_handle.emit( + "runtime-download-progress", + DownloadProgress { received, total }, + ); + last_emit = Instant::now(); + } + } + let _ = app_handle.emit( + "runtime-download-progress", + DownloadProgress { + received, + total: Some(received), + }, + ); + + // Flush OS write cache before rename — guards against corrupt archive on + // Windows after power loss between close and rename. + file.sync_all() + .map_err(|e| format!("failed to flush {}: {e}", tmp.display()))?; + drop(file); + + fs::rename(&tmp, target) + .map_err(|e| format!("failed to move runtime pack to {}: {e}", target.display())) +} + +#[cfg(unix)] +fn download_file(url: &str, target: &Path, timeout: Duration) -> Result<(), String> { + let tmp = target.with_extension("download"); + if tmp.exists() { + fs::remove_file(&tmp).map_err(|e| format!("failed to remove {}: {e}", tmp.display()))?; + } + + // file:// and bare-path shortcuts are development-only (#136). + #[cfg(debug_assertions)] + if let Some(path) = url.strip_prefix("file://") { + fs::copy(Path::new(path), &tmp) + .map_err(|e| format!("failed to copy runtime pack from {url}: {e}"))?; + return fs::rename(&tmp, target) + .map_err(|e| format!("failed to move runtime pack to {}: {e}", target.display())); + } + #[cfg(debug_assertions)] + if Path::new(url).is_file() { + fs::copy(Path::new(url), &tmp) + .map_err(|e| format!("failed to copy runtime pack from {url}: {e}"))?; + return fs::rename(&tmp, target) + .map_err(|e| format!("failed to move runtime pack to {}: {e}", target.display())); + } + + { + let mut command = Command::new("curl"); + command + .args([ + "--fail", + "--location", + "--show-error", + "--output", + &tmp.display().to_string(), + "--", + url, + ]) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + let output = command_output_with_timeout(command, timeout, "runtime pack download")?; + if !output.status.success() || !tmp.is_file() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "failed to download runtime pack from {url}: {}", + stderr.trim() + )); + } + } + + fs::rename(&tmp, target) + .map_err(|e| format!("failed to move runtime pack to {}: {e}", target.display())) +} + +fn verify_runtime_archive( + manifest: &RuntimeManifest, + archive: &Path, +) -> Result { + if !archive.is_file() { + return Err(format!( + "runtime archive not found at {}", + archive.display() + )); + } + let size = archive + .metadata() + .map_err(|e| format!("failed to stat {}: {e}", archive.display()))? + .len(); + let sha256 = sha256_file(archive)?; + if !sha256.eq_ignore_ascii_case(manifest.runtime_sha256.trim()) { + let _ = fs::remove_file(archive); + return Err(format!( + "runtime archive checksum mismatch: expected {}, got {}", + manifest.runtime_sha256, sha256 + )); + } + Ok(RuntimeArchive { + archive_path: archive.display().to_string(), + sha256, + size, + }) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = + fs::File::open(path).map_err(|e| format!("failed to open {}: {e}", path.display()))?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 1024 * 64]; + loop { + let read = file + .read(&mut buffer) + .map_err(|e| format!("failed to read {}: {e}", path.display()))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn extract_tar_archive(archive: &Path, destination: &Path) -> Result<(), String> { + let file = fs::File::open(archive) + .map_err(|e| format!("failed to open archive {}: {e}", archive.display()))?; + let is_zst = archive + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("zst")); + if is_zst { + let decoder = + zstd::Decoder::new(file).map_err(|e| format!("failed to init zstd decoder: {e}"))?; + Archive::new(decoder) + .unpack(destination) + .map_err(|e| format!("failed to extract runtime pack: {e}")) + } else { + let decoder = GzDecoder::new(file); + Archive::new(decoder) + .unpack(destination) + .map_err(|e| format!("failed to extract runtime pack: {e}")) + } +} + +fn unix_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +fn app_root() -> Result { + if let Ok(root) = env::var("STEMDECK_ROOT") { + return Ok(PathBuf::from(root)); + } + if let Ok(cwd) = env::current_dir() { + if let Some(root) = find_repo_root(&cwd) { + return Ok(root); + } + } + let exe = env::current_exe().map_err(|e| format!("failed to resolve current exe: {e}"))?; + let exe_dir = exe + .parent() + .ok_or_else(|| "current exe has no parent directory".to_string())?; + if let Some(root) = find_repo_root(exe_dir) { + return Ok(root); + } + #[cfg(target_os = "macos")] + { + if let Some(contents) = exe_dir.parent() { + let resources = contents.join("Resources"); + if resources.is_dir() { + return Ok(resources); + } + } + } + Ok(exe_dir.to_path_buf()) +} + +fn find_repo_root(start: &Path) -> Option { + for candidate in start.ancestors() { + if candidate.join("pyproject.toml").is_file() && candidate.join("app").is_dir() { + return Some(candidate.to_path_buf()); + } + if candidate.join("backend").join("app").is_dir() && candidate.join("python").is_dir() { + return Some(candidate.to_path_buf()); + } + } + None +} + +fn backend_dir(root: &Path) -> Result { + if let Ok(data_dir) = local_data_dir() { + let backend = runtime_dir(&data_dir).join("backend"); + if backend.join("app").is_dir() { + return Ok(backend); + } + } + + let portable = root.join("backend"); + if portable.join("app").is_dir() { + return Ok(portable); + } + if root.join("app").is_dir() { + return Ok(root.to_path_buf()); + } + Err(format!( + "backend app directory not found under {}", + root.display() + )) +} + +/// Returns Some(PathBuf) if the env var is set and non-empty, None otherwise. +fn env_path_override(var: &str) -> Option { + env::var(var) + .ok() + .filter(|s| !s.is_empty()) + .map(PathBuf::from) +} + +fn python_path(root: &Path) -> Option { + #[cfg(debug_assertions)] + if let Some(p) = env_path_override("STEMDECK_PYTHON") { + return Some(p); + } + if let Ok(data_dir) = local_data_dir() { + let python = runtime_python_path(&data_dir); + if python.is_file() { + return Some(python); + } + } + let candidates = if cfg!(windows) { + vec![ + root.join("python").join("Scripts").join("python.exe"), + root.join(".venv").join("Scripts").join("python.exe"), + ] + } else { + vec![ + root.join("python").join("bin").join("python"), + root.join(".venv").join("bin").join("python"), + PathBuf::from("python3"), + ] + }; + candidates + .into_iter() + .find(|p| p.is_file()) + .or_else(|| Some(PathBuf::from("python3"))) +} + +fn ffmpeg_path(data_dir: &Path) -> Option { + if let Some(p) = env_path_override("STEMDECK_FFMPEG") { + return Some(p); + } + let file = if cfg!(windows) { + "ffmpeg.exe" + } else { + "ffmpeg" + }; + Some(data_dir.join("ffmpeg").join(file)) +} + +fn ffprobe_path(data_dir: &Path) -> PathBuf { + let file = if cfg!(windows) { + "ffprobe.exe" + } else { + "ffprobe" + }; + data_dir.join("ffmpeg").join(file) +} + +// Locate an FFmpeg binary that already exists on disk. Honors the STEMDECK_FFMPEG +// override, then checks the canonical flat location, then the `bin/` subfolder so a +// user who dropped an upstream FFmpeg build (which nests binaries under bin/) into +// data/ffmpeg/ is detected instead of triggering a download (#248). ffprobe lives +// alongside ffmpeg in every layout, so the returned parent dir suffices for PATH. +fn resolve_existing_ffmpeg(data_dir: &Path) -> Option { + if let Some(p) = env_path_override("STEMDECK_FFMPEG") { + return p.is_file().then_some(p); + } + let file = if cfg!(windows) { + "ffmpeg.exe" + } else { + "ffmpeg" + }; + let ffmpeg_root = data_dir.join("ffmpeg"); + [ffmpeg_root.join(file), ffmpeg_root.join("bin").join(file)] + .into_iter() + .find(|p| p.is_file()) +} + +fn ffmpeg_dir_if_present(data_dir: &Path) -> Option { + let path = resolve_existing_ffmpeg(data_dir)?; + path.parent().map(Path::to_path_buf) +} + +/// Bind to port 0 and return both the chosen port and the live listener. +/// Caller must hold the listener until just after the child process is +/// spawned, then drop it so the child can bind the same port. Holding the +/// socket until spawn narrows the TOCTOU window to a single OS context +/// switch rather than the entire command-setup period. +fn free_port() -> Result<(u16, TcpListener), String> { + let listener = + TcpListener::bind("127.0.0.1:0").map_err(|e| format!("port bind failed: {e}"))?; + let port = listener.local_addr().map_err(|e| e.to_string())?.port(); + Ok((port, listener)) +} + +/// The user's preferred port (Settings -> port), read from the backend's +/// settings.json before launch. Defaults to 8080. +fn configured_port() -> u16 { + const DEFAULT_PORT: u16 = 8000; + let Ok(data_dir) = local_data_dir() else { + return DEFAULT_PORT; + }; + let Ok(text) = fs::read_to_string(data_dir.join("settings.json")) else { + return DEFAULT_PORT; + }; + let Ok(json) = serde_json::from_str::(&text) else { + return DEFAULT_PORT; + }; + match json.get("port").and_then(serde_json::Value::as_u64) { + Some(p) if (1024..=65535).contains(&p) => p as u16, + _ => DEFAULT_PORT, + } +} + +/// Reserve the user's preferred port; fall back to any free port if it's taken, +/// so a port conflict can never block startup. +fn reserve_port(desired: u16) -> Result<(u16, TcpListener), String> { + if let Ok(listener) = TcpListener::bind(("127.0.0.1", desired)) { + let port = listener.local_addr().map_err(|e| e.to_string())?.port(); + return Ok((port, listener)); + } + free_port() +} + +fn wait_for_health(port: u16, timeout: Duration, log_path: &Path) -> Result<(), String> { + let deadline = Instant::now() + timeout; + let mut interval = Duration::from_millis(250); + loop { + if Instant::now() >= deadline { + let tail = file_tail(log_path, 30); + let hint = if tail.trim().is_empty() { + format!( + "No backend log output was captured at {}.", + log_path.display() + ) + } else { + format!( + "Last backend log lines from {}:\n{}", + log_path.display(), + tail + ) + }; + return Err(format!( + "backend did not become healthy within {} seconds.\n\n{}", + timeout.as_secs(), + hint + )); + } + if health_once(port).is_ok() { + return Ok(()); + } + thread::sleep(interval); + // Exponential backoff capped at 2 s to reduce busy-polling while + // still detecting fast startups quickly. + interval = (interval * 2).min(Duration::from_secs(2)); + } +} + +fn file_tail(path: &Path, max_lines: usize) -> String { + fs::read_to_string(path) + .map(|text| { + let lines: Vec<&str> = text.lines().rev().take(max_lines).collect(); + lines.into_iter().rev().collect::>().join("\n") + }) + .unwrap_or_default() +} + +fn health_once(port: u16) -> Result<(), String> { + let mut stream = TcpStream::connect(("127.0.0.1", port)).map_err(|e| e.to_string())?; + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .map_err(|e| e.to_string())?; + stream + .write_all(b"GET /api/health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n") + .map_err(|e| e.to_string())?; + let mut response = String::new(); + stream + .read_to_string(&mut response) + .map_err(|e| e.to_string())?; + if response.starts_with("HTTP/1.1 200") || response.starts_with("HTTP/1.0 200") { + Ok(()) + } else { + Err("health endpoint did not return 200".to_string()) + } +} + +fn ensure_ffmpeg(data_dir: &Path) -> Result { + // Use an already-present binary (flat, bin/, or STEMDECK_FFMPEG override) before + // downloading, so a manually-placed FFmpeg is honored (#248). + if let Some(existing) = resolve_existing_ffmpeg(data_dir) { + verify_ffmpeg(&existing)?; + return Ok(existing); + } + + #[cfg(windows)] + { + download_windows_ffmpeg(data_dir)?; + let portable = + ffmpeg_path(data_dir).ok_or_else(|| "failed to resolve FFmpeg path".to_string())?; + verify_ffmpeg(&portable)?; + return Ok(portable); + } + + #[cfg(target_os = "macos")] + { + download_macos_ffmpeg(data_dir)?; + let portable = + ffmpeg_path(data_dir).ok_or_else(|| "failed to resolve FFmpeg path".to_string())?; + verify_ffmpeg(&portable)?; + Ok(portable) + } + + #[cfg(all(unix, not(target_os = "macos")))] + { + // Prefer a system ffmpeg on PATH (dev installs, or users who already have + // one) so we skip the download entirely. Otherwise fetch a static build + // into data_dir/ffmpeg -- the shared config.json PATH plumbing then lets + // the Demucs subprocess find it too. + if verify_ffmpeg(Path::new("ffmpeg")).is_ok() { + return Ok(PathBuf::from("ffmpeg")); + } + download_linux_ffmpeg(data_dir)?; + let portable = + ffmpeg_path(data_dir).ok_or_else(|| "failed to resolve FFmpeg path".to_string())?; + verify_ffmpeg(&portable)?; + Ok(portable) + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn download_linux_ffmpeg(data_dir: &Path) -> Result<(), String> { + let url = env_path_override("STEMDECK_FFMPEG_URL") + .map(|p| p.display().to_string()) + .unwrap_or_else(|| DEFAULT_LINUX_FFMPEG_URL.to_string()); + let downloads = data_dir.join("downloads"); + fs::create_dir_all(&downloads) + .map_err(|e| format!("failed to create {}: {e}", downloads.display()))?; + let archive = downloads.join("ffmpeg-linux.tar.xz"); + download_file(&url, &archive, Duration::from_secs(30 * 60))?; + + // Extract with the system tar (xz support is standard on desktop Linux). The + // static build unpacks to a single ffmpeg--amd64-static/ directory. + let extract_dir = downloads.join("ffmpeg-linux"); + let _ = fs::remove_dir_all(&extract_dir); + fs::create_dir_all(&extract_dir) + .map_err(|e| format!("failed to create {}: {e}", extract_dir.display()))?; + let status = Command::new("tar") + .args([ + "-xJf", + &archive.display().to_string(), + "-C", + &extract_dir.display().to_string(), + ]) + .status() + .map_err(|e| format!("failed to run tar (is it installed?): {e}"))?; + if !status.success() { + let _ = fs::remove_file(&archive); + return Err("failed to extract the FFmpeg archive".to_string()); + } + + // The archive holds one top-level dir; ffmpeg + ffprobe live directly inside. + let inner = fs::read_dir(&extract_dir) + .map_err(|e| format!("failed to read {}: {e}", extract_dir.display()))? + .filter_map(Result::ok) + .map(|e| e.path()) + .find(|p| p.is_dir()) + .ok_or_else(|| "FFmpeg archive had no extracted directory".to_string())?; + + let ffmpeg_dir = data_dir.join("ffmpeg"); + fs::create_dir_all(&ffmpeg_dir) + .map_err(|e| format!("failed to create {}: {e}", ffmpeg_dir.display()))?; + for name in ["ffmpeg", "ffprobe"] { + let src = inner.join(name); + if !src.is_file() { + return Err(format!("{name} not found in the FFmpeg archive")); + } + let dest = ffmpeg_dir.join(name); + fs::copy(&src, &dest) + .map_err(|e| format!("failed to copy {name} to {}: {e}", dest.display()))?; + make_executable(&dest)?; + } + + let _ = fs::remove_dir_all(&extract_dir); + let _ = fs::remove_file(&archive); + Ok(()) +} + +// Pick the SHA256 to enforce for a download: the pinned hash when the URL is the +// built-in default, otherwise an explicit override hash from `env_var` if set, +// else None (skip). Mirrors the Windows path's "verify default, skip override". +#[cfg(target_os = "macos")] +fn expected_ffmpeg_sha256( + url: &str, + default_url: &str, + pinned_sha256: &str, + env_var: &str, +) -> Option { + if url == default_url { + return Some(pinned_sha256.to_ascii_lowercase()); + } + env::var(env_var) + .ok() + .map(|s| s.trim().to_ascii_lowercase()) + .filter(|s| !s.is_empty()) +} + +// Verify a freshly downloaded archive against an expected SHA256 before it is +// extracted or made executable (#172). On mismatch the file is removed so a +// corrupt or tampered binary is never run. `None` means no hash to enforce. +#[cfg(target_os = "macos")] +fn verify_pinned_sha256(path: &Path, expected: Option<&str>, label: &str) -> Result<(), String> { + let Some(expected) = expected else { + return Ok(()); + }; + let actual = sha256_file(path)?; + if !actual.eq_ignore_ascii_case(expected) { + let _ = fs::remove_file(path); + return Err(format!( + "{label} archive checksum mismatch (expected {expected}, got {actual}). \ + The download may be corrupt or tampered. Click Retry to try again." + )); + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn download_macos_ffmpeg(data_dir: &Path) -> Result<(), String> { + let ffmpeg_url = env_path_override("STEMDECK_FFMPEG_URL") + .map(|p| p.display().to_string()) + .unwrap_or_else(|| DEFAULT_MACOS_FFMPEG_URL.to_string()); + let ffprobe_url = env_path_override("STEMDECK_FFPROBE_URL") + .map(|p| p.display().to_string()) + .unwrap_or_else(|| DEFAULT_MACOS_FFPROBE_URL.to_string()); + // Verify the pinned hash for the default (built-in) URLs; for custom override + // URLs honour an explicit STEMDECK_FFMPEG_SHA256 / STEMDECK_FFPROBE_SHA256 when + // provided, otherwise skip (parity with the Windows override behaviour). + let ffmpeg_expected = expected_ffmpeg_sha256( + &ffmpeg_url, + DEFAULT_MACOS_FFMPEG_URL, + DEFAULT_MACOS_FFMPEG_SHA256, + "STEMDECK_FFMPEG_SHA256", + ); + let ffprobe_expected = expected_ffmpeg_sha256( + &ffprobe_url, + DEFAULT_MACOS_FFPROBE_URL, + DEFAULT_MACOS_FFPROBE_SHA256, + "STEMDECK_FFPROBE_SHA256", + ); + let downloads = data_dir.join("downloads"); + let ffmpeg_zip = downloads.join("ffmpeg-macos.zip"); + let ffprobe_zip = downloads.join("ffprobe-macos.zip"); + fs::create_dir_all(&downloads) + .map_err(|e| format!("failed to create {}: {e}", downloads.display()))?; + + download_file(&ffmpeg_url, &ffmpeg_zip, Duration::from_secs(30 * 60))?; + verify_pinned_sha256(&ffmpeg_zip, ffmpeg_expected.as_deref(), "FFmpeg")?; + download_file(&ffprobe_url, &ffprobe_zip, Duration::from_secs(30 * 60))?; + verify_pinned_sha256(&ffprobe_zip, ffprobe_expected.as_deref(), "ffprobe")?; + + let ffmpeg_dir = data_dir.join("ffmpeg"); + fs::create_dir_all(&ffmpeg_dir) + .map_err(|e| format!("failed to create {}: {e}", ffmpeg_dir.display()))?; + extract_single_binary_from_zip(&ffmpeg_zip, &ffmpeg_dir.join("ffmpeg"), "ffmpeg")?; + extract_single_binary_from_zip(&ffprobe_zip, &ffmpeg_dir.join("ffprobe"), "ffprobe")?; + + make_executable(&ffmpeg_dir.join("ffmpeg"))?; + make_executable(&ffmpeg_dir.join("ffprobe"))?; + Ok(()) +} + +#[cfg(target_os = "macos")] +fn extract_single_binary_from_zip( + archive_path: &Path, + target: &Path, + binary_name: &str, +) -> Result<(), String> { + let file = fs::File::open(archive_path) + .map_err(|e| format!("failed to open {}: {e}", archive_path.display()))?; + let mut archive = zip::ZipArchive::new(file) + .map_err(|e| format!("failed to read zip {}: {e}", archive_path.display()))?; + + for i in 0..archive.len() { + let mut entry = archive + .by_index(i) + .map_err(|e| format!("failed to read zip entry {i}: {e}"))?; + if !entry.is_file() { + continue; + } + let Some(name) = entry.enclosed_name() else { + continue; + }; + let Some(file_name) = name.file_name().and_then(|value| value.to_str()) else { + continue; + }; + if file_name != binary_name { + continue; + } + let mut output = fs::File::create(target) + .map_err(|e| format!("failed to create {}: {e}", target.display()))?; + std::io::copy(&mut entry, &mut output) + .map_err(|e| format!("failed to extract {}: {e}", target.display()))?; + return Ok(()); + } + + Err(format!( + "downloaded archive {} did not contain {binary_name}", + archive_path.display() + )) +} + +#[cfg(unix)] +fn make_executable(path: &Path) -> Result<(), String> { + let output = Command::new("chmod") + .args(["+x", &path.display().to_string()]) + .stderr(Stdio::piped()) + .output() + .map_err(|e| format!("failed to chmod {}: {e}", path.display()))?; + if output.status.success() { + Ok(()) + } else { + Err(format!( + "failed to chmod {}: {}", + path.display(), + String::from_utf8_lossy(&output.stderr).trim() + )) + } +} + +// Parse a combined `checksums.sha256` file (lines of ` `) and +// return the lowercased hash for `filename`, or None if it is not listed. +#[cfg(any(windows, test))] +fn sha256_from_checksums(contents: &str, filename: &str) -> Option { + contents.lines().find_map(|line| { + let mut parts = line.split_whitespace(); + let hash = parts.next()?; + let name = parts.next()?; + (name == filename).then(|| hash.to_ascii_lowercase()) + }) +} + +#[cfg(windows)] +fn download_windows_ffmpeg(data_dir: &Path) -> Result<(), String> { + let url = env_path_override("STEMDECK_FFMPEG_URL") + .map(|p| p.display().to_string()) + .unwrap_or_else(|| DEFAULT_WINDOWS_FFMPEG_URL.to_string()); + let is_default_url = url == DEFAULT_WINDOWS_FFMPEG_URL; + let downloads = data_dir.join("downloads"); + let archive_path = downloads.join("ffmpeg-windows.zip"); + fs::create_dir_all(&downloads) + .map_err(|e| format!("failed to create {}: {e}", downloads.display()))?; + + // Fetch BtbN's combined checksums.sha256 and pick the line for our archive (#135, #248). + // Only verified for the default URL; custom overrides skip the check. + let expected_sha256 = if is_default_url { + let archive_name = url + .rsplit('/') + .next() + .ok_or_else(|| "could not derive FFmpeg archive name from URL".to_string())?; + let sha256_tmp = downloads.join("ffmpeg-windows.checksums.sha256"); + download_file_blocking(DEFAULT_WINDOWS_FFMPEG_CHECKSUMS_URL, &sha256_tmp)?; + let raw = fs::read_to_string(&sha256_tmp) + .map_err(|e| format!("failed to read FFmpeg checksums: {e}"))?; + let _ = fs::remove_file(&sha256_tmp); + Some(sha256_from_checksums(&raw, archive_name).ok_or_else(|| { + format!("FFmpeg checksums file did not list an entry for {archive_name}") + })?) + } else { + None + }; + + download_file_blocking(&url, &archive_path)?; + + if let Some(expected) = expected_sha256 { + let actual = sha256_file(&archive_path)?; + if !actual.eq_ignore_ascii_case(&expected) { + let _ = fs::remove_file(&archive_path); + return Err(format!( + "FFmpeg archive checksum mismatch (expected {expected}, got {actual}). \ + The download may be corrupt. Click Retry to try again." + )); + } + } + + extract_ffmpeg_binaries(&archive_path, data_dir) +} + +#[cfg(windows)] +fn download_file_blocking(url: &str, target: &Path) -> Result<(), String> { + let tmp = target.with_extension("download"); + if tmp.exists() { + fs::remove_file(&tmp).map_err(|e| format!("failed to remove {}: {e}", tmp.display()))?; + } + + // NOTE: do not call this function from an async context — reqwest::blocking + // spawns its own tokio runtime and will panic with "Cannot start a runtime + // from within a runtime" if a tokio executor is already running on the thread. + let client = reqwest::blocking::Client::builder() + .connect_timeout(Duration::from_secs(15)) + .timeout(Duration::from_secs(30 * 60)) + .build() + .map_err(|e| format!("failed to build HTTP client: {e}"))?; + + let mut response = client.get(url).send().map_err(|e| { + if e.is_connect() || e.is_timeout() { + format!( + "Could not reach the download server. \ + Check your internet connection and try again. ({e})" + ) + } else { + format!("failed to start download from {url}: {e}") + } + })?; + + if !response.status().is_success() { + return Err(format!( + "failed to download from {url}: HTTP {}", + response.status() + )); + } + + let mut file = + fs::File::create(&tmp).map_err(|e| format!("failed to create {}: {e}", tmp.display()))?; + + response + .copy_to(&mut file) + .map_err(|e| format!("failed to write to {}: {e}", tmp.display()))?; + + // Flush OS write cache to disk before closing — guards against data loss if + // the process crashes or power is lost between close and rename. + file.sync_all() + .map_err(|e| format!("failed to flush {}: {e}", tmp.display()))?; + + // Explicitly drop the file handle before rename — Windows will not rename + // a file with an open handle. + drop(file); + + fs::rename(&tmp, target) + .map_err(|e| format!("failed to move download to {}: {e}", target.display())) +} + +#[cfg(windows)] +fn extract_ffmpeg_binaries(archive_path: &Path, data_dir: &Path) -> Result<(), String> { + let file = fs::File::open(archive_path) + .map_err(|e| format!("failed to open {}: {e}", archive_path.display()))?; + let mut archive = ZipArchive::new(file) + .map_err(|e| format!("failed to read FFmpeg zip {}: {e}", archive_path.display()))?; + let ffmpeg_dir = data_dir.join("ffmpeg"); + fs::create_dir_all(&ffmpeg_dir) + .map_err(|e| format!("failed to create {}: {e}", ffmpeg_dir.display()))?; + + let mut copied_ffmpeg = false; + let mut copied_ffprobe = false; + for i in 0..archive.len() { + let mut entry = archive + .by_index(i) + .map_err(|e| format!("failed to read FFmpeg zip entry {i}: {e}"))?; + if !entry.is_file() { + continue; + } + let Some(name) = entry.enclosed_name() else { + continue; + }; + let Some(file_name) = name.file_name().and_then(|value| value.to_str()) else { + continue; + }; + let target_name = match file_name.to_ascii_lowercase().as_str() { + "ffmpeg.exe" => { + copied_ffmpeg = true; + "ffmpeg.exe" + } + "ffprobe.exe" => { + copied_ffprobe = true; + "ffprobe.exe" + } + _ => continue, + }; + let target = ffmpeg_dir.join(target_name); + let mut output = fs::File::create(&target) + .map_err(|e| format!("failed to create {}: {e}", target.display()))?; + std::io::copy(&mut entry, &mut output) + .map_err(|e| format!("failed to extract {}: {e}", target.display()))?; + } + + if !copied_ffmpeg { + return Err("downloaded FFmpeg archive did not contain ffmpeg.exe".to_string()); + } + if !copied_ffprobe { + return Err("downloaded FFmpeg archive did not contain ffprobe.exe".to_string()); + } + Ok(()) +} + +fn verify_ffmpeg(path: &Path) -> Result<(), String> { + let mut command = Command::new(path); + command + .arg("-version") + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + hide_console_window(&mut command); + let output = command_output_with_timeout(command, Duration::from_secs(15), "FFmpeg check") + .map_err(|e| format!("failed to run FFmpeg at {}: {e}", path.display()))?; + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!( + "FFmpeg at {} failed verification: {}", + path.display(), + stderr.trim() + )) + } +} + +fn write_setup_config(data_dir: &Path, ffmpeg: &Path) -> Result<(), String> { + // ffprobe always sits next to the resolved ffmpeg (flat or bin/); fall back to + // the canonical flat location if ffmpeg has no parent. + let ffprobe_file = if cfg!(windows) { + "ffprobe.exe" + } else { + "ffprobe" + }; + let ffprobe = ffmpeg + .parent() + .map(|dir| dir.join(ffprobe_file)) + .unwrap_or_else(|| ffprobe_path(data_dir)); + let updated_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default(); + update_setup_config( + data_dir, + [ + ("setupVersion", serde_json::json!(SETUP_VERSION)), + ("ffmpegReady", serde_json::json!(true)), + ( + "ffmpegPath", + serde_json::json!(ffmpeg.display().to_string()), + ), + ("ffprobeReady", serde_json::json!(ffprobe.is_file())), + ( + "ffprobePath", + serde_json::json!(ffprobe.display().to_string()), + ), + ( + "ffmpegSource", + serde_json::json!(env::var("STEMDECK_FFMPEG_URL").unwrap_or_else(|_| { + if cfg!(windows) { + DEFAULT_WINDOWS_FFMPEG_URL.to_string() + } else if cfg!(target_os = "macos") { + DEFAULT_MACOS_FFMPEG_URL.to_string() + } else { + "system PATH".to_string() + } + })), + ), + ("updatedAt", serde_json::json!(updated_at)), + ], + ) +} + +fn update_setup_config( + data_dir: &Path, + entries: [(&str, serde_json::Value); N], +) -> Result<(), String> { + let config_path = data_dir.join("config.json"); + let mut config = fs::read_to_string(&config_path) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .filter(|value| value.is_object()) + .unwrap_or_else(|| serde_json::json!({})); + + let Some(object) = config.as_object_mut() else { + return Err("setup config is not a JSON object".to_string()); + }; + for (key, value) in entries { + object.insert(key.to_string(), value); + } + object + .entry("modelReady".to_string()) + .or_insert(serde_json::Value::Bool(false)); + + let body = serde_json::to_string_pretty(&config) + .map_err(|e| format!("failed to serialize setup config: {e}"))?; + + // Write atomically: temp file → sync → rename. A crash mid-write leaves the + // previous config intact rather than producing a truncated/empty file. + let tmp_path = config_path.with_extension("json.tmp"); + let mut tmp_file = fs::File::create(&tmp_path) + .map_err(|e| format!("failed to create {}: {e}", tmp_path.display()))?; + tmp_file + .write_all((body + "\n").as_bytes()) + .map_err(|e| format!("failed to write {}: {e}", tmp_path.display()))?; + tmp_file + .sync_all() + .map_err(|e| format!("failed to flush {}: {e}", tmp_path.display()))?; + drop(tmp_file); + fs::rename(&tmp_path, &config_path) + .map_err(|e| format!("failed to move config to {}: {e}", config_path.display())) +} + +/// Polls an already-spawned child until it exits or the timeout elapses. +/// Mirrors command_output_with_timeout but accepts a pre-spawned Child so the +/// caller can record the PID before waiting (e.g. to kill on window close). +fn child_output_with_timeout( + mut child: Child, + timeout: Duration, + label: &str, +) -> Result { + let deadline = Instant::now() + timeout; + loop { + if let Some(status) = child + .try_wait() + .map_err(|e| format!("failed to wait for {label}: {e}"))? + { + let mut stdout = Vec::new(); + if let Some(mut pipe) = child.stdout.take() { + let _ = pipe.read_to_end(&mut stdout); + } + let mut stderr = Vec::new(); + if let Some(mut pipe) = child.stderr.take() { + let _ = pipe.read_to_end(&mut stderr); + } + return Ok(Output { + status, + stdout, + stderr, + }); + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!( + "{label} timed out after {} seconds", + timeout.as_secs() + )); + } + thread::sleep(Duration::from_millis(100)); + } +} + +fn command_output_with_timeout( + mut command: Command, + timeout: Duration, + label: &str, +) -> Result { + let mut child = command + .spawn() + .map_err(|e| format!("failed to start {label}: {e}"))?; + let deadline = Instant::now() + timeout; + + loop { + if let Some(status) = child + .try_wait() + .map_err(|e| format!("failed to wait for {label}: {e}"))? + { + let mut stdout = Vec::new(); + if let Some(mut pipe) = child.stdout.take() { + let _ = pipe.read_to_end(&mut stdout); + } + + let mut stderr = Vec::new(); + if let Some(mut pipe) = child.stderr.take() { + let _ = pipe.read_to_end(&mut stderr); + } + + return Ok(Output { + status, + stdout, + stderr, + }); + } + + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!( + "{label} timed out after {} seconds", + timeout.as_secs() + )); + } + + thread::sleep(Duration::from_millis(100)); + } +} + +#[cfg(windows)] +fn hide_console_window(command: &mut Command) { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + command.creation_flags(CREATE_NO_WINDOW); +} + +#[cfg(not(windows))] +fn hide_console_window(_command: &mut Command) {} + +#[cfg(test)] +mod tests { + use std::fs; + use tempfile::TempDir; + + fn make_tmp() -> TempDir { + tempfile::tempdir().expect("failed to create temp dir") + } + + #[test] + fn version_mismatch_detected() { + let dir = make_tmp(); + let version_file = dir.path().join("last_version.txt"); + fs::write(&version_file, "0.4.0").unwrap(); + let last = fs::read_to_string(&version_file).unwrap_or_default(); + assert_ne!(last.trim(), "0.5.0-alpha.1"); + } + + #[test] + fn version_match_skips_cleanup() { + let dir = make_tmp(); + let version_file = dir.path().join("last_version.txt"); + let current = "0.5.0-alpha.1"; + fs::write(&version_file, current).unwrap(); + let last = fs::read_to_string(&version_file).unwrap_or_default(); + assert_eq!(last.trim(), current); // no cleanup should fire + } + + #[test] + fn migration_flag_gates_webkit_clear() { + let dir = make_tmp(); + let migration_flag = dir.path().join("store_migration_done"); + // Flag absent → cleanup must NOT fire on first upgrade + assert!(!migration_flag.exists()); + // Write flag + fs::write(&migration_flag, "").unwrap(); + // Flag present → cleanup CAN fire on subsequent upgrades + assert!(migration_flag.exists()); + } + + #[test] + fn version_file_write_failure_does_not_loop() { + // If version file can't be written we must NOT update it, + // so the next launch also skips cleanup (not a repeat wipe). + let dir = make_tmp(); + let version_file = dir.path().join("last_version.txt"); + fs::write(&version_file, "0.4.0").unwrap(); + // Simulate failure by checking: if write errors, last stays "0.4.0" + let result = fs::write(dir.path().join("readonly_dir/last_version.txt"), "0.5.0"); + assert!(result.is_err()); // the write failed + // Original file unchanged — next launch will see "0.4.0" != "0.5.0" again, + // but migration_flag is absent so no cleanup fires. Correct behavior. + let last = fs::read_to_string(&version_file).unwrap_or_default(); + assert_eq!(last.trim(), "0.4.0"); + } + + #[cfg(target_os = "macos")] + #[test] + fn clear_webkit_data_tolerates_missing_dirs() { + // Calling clear_webkit_data when the dirs don't exist must not panic. + // We can't safely delete real WebKit dirs in a test, but we can verify + // the function handles NotFound gracefully by checking the logic: + let tmp = make_tmp(); + let fake_webkit = tmp.path().join("WebKit").join("app.stemdeck.desktop"); + // Never created → remove_dir_all should return NotFound, which we ignore. + let result = fs::remove_dir_all(&fake_webkit); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::NotFound); + // clear_webkit_data suppresses NotFound — this is the correct behavior. + } + + // --- macOS FFmpeg checksum verification (#172) --- + + #[cfg(target_os = "macos")] + #[test] + fn verify_pinned_sha256_accepts_matching_hash() { + let dir = make_tmp(); + let f = dir.path().join("a.bin"); + fs::write(&f, b"hello").unwrap(); + // sha256("hello") + let sha = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; + assert!(super::verify_pinned_sha256(&f, Some(sha), "test").is_ok()); + assert!(f.exists(), "a valid download must be kept"); + } + + #[cfg(target_os = "macos")] + #[test] + fn verify_pinned_sha256_rejects_and_removes_on_mismatch() { + let dir = make_tmp(); + let f = dir.path().join("a.bin"); + fs::write(&f, b"hello").unwrap(); + let wrong = "0000000000000000000000000000000000000000000000000000000000000000"; + assert!(super::verify_pinned_sha256(&f, Some(wrong), "test").is_err()); + assert!(!f.exists(), "a tampered/corrupt download must be removed"); + } + + #[cfg(target_os = "macos")] + #[test] + fn verify_pinned_sha256_none_skips() { + let dir = make_tmp(); + let f = dir.path().join("a.bin"); + fs::write(&f, b"hello").unwrap(); + assert!(super::verify_pinned_sha256(&f, None, "test").is_ok()); + assert!(f.exists()); + } + + #[cfg(target_os = "macos")] + #[test] + fn expected_sha_pins_default_url_and_skips_unknown_override() { + // Default URL -> the pinned hash. + let got = super::expected_ffmpeg_sha256( + super::DEFAULT_MACOS_FFMPEG_URL, + super::DEFAULT_MACOS_FFMPEG_URL, + super::DEFAULT_MACOS_FFMPEG_SHA256, + "STEMDECK_FFMPEG_SHA256_TEST_UNSET_172", + ); + assert_eq!(got.as_deref(), Some(super::DEFAULT_MACOS_FFMPEG_SHA256)); + // Custom override URL with no override hash env set -> None (skip, + // matching the Windows override behaviour). + let none = super::expected_ffmpeg_sha256( + "https://example.com/custom.zip", + super::DEFAULT_MACOS_FFMPEG_URL, + super::DEFAULT_MACOS_FFMPEG_SHA256, + "STEMDECK_FFMPEG_SHA256_TEST_UNSET_172", + ); + assert!(none.is_none()); + } + + #[test] + fn sha256_from_checksums_picks_matching_line() { + let contents = "\ +c64a5bf0ce386059ca8898b975de96d9ca0abd2c4763929c9cb1d2f2c93a6694 ffmpeg-master-latest-win64-gpl.zip +3D26DD6B1AF970297D141531D2C651491C4F6043B95D8C68E2FEC3C141255FF7 ffmpeg-n8.1-latest-win64-gpl-8.1.zip +b6052160df96b31c9b1e33854a4dcda3d4b57641b880270f31736fb9f445d384 ffmpeg-n7.1-latest-win64-gpl-7.1.zip +"; + // Matching line -> lowercased hash. + assert_eq!( + super::sha256_from_checksums(contents, "ffmpeg-n8.1-latest-win64-gpl-8.1.zip") + .as_deref(), + Some("3d26dd6b1af970297d141531d2c651491c4f6043b95d8c68e2fec3c141255ff7") + ); + // A different asset is not matched. + assert_eq!( + super::sha256_from_checksums(contents, "ffmpeg-master-latest-win64-gpl.zip").as_deref(), + Some("c64a5bf0ce386059ca8898b975de96d9ca0abd2c4763929c9cb1d2f2c93a6694") + ); + // Absent filename -> None. + assert!(super::sha256_from_checksums(contents, "not-in-list.zip").is_none()); + // Extra whitespace between hash and name is tolerated. + assert_eq!( + super::sha256_from_checksums("abc123 only-one.zip\n", "only-one.zip").as_deref(), + Some("abc123") + ); + } + + #[test] + fn resolve_existing_ffmpeg_prefers_flat_then_bin() { + let name = if cfg!(windows) { + "ffmpeg.exe" + } else { + "ffmpeg" + }; + // Nothing present -> None. + let dir = make_tmp(); + assert!(super::resolve_existing_ffmpeg(dir.path()).is_none()); + + // Only a bin/ binary -> found in bin/. + let dir = make_tmp(); + let bin = dir.path().join("ffmpeg").join("bin"); + fs::create_dir_all(&bin).unwrap(); + fs::write(bin.join(name), b"x").unwrap(); + assert_eq!( + super::resolve_existing_ffmpeg(dir.path()), + Some(bin.join(name)) + ); + + // Both present -> flat wins. + let flat = dir.path().join("ffmpeg").join(name); + fs::write(&flat, b"x").unwrap(); + assert_eq!(super::resolve_existing_ffmpeg(dir.path()), Some(flat)); + } + + #[cfg(not(target_os = "macos"))] + #[test] + fn wheel_tag_routes_blackwell_to_cu128() { + // Blackwell (sm_120 / sm_100, major >= 10) -> cu128 regardless of the + // driver CUDA version. + assert_eq!(super::wheel_tag(Some("12.0"), "12.8"), "cu128"); + assert_eq!(super::wheel_tag(Some("10.0"), "12.4"), "cu128"); + // Non-Blackwell cards fall back to the CUDA-version heuristic. + assert_eq!(super::wheel_tag(Some("8.9"), "12.4"), "cu124"); + assert_eq!(super::wheel_tag(Some("8.6"), "12.1"), "cu121"); + assert_eq!(super::wheel_tag(Some("7.5"), "11.8"), "cu118"); + // Missing / unparseable compute capability also falls back. + assert_eq!(super::wheel_tag(None, "12.1"), "cu121"); + assert_eq!(super::wheel_tag(Some("N/A"), "12.4"), "cu124"); + } +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json new file mode 100644 index 0000000..fe34310 --- /dev/null +++ b/desktop/src-tauri/tauri.conf.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "StemDeck", + "version": "0.0.0", + "identifier": "app.stemdeck.desktop", + "build": { + "frontendDist": "../ui", + "beforeDevCommand": "", + "beforeBuildCommand": "" + }, + "app": { + "withGlobalTauri": true, + "windows": [ + { + "title": "StemDeck", + "width": 1280, + "height": 820, + "minWidth": 1024, + "minHeight": 720, + "backgroundColor": "#050b10", + "dragDropEnabled": false + } + ], + "security": { + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob:; connect-src 'self' ipc: http://ipc.localhost; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" + } + }, + "bundle": { + "active": true, + "targets": ["app"], + "icon": ["icons/icon.icns", "icons/icon.png", "icons/icon.ico"], + "macOS": { + "minimumSystemVersion": "13.0", + "dmg": { + "windowSize": { + "width": 660, + "height": 400 + }, + "appPosition": { + "x": 180, + "y": 170 + }, + "applicationFolderPosition": { + "x": 480, + "y": 170 + } + } + } + } +} diff --git a/desktop/ui/index.html b/desktop/ui/index.html new file mode 100644 index 0000000..59574c0 --- /dev/null +++ b/desktop/ui/index.html @@ -0,0 +1,40 @@ + + + + + + StemDeck Launcher + + + +
+
+
+

StemDeck

+ ALPHA +
+

Preparing the local audio engine, first run will take a while...

+ +
    +
  1. Setting up local runtime
  2. +
  3. Creating workspace
  4. +
  5. Checking FFmpeg
  6. +
  7. Configuring compute device
  8. +
  9. Checking AI separation model
  10. +
  11. Starting backend
  12. +
+ +

Starting setup...

+ + + +
+ +
+
+
+ + + diff --git a/desktop/ui/runtime-manifest.json b/desktop/ui/runtime-manifest.json new file mode 100644 index 0000000..b0f27c3 --- /dev/null +++ b/desktop/ui/runtime-manifest.json @@ -0,0 +1,8 @@ +{ + "version": "0.1.0-alpha.1", + "arch": "arm64", + "runtimeUrl": "", + "runtimeSha256": "", + "runtimeSize": 0, + "archiveName": "StemDeck-runtime-macOS-arm64.tar.zst" +} diff --git a/desktop/ui/setup.css b/desktop/ui/setup.css new file mode 100644 index 0000000..cc3cc7f --- /dev/null +++ b/desktop/ui/setup.css @@ -0,0 +1,215 @@ +:root { + color-scheme: dark; + --bg: #050b10; + --panel: #101a22; + --line: rgba(148, 163, 184, 0.2); + --ink: #f0f2f5; + --muted: #9aa3ad; + --gold: #d8a84a; + --ok: #54c568; + --err: #ef4f58; + font-family: + "JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} + +* { + box-sizing: border-box; +} + +body { + min-height: 100vh; + margin: 0; + background: + radial-gradient(circle at 80% 10%, rgba(216, 168, 74, 0.1), transparent 26rem), + radial-gradient(circle at 12% 92%, rgba(62, 141, 207, 0.09), transparent 24rem), + var(--bg); + color: var(--ink); +} + +.setup-shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 32px; +} + +.setup-card { + width: min(760px, 100%); + padding: 32px; + border: 1px solid var(--line); + border-radius: 14px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.045), transparent), var(--panel); + box-shadow: 0 20px 90px rgba(0, 0, 0, 0.34); +} + +.brand-row { + display: flex; + align-items: center; + gap: 12px; +} + +h1 { + margin: 0; + font-size: 32px; + letter-spacing: 0; +} + +.brand-row span { + padding: 3px 7px; + border: 1px solid rgba(216, 168, 74, 0.58); + border-radius: 7px; + color: #f1cf82; + font-size: 13px; +} + +.subtitle { + margin: 8px 0 26px; + color: var(--muted); +} + +.setup-steps { + display: grid; + gap: 12px; + margin: 0; + padding: 0; + list-style: none; +} + +.setup-steps li { + display: flex; + align-items: center; + gap: 12px; + min-height: 42px; + padding: 0 14px; + border: 1px solid rgba(148, 163, 184, 0.08); + border-radius: 8px; + background: rgba(3, 10, 15, 0.34); + color: rgba(154, 163, 173, 0.45); /* dimmed — queued */ + transition: color 240ms ease, border-color 240ms ease; +} + +/* pending: small hollow ring, barely visible */ +.setup-steps li::before { + content: ""; + flex: 0 0 auto; + width: 12px; + height: 12px; + border: 1.5px solid currentColor; + border-radius: 50%; + opacity: 0.5; +} + +/* active: gold spinning ring */ +.setup-steps li.active { + color: var(--gold); + border-color: rgba(216, 168, 74, 0.22); +} + +.setup-steps li.active::before { + opacity: 1; + border-color: var(--gold); + border-top-color: transparent; + animation: spin 750ms linear infinite; +} + +/* done: bright green ✓ */ +.setup-steps li.done { + color: var(--ok); + border-color: rgba(84, 197, 104, 0.18); +} + +.setup-steps li.done::before { + content: "✓"; + width: auto; + height: auto; + border: 0; + opacity: 1; + font-size: 14px; + line-height: 1; + animation: none; +} + +/* error: red ✗ */ +.setup-steps li.error { + color: var(--err); + border-color: rgba(239, 79, 88, 0.25); +} + +.setup-steps li.error::before { + content: "✗"; + width: auto; + height: auto; + border: 0; + opacity: 1; + font-size: 14px; + line-height: 1; + animation: none; +} + +.status { + margin: 24px 0 0; + color: #cbd2da; +} + +.progress-wrap { + height: 4px; + background: rgba(148, 163, 184, 0.15); + border-radius: 2px; + margin-top: 10px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: var(--gold); + border-radius: 2px; + width: 0%; + transition: width 0.15s linear; +} + +.progress-fill.indeterminate { + width: 35%; + animation: progress-slide 1.6s ease-in-out infinite; +} + +@keyframes progress-slide { + 0% { transform: translateX(-200%); } + 100% { transform: translateX(400%); } +} + +.details { + max-height: 180px; + overflow: auto; + margin: 16px 0 0; + padding: 12px; + border: 1px solid rgba(239, 79, 88, 0.25); + border-radius: 8px; + color: #ffd0d4; + background: rgba(239, 79, 88, 0.08); + white-space: pre-wrap; +} + +.actions { + margin-top: 20px; +} + +button { + min-height: 42px; + padding: 0 18px; + border: 1px solid rgba(216, 168, 74, 0.4); + border-radius: 8px; + background: rgba(216, 168, 74, 0.14); + color: #f1cf82; + font: inherit; + cursor: pointer; +} + +.hidden { + display: none; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/desktop/ui/setup.js b/desktop/ui/setup.js new file mode 100644 index 0000000..8e13e97 --- /dev/null +++ b/desktop/ui/setup.js @@ -0,0 +1,383 @@ +const { invoke } = window.__TAURI__.core; + +let _runtimeUnlisten = null; + +// The backend always binds all interfaces; network availability is gated live +// by the backend itself (Settings → "Make StemDeck available on your network"). +function startBackend() { + return invoke("start_backend"); +} + +const statusEl = document.getElementById("status"); +const detailsEl = document.getElementById("details"); +const retryBtn = document.getElementById("retry"); +const steps = [...document.querySelectorAll("[data-step]")]; + +function setStep(name, state) { + const el = steps.find((item) => item.dataset.step === name); + if (!el) return; + el.classList.remove("active", "done", "error"); + if (state) el.classList.add(state); +} + +function setStatus(message) { + statusEl.textContent = message; +} + +function showError(error, hint) { + for (const el of steps) { + if (el.classList.contains("active")) { + el.classList.replace("active", "error"); + } + } + setStatus("Setup could not complete."); + const msg = String(error?.message ?? error); + detailsEl.textContent = hint ? `${msg}\n\n→ ${hint}` : msg; + detailsEl.classList.remove("hidden"); + retryBtn.classList.remove("hidden"); +} + +async function runStep(name, fn) { + setStep(name, "active"); + try { + const result = await fn(); + setStep(name, "done"); + return result; + } catch (err) { + setStep(name, "error"); + throw err; + } +} + +function minDelay(ms) { + return Promise.all([ + new Promise((r) => setTimeout(r, ms)), + new Promise((r) => requestAnimationFrame(r)), + ]); +} + +function formatElapsed(startedAt) { + const seconds = Math.floor((Date.now() - startedAt) / 1000); + const minutes = Math.floor(seconds / 60); + const rest = seconds % 60; + return minutes > 0 ? `${minutes}m ${rest}s` : `${rest}s`; +} + +function startProgressStatus(messages) { + const startedAt = Date.now(); + let messageIndex = 0; + + const update = () => { + const elapsedSeconds = Math.floor((Date.now() - startedAt) / 1000); + while ( + messageIndex + 1 < messages.length && + elapsedSeconds >= messages[messageIndex + 1].afterSeconds + ) { + messageIndex += 1; + } + + setStatus(`${messages[messageIndex].text} Elapsed: ${formatElapsed(startedAt)}.`); + }; + + update(); + const timer = window.setInterval(update, 1000); + return () => window.clearInterval(timer); +} + +function isMac() { + return /mac/i.test(navigator.userAgentData?.platform ?? navigator.platform ?? ""); +} + +async function installRuntimePack(appRoot) { + const status = await invoke("runtime_pack_status"); + if (!status.manifestReady) { + throw Object.assign( + new Error(`Python runtime not found under ${appRoot}.`), + { hint: "Try reinstalling StemDeck. If the problem persists, check that your disk has at least 2 GB free." } + ); + } + + const progressWrap = document.getElementById("progress-wrap"); + const progressFill = document.getElementById("progress-fill"); + + // lastProgressAt is updated by the SSE handler below; only meaningful + // during a network download, so it is reset just before download starts. + let lastReceived = 0; + let lastProgressAt = Date.now(); + const STALL_WARN_MS = 30_000; + + // Guard against stacked listeners on rapid retry (#146): unlisten any + // previous registration before creating a new one. + if (_runtimeUnlisten) { _runtimeUnlisten(); _runtimeUnlisten = null; } + + const unlisten = await window.__TAURI__.event.listen( + "runtime-download-progress", + (event) => { + const { received, total } = event.payload; + if (received !== lastReceived) { + lastReceived = received; + lastProgressAt = Date.now(); + } + const mb = (received / 1e6).toFixed(0); + if (total && total > 0) { + const pct = Math.min(100, Math.round((received / total) * 100)); + progressFill.style.width = `${pct}%`; + progressFill.classList.remove("indeterminate"); + setStatus(`Downloading StemDeck runtime... ${mb} / ${(total / 1e6).toFixed(0)} MB`); + } else { + progressFill.classList.add("indeterminate"); + setStatus(`Downloading StemDeck runtime... ${mb} MB received`); + } + } + ); + _runtimeUnlisten = unlisten; + + progressWrap.classList.remove("hidden"); + progressFill.style.width = "0%"; + progressFill.classList.remove("indeterminate"); + + try { + let verified = false; + if (status.archiveReady) { + try { + setStatus("Runtime archive found locally, verifying..."); + progressWrap.classList.add("hidden"); + await invoke("verify_runtime_pack"); + verified = true; + } catch { + // Stale or corrupt archive — fall through to re-download + } + } + if (!verified) { + progressWrap.classList.remove("hidden"); + setStatus("Downloading StemDeck runtime..."); + + // Reset stall baseline when network download is actually about to start (#150). + lastProgressAt = Date.now(); + + // stallTimer starts here, not at top of function, so it doesn't fire + // during the local verify_runtime_pack path (#150). + // When a stall is detected it stops startProgressStatus first to avoid + // both timers writing setStatus concurrently (#149). + let stopSlowMsg = null; + const stallTimer = window.setInterval(() => { + const stallMs = Date.now() - lastProgressAt; + if (stallMs >= STALL_WARN_MS) { + if (stopSlowMsg) { stopSlowMsg(); stopSlowMsg = null; } + setStatus( + stallMs >= 60_000 + ? "Download appears unreachable — check your internet connection and click Retry." + : "Download seems slow or stalled — check your internet connection." + ); + } + }, 5_000); + + // startProgressStatus is assigned after stallTimer creation; the closure + // above captures stopSlowMsg by reference, so it sees the updated value. + stopSlowMsg = startProgressStatus([ + { afterSeconds: 0, text: "Downloading StemDeck runtime..." }, + { afterSeconds: 30, text: "Still downloading runtime... slow connection detected." }, + { afterSeconds: 90, text: "Still downloading... large file on a slow connection can take a few minutes." }, + ]); + + try { + await invoke("download_runtime_pack"); + } catch (err) { + throw Object.assign( + new Error(String(err)), + { hint: "Check your internet connection and click Retry. If the problem persists, try a different network." } + ); + } finally { + window.clearInterval(stallTimer); + if (stopSlowMsg) { stopSlowMsg(); } + } + progressWrap.classList.add("hidden"); + setStatus("Verifying StemDeck runtime..."); + await invoke("verify_runtime_pack"); + } + setStatus("Installing StemDeck runtime..."); + const installed = await invoke("extract_runtime_pack"); + if (!installed.runtimeReady) { + throw Object.assign( + new Error("Runtime install finished but Python/backend files were not found."), + { hint: "Your disk may be full or the archive may be corrupt. Free up space and click Retry to re-download." } + ); + } + } finally { + _runtimeUnlisten = null; + unlisten(); + progressWrap.classList.add("hidden"); + } +} + +async function runSetup() { + detailsEl.classList.add("hidden"); + retryBtn.classList.add("hidden"); + for (const step of steps) step.classList.remove("active", "done", "error"); + + try { + setStep("runtime", "active"); + setStatus("Checking Python runtime..."); + let [runtime] = await Promise.all([ + invoke("probe_runtime"), + minDelay(350), + ]); + + // Compare the installed runtime against the version this app bundle expects. + // On a DMG upgrade the old runtime is still fully "ready", so this MUST be + // checked before the early-return below -- otherwise setup starts the stale + // backend + frontend and the new release (e.g. new features, version) never + // takes effect until the runtime is manually cleared. + const runtimeStatus = await invoke("runtime_pack_status"); + const expectedVersion = runtimeStatus.manifest?.version; + const installedVersion = runtimeStatus.installedVersion; + // Mismatch when this build expects a version the installed runtime isn't. + // An unknown installedVersion (a runtime from a build that never recorded + // one) also counts, so an upgrade still refreshes it. Self-heals: after one + // refresh the install records the version and subsequent launches match. + const versionMismatch = Boolean(expectedVersion) && installedVersion !== expectedVersion; + + if (runtime.pythonReady && runtime.ffmpegReady && runtime.torchDevice && !versionMismatch) { + for (const step of steps) { + step.classList.remove("active", "error"); + if (step.dataset.step === "backend") { + step.classList.remove("done"); + } else { + step.classList.add("done"); + } + } + await runStep("backend", async () => { + setStatus("Runtime is ready. Starting StemDeck backend..."); + const backend = await startBackend(); + setStatus("Opening StemDeck..."); + window.location.replace(backend.url); + }); + return; + } + + if (!runtime.pythonReady || versionMismatch) { + if (versionMismatch) { + setStatus(`Updating runtime from ${installedVersion || "an older build"} to ${expectedVersion}...`); + } + await invoke("ensure_workspace"); + await installRuntimePack(runtime.appRoot); + runtime = await invoke("probe_runtime"); + if (!runtime.pythonReady) { + setStep("runtime", "error"); + throw Object.assign( + new Error(`Python runtime setup failed under: ${runtime.dataDir}`), + { hint: "Check that your disk has at least 2 GB free and click Retry. If it keeps failing, try reinstalling StemDeck." } + ); + } + } + setStep("runtime", "done"); + setStatus(`Python runtime found at ${runtime.pythonPath}`); + await minDelay(200); + + let gpuSummary = ""; + + await runStep("workspace", () => invoke("ensure_workspace")); + + if (runtime.ffmpegReady) { + setStep("ffmpeg", "done"); + } else { + await runStep("ffmpeg", async () => { + const stopProgress = startProgressStatus([ + { + afterSeconds: 0, + text: "Downloading FFmpeg... this can take a few minutes on first run.", + }, + { + afterSeconds: 60, + text: "Still downloading FFmpeg... slow networks or antivirus scans can delay this.", + }, + ]); + + try { + const assets = await invoke("ensure_external_assets"); + if (!assets.ffmpegReady) { + throw new Error( + "FFmpeg setup did not complete. Check your internet connection and retry." + ); + } + } finally { + stopProgress(); + } + }); + } + + await runStep("gpu", async () => { + const macGPU = isMac(); + const stopProgress = startProgressStatus( + macGPU + ? [ + { + afterSeconds: 0, + text: "Checking Apple Silicon compute support...", + }, + { + afterSeconds: 10, + text: "Verifying MPS acceleration for AI models...", + }, + ] + : [ + { + afterSeconds: 0, + text: "Checking NVIDIA GPU and compute support...", + }, + { + afterSeconds: 20, + text: "Installing NVIDIA acceleration if needed... first run can take 5-15 minutes.", + }, + { + afterSeconds: 120, + text: "Still installing NVIDIA acceleration... CUDA Torch packages are large.", + }, + { + afterSeconds: 300, + text: "Still working... setup should finish or time out automatically.", + }, + ] + ); + + try { + const gpu = await invoke("ensure_torch_device"); + if (macGPU) { + gpuSummary = + gpu.torchDevice === "mps" + ? `${gpu.gpuName} acceleration enabled` + : "MPS acceleration unavailable - stem separation will use CPU"; + } else { + if (gpu.gpuDetected && !gpu.cudaVerified) { + showError( + `GPU detected (${gpu.gpuName}) but CUDA setup failed - stem separation will use CPU.\nCheck logs/setup.log in the StemDeck data folder for details.` + ); + } + gpuSummary = gpu.gpuDetected + ? gpu.cudaVerified + ? `${gpu.gpuName} - CUDA ${gpu.cudaVersion} enabled` + : `${gpu.gpuName} found - falling back to CPU (CUDA unverified)` + : "No NVIDIA GPU - stem separation will use CPU"; + } + return gpu; + } finally { + stopProgress(); + } + }); + + setStep("model", "done"); + setStatus("AI separation model will download on first use (~340 MB)."); + + await runStep("backend", async () => { + setStatus(gpuSummary ? `${gpuSummary} - starting backend...` : "Starting StemDeck backend..."); + const backend = await startBackend(); + setStatus("Opening StemDeck..."); + window.location.replace(backend.url); + }); + } catch (error) { + showError(error, error?.hint); + } +} + +retryBtn.addEventListener("click", runSetup); +runSetup(); diff --git a/imgs/screenshot/stemdeck.png b/imgs/screenshot/stemdeck.png new file mode 100644 index 0000000..52e00a8 Binary files /dev/null and b/imgs/screenshot/stemdeck.png differ diff --git a/imgs/stemdeck-svg-assets/stemdeck-icon.svg b/imgs/stemdeck-svg-assets/stemdeck-icon.svg new file mode 100644 index 0000000..0ebce18 --- /dev/null +++ b/imgs/stemdeck-svg-assets/stemdeck-icon.svg @@ -0,0 +1,28 @@ + + StemDeck App Icon + Dark rounded square app icon with a golden segmented waveform. + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/imgs/stemdeck-svg-assets/stemdeck-logo-horizontal.svg b/imgs/stemdeck-svg-assets/stemdeck-logo-horizontal.svg new file mode 100644 index 0000000..b999fac --- /dev/null +++ b/imgs/stemdeck-svg-assets/stemdeck-logo-horizontal.svg @@ -0,0 +1,25 @@ + + StemDeck Horizontal Logo + StemDeck logo with golden waveform symbol and wordmark. + + + + + + + + + + + + + + + + + + + Stem + Deck + POWERED STEM SEPARATION + diff --git a/imgs/stemdeck-svg-assets/stemdeck-logo-stacked.svg b/imgs/stemdeck-svg-assets/stemdeck-logo-stacked.svg new file mode 100644 index 0000000..010c71f --- /dev/null +++ b/imgs/stemdeck-svg-assets/stemdeck-logo-stacked.svg @@ -0,0 +1,24 @@ + + StemDeck Stacked Logo + Stacked StemDeck logo with waveform symbol above the wordmark. + + + + + + + + + + + + + + + + + + + Stem Deck + POWERED STEM SEPARATION + diff --git a/imgs/stemdeck-svg-assets/stemdeck-tray-light.svg b/imgs/stemdeck-svg-assets/stemdeck-tray-light.svg new file mode 100644 index 0000000..7408017 --- /dev/null +++ b/imgs/stemdeck-svg-assets/stemdeck-tray-light.svg @@ -0,0 +1,12 @@ + + StemDeck Tray Icon Light + + + + + + + + + + diff --git a/imgs/stemdeck-svg-assets/stemdeck-tray-monochrome.svg b/imgs/stemdeck-svg-assets/stemdeck-tray-monochrome.svg new file mode 100644 index 0000000..bf2c320 --- /dev/null +++ b/imgs/stemdeck-svg-assets/stemdeck-tray-monochrome.svg @@ -0,0 +1,12 @@ + + StemDeck Tray Icon Monochrome + + + + + + + + + + diff --git a/imgs/stemdeck-svg-assets/stemdeck-waveform-symbol.svg b/imgs/stemdeck-svg-assets/stemdeck-waveform-symbol.svg new file mode 100644 index 0000000..cc3dc11 --- /dev/null +++ b/imgs/stemdeck-svg-assets/stemdeck-waveform-symbol.svg @@ -0,0 +1,22 @@ + + StemDeck Waveform Symbol + Golden segmented waveform symbol for StemDeck. + + + + + + + + + + + + + + + + + + + diff --git a/imgs/stemdeck-svg-assets/stemdeck-wordmark.svg b/imgs/stemdeck-svg-assets/stemdeck-wordmark.svg new file mode 100644 index 0000000..c7786f7 --- /dev/null +++ b/imgs/stemdeck-svg-assets/stemdeck-wordmark.svg @@ -0,0 +1,6 @@ + + StemDeck Wordmark + Stem + Deck + POWERED STEM SEPARATION + diff --git a/packaging/linux/README-LINUX.txt b/packaging/linux/README-LINUX.txt new file mode 100644 index 0000000..1529c43 --- /dev/null +++ b/packaging/linux/README-LINUX.txt @@ -0,0 +1,77 @@ +StemDeck Linux Portable Alpha +============================= + +This comes in two variants. Pick one: + +- StemDeck-Linux-x64.tar.gz CPU-only (smaller; runs anywhere) +- StemDeck-Linux-x64.NVIDIA.tar.gz NVIDIA/CUDA (larger; much faster on an + NVIDIA GPU, falls back to CPU if no GPU) + +Run +--- + +1. Extract the tarball, e.g.: + tar -xzf StemDeck-Linux-x64.tar.gz +2. Install the runtime prerequisites (see below). +3. Run the launcher: + cd StemDeck-Linux-x64 # or StemDeck-Linux-x64.NVIDIA + ./StemDeck +4. Let first-run setup prepare local runtime assets. + +Prerequisites +------------- + +This portable package bundles its own Python runtime (torch + demucs), and +StemDeck downloads FFmpeg automatically on first launch (or uses a system +`ffmpeg` if one is already on your PATH). The only system libraries you need +are your distro's WebKitGTK + GTK, which the desktop shell links against. + + Debian / Ubuntu: + sudo apt update + sudo apt install libwebkit2gtk-4.1-0 libgtk-3-0 + + Fedora: + sudo dnf install webkit2gtk4.1 gtk3 + + Arch: + sudo pacman -S webkit2gtk-4.1 gtk3 + +NVIDIA variant +-------------- + +To use the GPU you need a working NVIDIA driver on the host such that +`nvidia-smi` runs and reports your GPU. + + Check your driver: + nvidia-smi + +On first launch, the NVIDIA build detects your GPU and downloads the matching +CUDA-enabled PyTorch (a few GB) into your data directory — so the first run +needs an internet connection and some disk space. You do NOT need a separate +CUDA toolkit install, only the driver. + +If no usable GPU is detected, the NVIDIA build still runs and falls back to CPU. +If you do not have an NVIDIA GPU, use the CPU-only tarball instead — it skips +the CUDA download entirely. + +Notes +----- + +- This is a portable folder, not a system package. No .desktop entry, service, + or package-manager integration is created. +- User data lives under $XDG_DATA_HOME/stemdeck (or ~/.local/share/stemdeck). +- Your stem library is written to ~/Documents/StemDeck/. +- Demucs model weights download from the backend on first use into the data + directory under models/. + +Troubleshooting +--------------- + +- "./StemDeck: error while loading shared libraries" — install the WebKitGTK + and GTK packages listed above. +- A job failing immediately with an FFmpeg error — first-run setup downloads + FFmpeg automatically; check internet access and retry, or install a system + `ffmpeg` so `ffmpeg -version` works in your shell. +- If setup fails, check internet access and retry. +- Inspect logs under the data directory's logs/ folder. +- Deleting the data directory forces first-run setup to recreate runtime state. diff --git a/packaging/linux/THIRD_PARTY_NOTICES.txt b/packaging/linux/THIRD_PARTY_NOTICES.txt new file mode 100644 index 0000000..f7f3435 --- /dev/null +++ b/packaging/linux/THIRD_PARTY_NOTICES.txt @@ -0,0 +1,78 @@ +THIRD-PARTY NOTICES +=================== + +StemDeck includes third-party open-source software. Each component is +copyrighted by its respective authors and distributed under its own license. + +This starter notice is not a substitute for the full license inventory that +must be generated from the final packaged Python runtime before a public +release. + +Bundled Components +------------------ + +Python +License: Python Software Foundation License +Website: https://www.python.org/ + +Tauri +License: MIT or Apache-2.0, depending on component +Website: https://tauri.app/ + +FastAPI +License: MIT +Website: https://fastapi.tiangolo.com/ + +Uvicorn +License: BSD +Website: https://www.uvicorn.org/ + +yt-dlp +License: Unlicense +Website: https://github.com/yt-dlp/yt-dlp + +Demucs +License: MIT +Website: https://github.com/facebookresearch/demucs + +PyTorch / Torch +License: BSD-style +Website: https://pytorch.org/ + +torchaudio +License: BSD-style +Website: https://pytorch.org/audio/ + +librosa +License: ISC +Website: https://librosa.org/ + +pyloudnorm +License: MIT +Website: https://github.com/csteinmetz1/pyloudnorm + +soundfile +License: BSD +Website: https://github.com/bastibe/python-soundfile + +System Requirements (Not Bundled) +--------------------------------- + +FFmpeg is not bundled in the Linux package. On first launch StemDeck downloads a +static FFmpeg build into your user data directory (or uses a system `ffmpeg` +already on your PATH). The download is fetched directly from the upstream +provider, not redistributed by StemDeck. FFmpeg may be distributed under LGPL or +GPL terms depending on how the build was compiled. + +WebKitGTK and GTK shared libraries are provided by your Linux distribution and +are not bundled. They are distributed under LGPL terms. + +Demucs model weights are not bundled. They are downloaded during first use. +StemDeck should display the model source and license/usage terms before or +during download. + +Disclaimer +---------- + +This notice file is not legal advice. Before public release, verify the exact +licenses of every bundled package and generated binary artifact. diff --git a/packaging/macos/README-macOS.txt b/packaging/macos/README-macOS.txt new file mode 100644 index 0000000..a550295 --- /dev/null +++ b/packaging/macos/README-macOS.txt @@ -0,0 +1,31 @@ +StemDeck for macOS +================== + +Install: + +1. Open the StemDeck DMG. +2. Drag StemDeck.app to Applications. +3. Open StemDeck from Applications. + +First launch: + +- StemDeck is a thin native app. It downloads a pinned, checksummed StemDeck + runtime pack on first launch. +- The runtime installs to: + ~/Library/Application Support/StemDeck/runtime +- FFmpeg and ffprobe install to: + ~/Library/Application Support/StemDeck/ffmpeg +- Demucs model weights download on first use and are cached under: + ~/Library/Application Support/StemDeck/models + +Uninstall: + +1. Delete /Applications/StemDeck.app. +2. To remove runtime files, jobs, caches, models, and logs, delete: + ~/Library/Application Support/StemDeck + +Notes: + +- Internet access is required for first-run setup. +- Public releases should be signed and notarized. +- Unsigned local builds are for development and internal testing only. diff --git a/packaging/macos/THIRD_PARTY_NOTICES.txt b/packaging/macos/THIRD_PARTY_NOTICES.txt new file mode 100644 index 0000000..171b32c --- /dev/null +++ b/packaging/macos/THIRD_PARTY_NOTICES.txt @@ -0,0 +1,20 @@ +THIRD-PARTY NOTICES +=================== + +StemDeck for macOS uses a thin Tauri app plus a downloaded runtime pack. + +Bundled in StemDeck.app: + +- Tauri v2 +- The system WebKit WebView provided by macOS + +Downloaded during first-run setup: + +- StemDeck runtime pack containing Python and Python package dependencies +- FFmpeg and ffprobe +- Demucs model weights + +The runtime pack includes its own dependency inventory under +runtime/licenses/pip-list.json. Before public release, generate and include +full license texts for every packaged dependency and verify the exact FFmpeg +build license/provenance. diff --git a/packaging/macos/dmg-background.svg b/packaging/macos/dmg-background.svg new file mode 100644 index 0000000..5b7cb97 --- /dev/null +++ b/packaging/macos/dmg-background.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Drag StemDeck to Applications + diff --git a/packaging/windows/README-WINDOWS.txt b/packaging/windows/README-WINDOWS.txt new file mode 100644 index 0000000..968658b --- /dev/null +++ b/packaging/windows/README-WINDOWS.txt @@ -0,0 +1,25 @@ +StemDeck Windows Portable Alpha +=============================== + +Run +--- + +1. Extract the zip folder. +2. Double-click StemDeck.exe. +3. Let first-run setup prepare local runtime assets. + +Notes +----- + +- This is a portable folder, not an installer. +- No Start Menu shortcut, service, or registry integration is created. +- Generated files stay under data/. +- FFmpeg is downloaded during first-run setup into data/ffmpeg/. +- Demucs model weights are downloaded by the backend on first use into data/models/. + +Troubleshooting +--------------- + +- If setup fails, check internet access and retry. +- If a job fails, inspect data/jobs/ and data/logs/ when logs are added. +- Deleting data/ forces first-run setup to recreate runtime state. diff --git a/packaging/windows/THIRD_PARTY_NOTICES.txt b/packaging/windows/THIRD_PARTY_NOTICES.txt new file mode 100644 index 0000000..5e5294a --- /dev/null +++ b/packaging/windows/THIRD_PARTY_NOTICES.txt @@ -0,0 +1,74 @@ +THIRD-PARTY NOTICES +=================== + +StemDeck includes third-party open-source software. Each component is +copyrighted by its respective authors and distributed under its own license. + +This starter notice is not a substitute for the full license inventory that +must be generated from the final packaged Python runtime before a public +release. + +Bundled Components +------------------ + +Python +License: Python Software Foundation License +Website: https://www.python.org/ + +Tauri +License: MIT or Apache-2.0, depending on component +Website: https://tauri.app/ + +FastAPI +License: MIT +Website: https://fastapi.tiangolo.com/ + +Uvicorn +License: BSD +Website: https://www.uvicorn.org/ + +yt-dlp +License: Unlicense +Website: https://github.com/yt-dlp/yt-dlp + +Demucs +License: MIT +Website: https://github.com/facebookresearch/demucs + +PyTorch / Torch +License: BSD-style +Website: https://pytorch.org/ + +torchaudio +License: BSD-style +Website: https://pytorch.org/audio/ + +librosa +License: ISC +Website: https://librosa.org/ + +pyloudnorm +License: MIT +Website: https://github.com/csteinmetz1/pyloudnorm + +soundfile +License: BSD +Website: https://github.com/bastibe/python-soundfile + +Downloaded During First-Run Setup +--------------------------------- + +FFmpeg binaries are not bundled in the StemDeck alpha zip. They are downloaded +during first-run setup. FFmpeg builds may be distributed under LGPL or GPL +terms depending on how they are compiled. StemDeck should display the selected +FFmpeg build source and license before or during download. + +Demucs model weights are not bundled in the StemDeck alpha zip. They are +downloaded during first use. StemDeck should display the model source and +license/usage terms before or during download. + +Disclaimer +---------- + +This notice file is not legal advice. Before public release, verify the exact +licenses of every bundled package and generated binary artifact. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9fe8bf5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,103 @@ +[project] +name = "stemdeck" +# Version is derived from the git tag by hatch-vcs (see [tool.hatch.version]), +# so it is never hand-edited — `git tag vX.Y.Z` is the single source of truth. +# Builds between tags report a dev version (e.g. 0.7.0a5.dev3+g). (#169) +dynamic = ["version"] +description = "Paste a YouTube URL, get audio stems split into a DAW-style player." +requires-python = ">=3.10,<3.14" +dependencies = [ + "fastapi>=0.115,!=0.136.3", + "uvicorn[standard]>=0.30", + "yt-dlp>=2024.12.0", + # Pin below 4.1: demucs 4.1.0 (2026-07-11) added a dependency on `sphn`, a + # Rust/CMake native package with no Intel-macOS wheel, so the x64 macOS + # build compiles it from source and fails (CMake dropped <3.5 support). + # 4.0.1 is what every prior release shipped and what the torch/torchaudio + # pins below were written for. + "demucs>=4.0.1,<4.1", + # Pin torch/torchaudio to 2.6.x where available — torchaudio 2.7+ removed its built-in + # audio writer and now requires the separate `torchcodec` package, which + # has ABI issues with torch 2.11. Demucs 4.0.1 calls torchaudio.save() to + # write stems, so it breaks on 2.7+. + "torch>=2.6,<2.7; sys_platform != 'darwin' or platform_machine != 'x86_64'", + "torchaudio>=2.6,<2.7; sys_platform != 'darwin' or platform_machine != 'x86_64'", + # PyTorch does not publish macOS x86_64 wheels for 2.6.x. Keep Intel macOS + # on the last compatible line so the x64 runtime pack can still be built. + "torch>=2.2,<2.3; sys_platform == 'darwin' and platform_machine == 'x86_64'", + "torchaudio>=2.2,<2.3; sys_platform == 'darwin' and platform_machine == 'x86_64'", + # torchaudio 2.6 dispatches WAV writes through libsndfile via soundfile. + # Without this, demucs crashes on save with "Couldn't find appropriate + # backend to handle uri ... drums.wav". + "soundfile>=0.12", + # BPM + key analysis on the downloaded source. Pulls numpy/scipy/numba. + "librosa>=0.10", + # Current llvmlite releases no longer publish macOS x86_64 wheels, which + # makes Intel runtime builds require a full LLVM/CMake toolchain. Keep Intel + # macOS on a wheel-backed Numba line. + "numba>=0.61,<0.62; sys_platform == 'darwin' and platform_machine == 'x86_64'", + # Torch 2.2's Intel macOS wheel was built against NumPy 1.x. + "numpy<2; sys_platform == 'darwin' and platform_machine == 'x86_64'", + # ITU-R BS.1770 integrated-loudness (LUFS) measurement for the + # post-analysis loudness card. Pure Python, depends on scipy which + # librosa already pulls in. + "pyloudnorm>=0.1.1", + # FastAPI multipart/form-data (file uploads) unconditionally require + # python-multipart — it is not an optional extra. + "python-multipart>=0.0.9", + # CVE-2026-44431 / CVE-2026-44432: sensitive header forwarding and + # decompression-bomb bypass fixed in 2.7.0. urllib3 is a transitive + # dep via requests; pin floor to pull in the fix. + "urllib3>=2.7.0", + # Pure-Python QR code generator used by the /api/qr endpoint (server + # access QR codes in the desktop settings panel). + "segno>=1.6", +] + +[project.optional-dependencies] +dev = [ + "ruff>=0.6", + "pytest>=8", + "pytest-asyncio>=0.24", + "httpx>=0.27", +] + +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +# Derive the version from git tags. Writes app/_version.py at build time as a +# runtime fallback for environments without installed package metadata. +[tool.hatch.version] +source = "vcs" +# Used when the tag can't be reached (e.g. CI's shallow/tagless clone) so the +# build never hard-fails. Real builds derive from the tag or the pinned +# SETUPTOOLS_SCM_PRETEND_VERSION. (#169) +fallback-version = "0.0.0" + +[tool.hatch.build.hooks.vcs] +version-file = "app/_version.py" + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +[tool.ruff] +target-version = "py310" +line-length = 100 +# app/_version.py is generated by hatch-vcs at build/sync time — don't lint it. +exclude = ["jobs", ".run", "static/vendor", "app/_version.py"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "SIM"] +ignore = [ + "E501", # line too long -- format-check is the gate, not lint + "B008", # FastAPI uses Depends(...) in defaults + "SIM105", # try/except/pass is fine; contextlib.suppress is heavier for one-shot warm-up imports +] + +[tool.ruff.lint.isort] +split-on-trailing-comma = false + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..826ab1e --- /dev/null +++ b/run.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# stemdeck dev server control: setup | start | stop | restart | status + +set -euo pipefail + +cd "$(dirname "$0")" + +HOST="${HOST:-0.0.0.0}" +PORT="${PORT:-8000}" +RELOAD="${RELOAD:-0}" +# Treat the self-hosted server as a persistent, user-managed library (like the +# desktop app): opt out of the 24h job TTL sweep so processed tracks are not +# auto-deleted. Override with STEMDECK_PERSIST_LIBRARY=0 for disk-hygiene mode. +export STEMDECK_PERSIST_LIBRARY="${STEMDECK_PERSIST_LIBRARY:-1}" +FOREGROUND="${FOREGROUND:-0}" +PID_FILE=".run/uvicorn.pid" +LOG_FILE=".run/uvicorn.log" +UVICORN=".venv/bin/uvicorn" + +mkdir -p .run + +is_running() { + [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null +} + +start() { + if is_running; then + echo "already running (pid $(cat "$PID_FILE"))" + return 0 + fi + if [[ ! -x "$UVICORN" ]]; then + echo "uvicorn not found at $UVICORN — run: uv sync" >&2 + exit 1 + fi + echo "starting on http://$HOST:$PORT" + local args=(app.main:app --host "$HOST" --port "$PORT") + if [[ "$RELOAD" == "1" ]]; then + args+=(--reload) + fi + if [[ "$FOREGROUND" == "1" ]]; then + echo $$ >"$PID_FILE" + exec "$UVICORN" "${args[@]}" + fi + nohup "$UVICORN" "${args[@]}" >"$LOG_FILE" 2>&1 & + echo $! >"$PID_FILE" + for _ in {1..10}; do + sleep 0.5 + grep -q "Application startup complete\|Uvicorn running on" "$LOG_FILE" 2>/dev/null && break + is_running || break + done + if is_running; then + echo "started (pid $(cat "$PID_FILE"), log: $LOG_FILE)" + else + echo "failed to start — see $LOG_FILE" >&2 + exit 1 + fi +} + +stop() { + if ! is_running; then + echo "not running" + # also sweep any stray uvicorn for this app + pkill -f "uvicorn app.main:app" 2>/dev/null || true + rm -f "$PID_FILE" + return 0 + fi + local pid + pid=$(cat "$PID_FILE") + echo "stopping pid $pid" + kill "$pid" 2>/dev/null || true + for _ in {1..10}; do + kill -0 "$pid" 2>/dev/null || break + sleep 0.5 + done + if kill -0 "$pid" 2>/dev/null; then + echo "force-killing pid $pid" + kill -9 "$pid" 2>/dev/null || true + fi + # kill any in-flight demucs children spawned by the app + pkill -f "python -m demucs" 2>/dev/null || true + rm -f "$PID_FILE" + echo "stopped" +} + +status() { + if is_running; then + echo "running (pid $(cat "$PID_FILE")) on http://$HOST:$PORT" + else + echo "not running" + fi +} + +setup() { + local os + case "$(uname -s)" in + Darwin) os="macos" ;; + Linux) os="linux" ;; + *) + echo "setup: unsupported OS '$(uname -s)' — install ffmpeg + uv manually" >&2 + exit 1 + ;; + esac + echo "detected: $os" + + if [[ "$os" == "macos" ]]; then + if ! command -v brew >/dev/null 2>&1; then + echo "setup: Homebrew not found — install from https://brew.sh first" >&2 + exit 1 + fi + if ! command -v ffmpeg >/dev/null 2>&1; then + echo "==> brew install ffmpeg" + brew install ffmpeg + else + echo "ffmpeg: already installed ($(ffmpeg -version | head -n1))" + fi + if ! command -v uv >/dev/null 2>&1; then + echo "==> brew install uv" + brew install uv + else + echo "uv: already installed ($(uv --version))" + fi + else + # linux: assume Debian/Ubuntu (apt). Other distros fall through to manual. + if ! command -v apt-get >/dev/null 2>&1; then + echo "setup: non-apt Linux detected — install ffmpeg + uv via your package manager" >&2 + exit 1 + fi + if ! command -v ffmpeg >/dev/null 2>&1; then + echo "==> sudo apt-get update && sudo apt-get install -y ffmpeg" + sudo apt-get update + sudo apt-get install -y ffmpeg + else + echo "ffmpeg: already installed ($(ffmpeg -version | head -n1))" + fi + if ! command -v uv >/dev/null 2>&1; then + echo "==> installing uv via astral.sh installer" + curl -LsSf https://astral.sh/uv/install.sh | sh + # installer drops uv in ~/.local/bin; surface it for this shell + export PATH="$HOME/.local/bin:$PATH" + else + echo "uv: already installed ($(uv --version))" + fi + fi + + echo "==> uv sync" + uv sync --python 3.12 + + echo + echo "setup complete. start the server with: ./run.sh start" +} + +case "${1:-}" in + setup) setup ;; + start) start ;; + stop) stop ;; + restart) stop; start ;; + status) status ;; + *) + echo "usage: $0 {setup|start|stop|restart|status}" >&2 + exit 2 + ;; +esac diff --git a/scripts/linux/make-portable.sh b/scripts/linux/make-portable.sh new file mode 100755 index 0000000..0af7e20 --- /dev/null +++ b/scripts/linux/make-portable.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +# +# Build a portable Linux StemDeck package: a single .tar.gz containing the +# Tauri binary plus a self-contained Python runtime (torch + demucs), so the +# user extracts and runs ./StemDeck with no toolchain. +# +# This is the Linux analog of scripts/windows/make-portable.ps1. Like the macOS +# runtime pack (scripts/macos/make-runtime-pack.sh) it bundles a full +# python-build-standalone install — a plain `venv` will not work because the +# desktop shell checks for the stdlib under python/lib/ (python_stdlib_present +# in desktop/src-tauri/src/main.rs). +# +# Phase 1 ships the CPU-only variant. FFmpeg is NOT bundled in the tarball (so we +# don't redistribute it); instead the desktop shell downloads a static build on +# first launch into the user data dir, falling back to a system `ffmpeg` on PATH +# when one exists (see ensure_ffmpeg / download_linux_ffmpeg). +# +# Layout produced (so find_repo_root matches its backend/app + python branch): +# StemDeck-Linux-x64/ +# StemDeck # Tauri ELF binary +# cpu-only # marker read by is_cpu_only_package +# README-LINUX.txt +# THIRD_PARTY_NOTICES.txt +# backend/{app,static,pyproject.toml,uv.lock} +# python/{bin/python,lib/pythonX.Y/...} # full PBS install + +set -euo pipefail + +PACKAGE_NAME="${PACKAGE_NAME:-StemDeck-Linux-x64}" +PACKAGE_VERSION="${PACKAGE_VERSION:-}" +OUTPUT_ROOT="${OUTPUT_ROOT:-dist}" +PYTHON_VERSION="${PYTHON_VERSION:-3.12}" +TORCH_VERSION="${TORCH_VERSION:-2.6.0}" +SKIP_TAURI_BUILD="${SKIP_TAURI_BUILD:-0}" +# CPU_ONLY=1 (default): force the CPU-only torch wheel and mark the package so the +# desktop shell skips GPU detection. CPU_ONLY=0: keep the project's default torch, +# which on Linux x86_64 is the CUDA build (NVIDIA variant) — the shell then detects +# the GPU and uses CUDA at runtime. +CPU_ONLY="${CPU_ONLY:-1}" + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +STAGE="${REPO_ROOT}/${OUTPUT_ROOT}/${PACKAGE_NAME}" +ARCHIVE_PATH="${REPO_ROOT}/${OUTPUT_ROOT}/${PACKAGE_NAME}.tar.gz" +CHECKSUM_PATH="${ARCHIVE_PATH}.sha256" +PYTHON_DIR="${STAGE}/python" +BACKEND_DIR="${STAGE}/backend" +TARGET_BIN="${REPO_ROOT}/desktop/src-tauri/target/release/stemdeck" + +if [[ "$(uname -s)" != "Linux" ]]; then + echo "ERROR: this packaging script must run on Linux." >&2 + exit 1 +fi + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "ERROR: required command not found on PATH: $1" >&2 + exit 1 + fi +} + +require_command uv +require_command cargo +require_command node +require_command npm +require_command tar +require_command sha256sum + +# python-build-standalone (PBS) Python for x86_64 Linux. Unlike a venv, the +# full install carries its own stdlib under lib/, which the desktop shell needs. +echo "==> Installing python-build-standalone ${PYTHON_VERSION}" +uv python install "cpython-${PYTHON_VERSION}-linux-x86_64-gnu" +PBS_PYTHON="$(uv python find "cpython-${PYTHON_VERSION}-linux-x86_64-gnu")" +PBS_BASE_PREFIX="$("$PBS_PYTHON" -c 'import sys; print(sys.base_prefix)')" +if [[ ! -d "${PBS_BASE_PREFIX}/lib" ]]; then + echo "ERROR: PBS base prefix has no lib/ dir: ${PBS_BASE_PREFIX}" >&2 + exit 1 +fi + +echo "==> Cleaning stage" +rm -rf "$STAGE" "$ARCHIVE_PATH" "$CHECKSUM_PATH" +mkdir -p "$STAGE" "$BACKEND_DIR" "$PYTHON_DIR" + +# Copy the entire PBS install into python/ (-a preserves symlinks/permissions). +echo "==> Bundling Python runtime from ${PBS_BASE_PREFIX}" +cp -a "$PBS_BASE_PREFIX/." "$PYTHON_DIR/" +# PBS ships an EXTERNALLY-MANAGED marker that blocks installs into the copy. +find "$PYTHON_DIR/lib" -name "EXTERNALLY-MANAGED" -delete 2>/dev/null || true + +BUNDLED_PYTHON="${PYTHON_DIR}/bin/python" + +echo "==> Installing StemDeck into bundled Python" +# --system is required because python/ is a full PBS install, not a venv. +uv pip install --system --python "$BUNDLED_PYTHON" pip setuptools wheel +# Version is git-derived (hatch-vcs / setuptools-scm). Pin it so the install +# does not depend on git tags in the build checkout (#169). +if [[ -n "$PACKAGE_VERSION" ]]; then + export SETUPTOOLS_SCM_PRETEND_VERSION="${PACKAGE_VERSION#v}" +fi +uv pip install --system --python "$BUNDLED_PYTHON" "$REPO_ROOT" + +# Always bake the small CPU-only torch wheel — for BOTH variants. On Linux the +# default PyPI torch wheel bundles the full CUDA runtime (~2.5 GB), which makes +# the packaged tarball exceed GitHub's 2 GiB per-asset release limit. So we +# mirror what the Windows NVIDIA package actually does: ship CPU torch, and let +# the desktop shell download the matching CUDA wheel at first run on GPU +# machines (install_cuda_torch, gated cfg(not(macos)) so it covers Linux). The +# NVIDIA variant differs only by omitting the cpu-only marker below. +# +# pip strips the local '+cpu' version when resolving, so the project install +# pulls the CUDA wheel even if a CPU wheel was requested; --force-reinstall +# --no-deps replaces just the torch/torchaudio wheels (proven on Windows). +echo "==> Baking CPU-only torch (NVIDIA variant downloads CUDA at first run)" +"$BUNDLED_PYTHON" -m pip install \ + "torch==${TORCH_VERSION}+cpu" "torchaudio==${TORCH_VERSION}+cpu" \ + --index-url https://download.pytorch.org/whl/cpu \ + --force-reinstall --no-deps + +# The project install above pulled the default Linux torch, which is the CUDA +# build, dragging in nvidia-* CUDA runtime packages (cuDNN, cuBLAS, NCCL, ...) and +# triton -- together ~2.5 GB. The CPU torch swap used --no-deps, so those packages +# are now orphaned but still installed, bloating the tarball past GitHub's 2 GiB +# asset limit. Remove them: CPU torch does not use them, and the NVIDIA variant +# re-downloads CUDA at first run anyway. +echo "==> Removing orphaned CUDA runtime packages" +orphans=$("$BUNDLED_PYTHON" -m pip list --format=freeze 2>/dev/null \ + | sed -n 's/^\(nvidia-[^=]*\)==.*/\1/p') +orphans="$orphans triton" +echo " removing:$orphans" +"$BUNDLED_PYTHON" -m pip uninstall -y $orphans 2>/dev/null || true + +echo "==> Verifying imports" +"$BUNDLED_PYTHON" -c "import fastapi, uvicorn, yt_dlp, demucs, torch, torchaudio, librosa, pyloudnorm, soundfile; print('torch', torch.__version__, 'cuda', torch.version.cuda)" + +echo "==> Staging backend" +cp -R "$REPO_ROOT/app" "$BACKEND_DIR/app" +cp -R "$REPO_ROOT/static" "$BACKEND_DIR/static" +cp "$REPO_ROOT/pyproject.toml" "$BACKEND_DIR/pyproject.toml" +cp "$REPO_ROOT/uv.lock" "$BACKEND_DIR/uv.lock" +RESOLVED_VERSION="${PACKAGE_VERSION#v}" +printf '{ "version": "%s" }\n' "$RESOLVED_VERSION" > "$BACKEND_DIR/static/version.json" + +cp "$REPO_ROOT/packaging/linux/README-LINUX.txt" "$STAGE/README-LINUX.txt" +cp "$REPO_ROOT/packaging/linux/THIRD_PARTY_NOTICES.txt" "$STAGE/THIRD_PARTY_NOTICES.txt" + +# CPU-only marker: read by is_cpu_only_package so the shell skips GPU detection. +# Omitted for the NVIDIA variant so the shell detects the GPU and uses CUDA. +if [[ "$CPU_ONLY" == "1" ]]; then + touch "$STAGE/cpu-only" +fi + +echo "==> Stripping build-time artifacts from bundled Python" +find "$PYTHON_DIR" -type d -name "__pycache__" -prune -exec rm -rf {} + 2>/dev/null || true +find "$PYTHON_DIR" -type f \( -name "*.pyc" -o -name "*.pyo" \) -delete 2>/dev/null || true +TORCH_LIB="${PYTHON_DIR}/lib/python${PYTHON_VERSION}/site-packages/torch" +for rel in include test share/cmake; do + rm -rf "${TORCH_LIB:?}/${rel}" 2>/dev/null || true +done +# Static link archives are only needed to build C++ extensions, never to run. +find "$TORCH_LIB" -name "*.a" -type f -delete 2>/dev/null || true + +echo "==> Building Tauri desktop binary" +if [[ "$SKIP_TAURI_BUILD" != "1" ]]; then + pushd "$REPO_ROOT/desktop" >/dev/null + if [[ -f package-lock.json ]]; then + npm ci --include=dev + else + npm install --include=dev + fi + CI=true node node_modules/@tauri-apps/cli/tauri.js build + popd >/dev/null +fi + +if [[ ! -f "$TARGET_BIN" ]]; then + echo "ERROR: Tauri binary not found at ${TARGET_BIN}" >&2 + exit 1 +fi +cp "$TARGET_BIN" "$STAGE/StemDeck" +chmod +x "$STAGE/StemDeck" + +echo "==> Creating archive" +tar -czf "$ARCHIVE_PATH" -C "${REPO_ROOT}/${OUTPUT_ROOT}" "$PACKAGE_NAME" +( cd "${REPO_ROOT}/${OUTPUT_ROOT}" && sha256sum "${PACKAGE_NAME}.tar.gz" > "${PACKAGE_NAME}.tar.gz.sha256" ) + +echo "==> Done" +if [[ "$CPU_ONLY" == "1" ]]; then + echo "Variant : CPU-only" +else + echo "Variant : NVIDIA/CUDA" +fi +echo "Stage : ${STAGE}" +echo "Archive : ${ARCHIVE_PATH}" +echo "Checksum: ${CHECKSUM_PATH}" diff --git a/scripts/macos/make-app.sh b/scripts/macos/make-app.sh new file mode 100755 index 0000000..61fb095 --- /dev/null +++ b/scripts/macos/make-app.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +ARCH="${ARCH:-arm64}" +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +# Default the version to the current git tag so local builds match the tag with +# no VERSION passed; falls back to a dev label outside a tagged checkout. (#169) +VERSION="${VERSION:-$(git -C "$REPO_ROOT" describe --tags --always 2>/dev/null || echo 0.0.0-dev)}" +VERSION="${VERSION#v}" +BUILD_DIR="${REPO_ROOT}/.build" + +if [[ "$(uname)" != "Darwin" ]]; then + echo "ERROR: make-app.sh must run on macOS" >&2 + exit 1 +fi + +if [[ "$ARCH" != "arm64" && "$ARCH" != "x64" ]]; then + echo "ERROR: ARCH must be arm64 or x64, got '${ARCH}'" >&2 + exit 1 +fi + +for cmd in node npm cargo; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "ERROR: required command not found on PATH: $cmd" >&2 + exit 1 + fi +done + +mkdir -p "$BUILD_DIR" + +echo "==> Stamping version ${VERSION}" +sed -i '' "s/^version = \".*\"/version = \"${VERSION}\"/" "$REPO_ROOT/desktop/src-tauri/Cargo.toml" +sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" "$REPO_ROOT/desktop/src-tauri/tauri.conf.json" +sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" "$REPO_ROOT/desktop/package.json" + +cd "$REPO_ROOT/desktop" + +# Tauri CLI reads CI env var as a boolean flag; Woodpecker sets CI=woodpecker +# which fails the boolean parser. Force a valid value. +export CI=true + +npm ci +if [[ "$ARCH" == "arm64" ]]; then + npx tauri build --bundles app --target aarch64-apple-darwin + TARGET_DIR="$REPO_ROOT/desktop/src-tauri/target/aarch64-apple-darwin/release" +else + npx tauri build --bundles app --target x86_64-apple-darwin + TARGET_DIR="$REPO_ROOT/desktop/src-tauri/target/x86_64-apple-darwin/release" +fi + +APP_DIR="${TARGET_DIR}/bundle/macos/StemDeck.app" +if [[ ! -d "$APP_DIR" ]]; then + APP_DIR="$(find "$REPO_ROOT/desktop/src-tauri/target" -path '*/bundle/macos/StemDeck.app' -type d | head -1)" +fi +if [[ -z "$APP_DIR" || ! -d "$APP_DIR" ]]; then + echo "ERROR: could not find built StemDeck.app" >&2 + exit 1 +fi + +RESOURCES="${APP_DIR}/Contents/Resources" +mkdir -p "$RESOURCES" + +if [[ -f "$BUILD_DIR/runtime-manifest-${ARCH}.json" ]]; then + cp "$BUILD_DIR/runtime-manifest-${ARCH}.json" "$RESOURCES/runtime-manifest.json" +else + cp "$REPO_ROOT/desktop/ui/runtime-manifest.json" "$RESOURCES/runtime-manifest.json" +fi + +if [[ -f "$REPO_ROOT/packaging/macos/THIRD_PARTY_NOTICES.txt" ]]; then + cp "$REPO_ROOT/packaging/macos/THIRD_PARTY_NOTICES.txt" "$RESOURCES/THIRD_PARTY_NOTICES.txt" +fi + +echo "$APP_DIR" > "$BUILD_DIR/app-path-${ARCH}.txt" +echo "==> App ready: $APP_DIR" diff --git a/scripts/macos/make-dmg.sh b/scripts/macos/make-dmg.sh new file mode 100755 index 0000000..834fb8f --- /dev/null +++ b/scripts/macos/make-dmg.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +set -euo pipefail + +ARCH="${ARCH:-arm64}" +VERSION="${VERSION:-LOCAL_DEV_TEST}" +VERSION="${VERSION#v}" +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +BUILD_DIR="${REPO_ROOT}/.build" +DIST_DIR="${BUILD_DIR}/macos-dist" +DMG_STAGING="${BUILD_DIR}/dmg-staging-${ARCH}" +DMG_NAME="StemDeck-macOS-${ARCH}.dmg" +DMG_PATH="${DIST_DIR}/${DMG_NAME}" +DMG_RW_PATH="${DIST_DIR}/StemDeck-macOS-${ARCH}.rw.dmg" +RUNTIME_NAME="StemDeck-runtime-macOS-${ARCH}.tar.zst" +RUNTIME_PATH="${BUILD_DIR}/${RUNTIME_NAME}" +BACKGROUND_SRC="${REPO_ROOT}/packaging/macos/dmg-background.svg" +BACKGROUND_DIR_NAME=".background" +BACKGROUND_PNG_NAME="dmg-background.png" + +if [[ "$(uname)" != "Darwin" ]]; then + echo "ERROR: make-dmg.sh must run on macOS" >&2 + exit 1 +fi + +if [[ "$ARCH" != "arm64" && "$ARCH" != "x64" ]]; then + echo "ERROR: ARCH must be arm64 or x64, got '${ARCH}'" >&2 + exit 1 +fi + +for cmd in ditto hdiutil qlmanage shasum; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "ERROR: required command not found on PATH: $cmd" >&2 + exit 1 + fi +done + +if [[ "$ARCH" == "arm64" ]]; then + APP_DIR="${REPO_ROOT}/desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos/StemDeck.app" +else + APP_DIR="${REPO_ROOT}/desktop/src-tauri/target/x86_64-apple-darwin/release/bundle/macos/StemDeck.app" +fi + +if [[ ! -d "$APP_DIR" ]]; then + echo "ERROR: built app not found: $APP_DIR" >&2 + echo "Run: ARCH=${ARCH} scripts/macos/make-app.sh" >&2 + exit 1 +fi + +if [[ ! -f "$RUNTIME_PATH" ]]; then + echo "ERROR: runtime pack not found: $RUNTIME_PATH" >&2 + echo "Run: ARCH=${ARCH} VERSION=${VERSION} scripts/macos/make-runtime-pack.sh" >&2 + exit 1 +fi + +rm -rf "$DMG_STAGING" +mkdir -p "$DMG_STAGING" "$DIST_DIR" +mkdir -p "$DMG_STAGING/$BACKGROUND_DIR_NAME" + +ditto "$APP_DIR" "$DMG_STAGING/StemDeck.app" +ln -s /Applications "$DMG_STAGING/Applications" + +if [[ -f "$BACKGROUND_SRC" ]]; then + qlmanage -t -s 1320 -o "$DMG_STAGING/$BACKGROUND_DIR_NAME" "$BACKGROUND_SRC" >/dev/null 2>&1 + mv "$DMG_STAGING/$BACKGROUND_DIR_NAME/dmg-background.svg.png" "$DMG_STAGING/$BACKGROUND_DIR_NAME/$BACKGROUND_PNG_NAME" +fi + +if [[ -f "$REPO_ROOT/packaging/macos/README-macOS.txt" ]]; then + cp "$REPO_ROOT/packaging/macos/README-macOS.txt" "$DMG_STAGING/README-macOS.txt" +fi + +if [[ -f "$REPO_ROOT/packaging/macos/THIRD_PARTY_NOTICES.txt" ]]; then + cp "$REPO_ROOT/packaging/macos/THIRD_PARTY_NOTICES.txt" "$DMG_STAGING/THIRD_PARTY_NOTICES.txt" +fi + +rm -f "$DMG_PATH" "$DMG_RW_PATH" +hdiutil create \ + -volname "StemDeck" \ + -srcfolder "$DMG_STAGING" \ + -ov \ + -format UDRW \ + "$DMG_RW_PATH" + +MOUNT_DIR="$(mktemp -d /tmp/stemdeck-dmg.XXXXXX)" +cleanup_mount() { + hdiutil detach "$MOUNT_DIR" >/dev/null 2>&1 || true + rmdir "$MOUNT_DIR" >/dev/null 2>&1 || true +} +trap cleanup_mount EXIT + +hdiutil attach "$DMG_RW_PATH" -readwrite -noverify -nobrowse -mountpoint "$MOUNT_DIR" >/dev/null + +if command -v SetFile >/dev/null 2>&1; then + SetFile -a V "$MOUNT_DIR/$BACKGROUND_DIR_NAME" || true + SetFile -a V "$MOUNT_DIR/README-macOS.txt" || true + SetFile -a V "$MOUNT_DIR/THIRD_PARTY_NOTICES.txt" || true +fi + +if [[ -f "$MOUNT_DIR/$BACKGROUND_DIR_NAME/$BACKGROUND_PNG_NAME" ]]; then + osascript </dev/null +rmdir "$MOUNT_DIR" +trap - EXIT + +hdiutil convert "$DMG_RW_PATH" -format UDZO -imagekey zlib-level=9 -o "$DMG_PATH" >/dev/null +rm -f "$DMG_RW_PATH" + +CHECKSUMS_PATH="${DIST_DIR}/SHA256SUMS-macOS-${ARCH}.txt" +{ + shasum -a 256 "$DMG_PATH" + shasum -a 256 "$RUNTIME_PATH" +} | sed "s#${REPO_ROOT}/##" > "$CHECKSUMS_PATH" + +echo "==> DMG ready: $DMG_PATH" +echo "==> Runtime pack: $RUNTIME_PATH" +echo "==> Checksums: $CHECKSUMS_PATH" diff --git a/scripts/macos/make-iconset.sh b/scripts/macos/make-iconset.sh new file mode 100755 index 0000000..a4a84dd --- /dev/null +++ b/scripts/macos/make-iconset.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +SOURCE_SVG="${SOURCE_SVG:-${REPO_ROOT}/imgs/stemdeck-svg-assets/stemdeck-icon.svg}" +ICON_DIR="${REPO_ROOT}/desktop/src-tauri/icons" +WORK_DIR="${TMPDIR:-/tmp}/stemdeck-iconset" +RENDER_DIR="${WORK_DIR}/render" +ICONSET_DIR="${WORK_DIR}/icon.iconset" + +if [[ "$(uname)" != "Darwin" ]]; then + echo "ERROR: make-iconset.sh must run on macOS" >&2 + exit 1 +fi + +for cmd in qlmanage sips iconutil; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "ERROR: required command not found on PATH: $cmd" >&2 + exit 1 + fi +done + +if [[ ! -f "$SOURCE_SVG" ]]; then + echo "ERROR: source SVG not found: $SOURCE_SVG" >&2 + exit 1 +fi + +rm -rf "$WORK_DIR" +mkdir -p "$RENDER_DIR" "$ICONSET_DIR" "$ICON_DIR" + +qlmanage -t -s 1024 -o "$RENDER_DIR" "$SOURCE_SVG" >/dev/null +RENDERED_PNG="$(find "$RENDER_DIR" -maxdepth 1 -type f -name '*.png' | head -1)" +if [[ -z "$RENDERED_PNG" || ! -f "$RENDERED_PNG" ]]; then + echo "ERROR: qlmanage did not render a PNG from $SOURCE_SVG" >&2 + exit 1 +fi + +cp "$RENDERED_PNG" "$ICON_DIR/icon.png" + +for size in 16 32 128 256 512; do + sips -z "$size" "$size" "$ICON_DIR/icon.png" \ + --out "$ICONSET_DIR/icon_${size}x${size}.png" >/dev/null +done + +sips -z 32 32 "$ICON_DIR/icon.png" --out "$ICONSET_DIR/icon_16x16@2x.png" >/dev/null +sips -z 64 64 "$ICON_DIR/icon.png" --out "$ICONSET_DIR/icon_32x32@2x.png" >/dev/null +sips -z 256 256 "$ICON_DIR/icon.png" --out "$ICONSET_DIR/icon_128x128@2x.png" >/dev/null +sips -z 512 512 "$ICON_DIR/icon.png" --out "$ICONSET_DIR/icon_256x256@2x.png" >/dev/null +cp "$ICON_DIR/icon.png" "$ICONSET_DIR/icon_512x512@2x.png" + +iconutil -c icns "$ICONSET_DIR" -o "$ICON_DIR/icon.icns" + +echo "==> Icon PNG: $ICON_DIR/icon.png" +echo "==> Icon ICNS: $ICON_DIR/icon.icns" diff --git a/scripts/macos/make-runtime-pack.sh b/scripts/macos/make-runtime-pack.sh new file mode 100755 index 0000000..fd7dd13 --- /dev/null +++ b/scripts/macos/make-runtime-pack.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +set -euo pipefail + +ARCH="${ARCH:-arm64}" +VERSION="${VERSION:-LOCAL_DEV_TEST}" +VERSION="${VERSION#v}" +RELEASE_BASE_URL="${RELEASE_BASE_URL:-https://github.com/stemdeckapp/stemdeck/releases/download/v${VERSION}}" +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +BUILD_DIR="${REPO_ROOT}/.build" +STAGING="${BUILD_DIR}/runtime-staging-${ARCH}" +RUNTIME_DIR="${STAGING}/runtime" +PYTHON_DIR="${RUNTIME_DIR}/python" +BACKEND_DIR="${RUNTIME_DIR}/backend" + +if [[ "$(uname)" != "Darwin" ]]; then + echo "ERROR: make-runtime-pack.sh must run on macOS" >&2 + exit 1 +fi + +if [[ "$ARCH" != "arm64" && "$ARCH" != "x64" ]]; then + echo "ERROR: ARCH must be arm64 or x64, got '${ARCH}'" >&2 + exit 1 +fi + +PYTHON_BIN="${PYTHON_BIN:-}" +if [[ -z "$PYTHON_BIN" ]]; then + for candidate in python3.12 python3.11 python3.10 python3; do + if command -v "$candidate" >/dev/null 2>&1; then + PYTHON_BIN="$candidate" + break + fi + done +fi + +for cmd in ditto shasum tar "$PYTHON_BIN"; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "ERROR: required command not found on PATH: $cmd" >&2 + exit 1 + fi +done + +PYTHON_VERSION="$("$PYTHON_BIN" - <<'PY' +import sys +print(f"{sys.version_info.major}.{sys.version_info.minor}") +PY +)" +PYTHON_MACHINE="$("$PYTHON_BIN" - <<'PY' +import platform +print(platform.machine()) +PY +)" +case "$PYTHON_VERSION" in + 3.10|3.11|3.12|3.13) ;; + *) + echo "ERROR: ${PYTHON_BIN} is Python ${PYTHON_VERSION}; Torch 2.6 runtime builds require Python 3.10-3.13." >&2 + echo "Set PYTHON_BIN=/path/to/python3.12 or PYTHON_BIN=/path/to/python3.11." >&2 + exit 1 + ;; +esac + +HOST_ARCH="$(uname -m)" +if [[ "$ARCH" == "arm64" && "$PYTHON_MACHINE" != "arm64" ]]; then + echo "ERROR: arm64 runtime requires an arm64 Python, got ${PYTHON_MACHINE}" >&2 + exit 1 +fi +if [[ "$ARCH" == "x64" && "$PYTHON_MACHINE" != "x86_64" ]]; then + echo "ERROR: x64 runtime requires an x86_64 Python, got ${PYTHON_MACHINE}" >&2 + exit 1 +fi +if [[ "$ARCH" == "x64" && "$HOST_ARCH" == "arm64" ]]; then + if ! arch -x86_64 /usr/bin/true >/dev/null 2>&1; then + echo "ERROR: x64 runtime on arm64 hosts requires Rosetta 2." >&2 + exit 1 + fi +fi + +rm -rf "$STAGING" +mkdir -p "$PYTHON_DIR" "$BACKEND_DIR" "$BUILD_DIR" + +echo "==> Bundling Python installation (${ARCH})" +echo "==> Python: $("$PYTHON_BIN" --version)" +echo "==> Python architecture: ${PYTHON_MACHINE}" + +# Get the full PBS (python-build-standalone) installation root. +# This directory has the complete stdlib in lib/pythonX.Y/ — unlike a venv, +# which only creates site-packages/ and relies on the original base_prefix +# (a path that won't exist on user machines) for stdlib. +PYTHON_BASE_PREFIX="$("$PYTHON_BIN" - <<'PY' +import sys +print(sys.base_prefix) +PY +)" +echo "==> Python base prefix: ${PYTHON_BASE_PREFIX}" + +if [[ ! -d "${PYTHON_BASE_PREFIX}/lib" ]]; then + echo "ERROR: Python base prefix has no lib/ dir: ${PYTHON_BASE_PREFIX}" >&2 + echo " Make sure PYTHON_BIN points to a python-build-standalone (UV) Python." >&2 + exit 1 +fi + +# Verify the stdlib is actually present in base_prefix before copying. +STDLIB_CHECK="$("$PYTHON_BIN" - <<'PY' +import sys, pathlib +ver = f"python{sys.version_info.major}.{sys.version_info.minor}" +p = pathlib.Path(sys.base_prefix) / "lib" / ver / "encodings" / "__init__.py" +print("ok" if p.is_file() else f"missing:{p}") +PY +)" +if [[ "$STDLIB_CHECK" != "ok" ]]; then + echo "ERROR: stdlib not found in base_prefix (${STDLIB_CHECK})" >&2 + echo " base_prefix: ${PYTHON_BASE_PREFIX}" >&2 + exit 1 +fi + +# Copy the entire PBS Python installation into the runtime bundle. +# ditto preserves symlinks, HFS+ metadata, and extended attributes. +ditto "$PYTHON_BASE_PREFIX" "$PYTHON_DIR" + +# PBS Python ships an EXTERNALLY-MANAGED marker that blocks uv from installing +# packages into it. Remove it so we can treat this copy as our own install. +find "$PYTHON_DIR/lib" -name "EXTERNALLY-MANAGED" -delete + +echo "==> Installing packages into bundled Python" +# --system is required because $PYTHON_DIR is not a venv (it's a full Python install). +uv pip install --system --python "$PYTHON_DIR/bin/python" pip setuptools wheel +# The project version is git-derived (hatch-vcs). Pin it explicitly from $VERSION +# so the install doesn't depend on git tags being present in the build checkout (#169). +SETUPTOOLS_SCM_PRETEND_VERSION="${VERSION#v}" \ + uv pip install --system --python "$PYTHON_DIR/bin/python" "$REPO_ROOT" + +echo "==> Verifying stdlib and imports" +PYTHON_DIR="$PYTHON_DIR" PYTHONHOME="$PYTHON_DIR" "$PYTHON_DIR/bin/python" - <<'PY' +import importlib, os, pathlib, sys + +ver = f"python{sys.version_info.major}.{sys.version_info.minor}" +stdlib = pathlib.Path(os.environ["PYTHON_DIR"]) / "lib" / ver +if not (stdlib / "encodings" / "__init__.py").is_file(): + print(f"ERROR: encodings not found in {stdlib}", file=sys.stderr) + sys.exit(1) +print(f" stdlib OK at {stdlib}") + +packages = [ + "fastapi", "uvicorn", "yt_dlp", "demucs", "torch", "torchaudio", + "librosa", "pyloudnorm", "soundfile", +] +for package in packages: + importlib.import_module(package) + print(f" OK {package}") +PY + +echo "==> Staging backend" +cp -R "$REPO_ROOT/app" "$BACKEND_DIR/app" +cp -R "$REPO_ROOT/static" "$BACKEND_DIR/static" +cp "$REPO_ROOT/pyproject.toml" "$BACKEND_DIR/pyproject.toml" +cp "$REPO_ROOT/uv.lock" "$BACKEND_DIR/uv.lock" + +cat > "$BACKEND_DIR/static/version.json" < Capturing dependency inventory" +mkdir -p "$RUNTIME_DIR/licenses" +uv pip list --system --python "$PYTHON_DIR/bin/python" --format=json > "$RUNTIME_DIR/licenses/pip-list.json" + +cat > "$RUNTIME_DIR/runtime-manifest.json" < Stripping Python caches" +find "$PYTHON_DIR" -type d -name "__pycache__" -prune -exec rm -rf {} + 2>/dev/null || true +find "$PYTHON_DIR" -type f \( -name "*.pyc" -o -name "*.pyo" \) -delete + +ARCHIVE_NAME="StemDeck-runtime-macOS-${ARCH}.tar.zst" +ARCHIVE_PATH="${BUILD_DIR}/${ARCHIVE_NAME}" +if command -v zstd >/dev/null 2>&1; then + tar --zstd -cf "$ARCHIVE_PATH" -C "$STAGING" runtime +else + ARCHIVE_NAME="StemDeck-runtime-macOS-${ARCH}.tar.gz" + ARCHIVE_PATH="${BUILD_DIR}/${ARCHIVE_NAME}" + tar -czf "$ARCHIVE_PATH" -C "$STAGING" runtime +fi + +SIZE="$(stat -f%z "$ARCHIVE_PATH")" +SHA256="$(shasum -a 256 "$ARCHIVE_PATH" | awk '{print $1}')" +RUNTIME_URL="${RELEASE_BASE_URL}/${ARCHIVE_NAME}" + +cat > "${BUILD_DIR}/runtime-manifest-${ARCH}.json" < Runtime pack ready" +echo "Archive: ${ARCHIVE_PATH}" +echo "Size: ${SIZE}" +echo "SHA256: ${SHA256}" +echo "Manifest: ${BUILD_DIR}/runtime-manifest-${ARCH}.json" diff --git a/scripts/windows/make-portable.ps1 b/scripts/windows/make-portable.ps1 new file mode 100644 index 0000000..9e7f0f0 --- /dev/null +++ b/scripts/windows/make-portable.ps1 @@ -0,0 +1,264 @@ +param( + [string]$Configuration = "release", + [string]$OutputRoot = "dist", + [string]$PackageName = "StemDeck-Windows-x64", + [string]$PackageVersion, + [switch]$SkipTauriBuild, + [switch]$CpuOnly, + [switch]$StripVenv +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +if ($env:OS -ne "Windows_NT") { + throw "This packaging script must run on Windows." +} + +$Root = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$Stage = Join-Path $Root "$OutputRoot\$PackageName" +$ZipPath = Join-Path $Root "$OutputRoot\$PackageName.zip" +$ChecksumPath = "$ZipPath.sha256" +$PythonDir = Join-Path $Stage "python" +$PythonExe = Join-Path $PythonDir "Scripts\python.exe" +$BackendDir = Join-Path $Stage "backend" +$DesktopDir = Join-Path $Root "desktop" +$TauriDir = Join-Path $DesktopDir "src-tauri" +$TargetExe = Join-Path $TauriDir "target\$Configuration\stemdeck.exe" + +function Require-Command([string]$Name) { + if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { + throw "Required command not found on PATH: $Name" + } +} + +function Copy-Tree([string]$Source, [string]$Destination) { + if (Test-Path $Destination) { + Remove-Item -Recurse -Force $Destination + } + Copy-Item -Recurse -Force $Source $Destination +} + +function Copy-TreeContents([string]$Source, [string]$Destination, [string[]]$ExcludeNames = @()) { + New-Item -ItemType Directory -Force $Destination | Out-Null + Get-ChildItem -LiteralPath $Source -Force | + Where-Object { $ExcludeNames -notcontains $_.Name } | + ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $Destination -Recurse -Force + } +} + +function Set-PyvenvValue([string]$ConfigPath, [string]$Key, [string]$Value) { + $content = Get-Content -LiteralPath $ConfigPath + $pattern = "^\s*$([regex]::Escape($Key))\s*=" + $line = "$Key = $Value" + $found = $false + $updated = foreach ($entry in $content) { + if ($entry -match $pattern) { + $found = $true + $line + } else { + $entry + } + } + if (-not $found) { + $updated += $line + } + $utf8NoBom = New-Object System.Text.UTF8Encoding $false + [System.IO.File]::WriteAllLines($ConfigPath, [string[]]$updated, $utf8NoBom) +} + +function Get-PackageVersion { + if ($PackageVersion) { + return $PackageVersion.TrimStart("v") + } + $tauriConfig = Get-Content -LiteralPath (Join-Path $TauriDir "tauri.conf.json") -Raw | + ConvertFrom-Json + return [string]$tauriConfig.version +} + +function Bundle-PythonRuntime([string]$VenvDir, [string]$VenvPython) { + $baseExecutable = (& $VenvPython -c "import sys; print(getattr(sys, '_base_executable', sys.executable))").Trim() + if (-not (Test-Path $baseExecutable)) { + throw "Could not locate base Python executable: $baseExecutable" + } + $baseHome = Split-Path -Parent $baseExecutable + $portableBaseHome = Join-Path $VenvDir "base" + $baseLib = Join-Path $baseHome "Lib" + $baseDlls = Join-Path $baseHome "DLLs" + if (-not (Test-Path $baseLib)) { + throw "Could not locate base Python standard library: $baseLib" + } + + Write-Host "Bundling Python runtime from $baseHome..." + New-Item -ItemType Directory -Force $portableBaseHome | Out-Null + Copy-Item -Force $baseExecutable (Join-Path $portableBaseHome "python.exe") + $basePythonw = Join-Path $baseHome "pythonw.exe" + if (Test-Path $basePythonw) { + Copy-Item -Force $basePythonw (Join-Path $portableBaseHome "pythonw.exe") + } + Get-ChildItem -LiteralPath $baseHome -Filter "*.dll" -File -Force | + Copy-Item -Destination $portableBaseHome -Force + if (Test-Path $baseDlls) { + Copy-Tree $baseDlls (Join-Path $portableBaseHome "DLLs") + } + Copy-TreeContents $baseLib (Join-Path $portableBaseHome "Lib") @("site-packages") + + $cfg = Join-Path $VenvDir "pyvenv.cfg" + Set-PyvenvValue $cfg "home" $portableBaseHome + Set-PyvenvValue $cfg "executable" (Join-Path $portableBaseHome "python.exe") +} + +function Invoke-TauriBuild { + $TauriCli = Join-Path $DesktopDir "node_modules\@tauri-apps\cli\tauri.js" + if (-not (Test-Path $TauriCli)) { + throw "Tauri CLI not found at $TauriCli. npm install/ci may have omitted devDependencies." + } + & node $TauriCli build +} + +function Assert-Fresh-TauriBuild { + if (-not (Test-Path $TargetExe)) { + throw "Tauri executable not found at $TargetExe. Remove -SkipTauriBuild or build the NVIDIA package first." + } + + $exe = Get-Item $TargetExe + $newerSources = @( + Get-ChildItem -Path (Join-Path $DesktopDir "ui") -File -Recurse + Get-ChildItem -Path (Join-Path $TauriDir "src") -File -Recurse + Get-Item (Join-Path $TauriDir "Cargo.toml") + Get-Item (Join-Path $TauriDir "tauri.conf.json") + ) | Where-Object { $_.LastWriteTimeUtc -gt $exe.LastWriteTimeUtc } + + if ($newerSources.Count -gt 0) { + $list = ($newerSources | Select-Object -First 8 | ForEach-Object { " - $($_.FullName)" }) -join "`n" + throw @" +-SkipTauriBuild would package a stale StemDeck.exe. + +The existing executable is older than desktop UI/Tauri source files: +$list + +Remove -SkipTauriBuild or run the NVIDIA package build first so the CPU package reuses a fresh executable. +"@ + } +} + +Require-Command "node" +Require-Command "npm" +Require-Command "cargo" + +if (-not (Get-Command "py" -ErrorAction SilentlyContinue) -and -not (Get-Command "python" -ErrorAction SilentlyContinue)) { + throw "Python launcher not found. Install Python 3.12 on the Windows build agent." +} + +if (Test-Path $Stage) { + Remove-Item -Recurse -Force $Stage +} +if (Test-Path $ZipPath) { + Remove-Item -Force $ZipPath +} +if (Test-Path $ChecksumPath) { + Remove-Item -Force $ChecksumPath +} + +New-Item -ItemType Directory -Force $Stage | Out-Null +New-Item -ItemType Directory -Force $BackendDir | Out-Null +New-Item -ItemType Directory -Force (Join-Path $Stage "data") | Out-Null +foreach ($Dir in @("cache", "downloads", "ffmpeg", "jobs", "logs", "models")) { + New-Item -ItemType Directory -Force (Join-Path $Stage "data\$Dir") | Out-Null +} +if ($CpuOnly) { + New-Item -ItemType File -Force (Join-Path $Stage "cpu-only") | Out-Null + New-Item -ItemType File -Force (Join-Path $Stage "data\cpu-only") | Out-Null +} + +Copy-Tree (Join-Path $Root "app") (Join-Path $BackendDir "app") +Copy-Tree (Join-Path $Root "static") (Join-Path $BackendDir "static") +$PackageVersion = Get-PackageVersion +$VersionJson = @{ version = $PackageVersion } | ConvertTo-Json -Compress +$utf8NoBom = New-Object System.Text.UTF8Encoding $false +[System.IO.File]::WriteAllText((Join-Path $BackendDir "static\version.json"), $VersionJson + "`n", $utf8NoBom) +Copy-Item -Force (Join-Path $Root "pyproject.toml") (Join-Path $BackendDir "pyproject.toml") +Copy-Item -Force (Join-Path $Root "uv.lock") (Join-Path $BackendDir "uv.lock") +Copy-Item -Force (Join-Path $Root "packaging\windows\README-WINDOWS.txt") (Join-Path $Stage "README-WINDOWS.txt") +Copy-Item -Force (Join-Path $Root "packaging\windows\THIRD_PARTY_NOTICES.txt") (Join-Path $Stage "THIRD_PARTY_NOTICES.txt") + +if (Get-Command "py" -ErrorAction SilentlyContinue) { + & py -3.12 -m venv $PythonDir +} else { + & python -m venv $PythonDir +} + +& $PythonExe -m pip install --upgrade pip + +# The project version is git-derived (hatch-vcs). Pin it from $PackageVersion so +# the install doesn't depend on git tags in the build checkout (#169). +if ($PackageVersion) { + $env:SETUPTOOLS_SCM_PRETEND_VERSION = ($PackageVersion -replace '^v', '') +} +& $PythonExe -m pip install "$Root" + +if ($CpuOnly) { + # pip strips local version identifiers when resolving requirements, so it installs + # the CUDA wheel from PyPI even when we pre-install the CPU wheel. Force-reinstall + # after the fact: uninstalls CUDA torch and replaces it with the CPU-only variant. + & $PythonExe -m pip install torch==2.6.0+cpu torchaudio==2.6.0+cpu ` + --index-url https://download.pytorch.org/whl/cpu ` + --force-reinstall --no-deps +} + +& $PythonExe -c "import fastapi, uvicorn, yt_dlp, demucs, torch, torchaudio, librosa, pyloudnorm, soundfile" + +Bundle-PythonRuntime $PythonDir $PythonExe +& $PythonExe -c "import sys, fastapi, uvicorn; print('Portable Python:', sys.executable)" + +if ($StripVenv) { + Write-Host "Stripping venv of build-time artifacts..." + Get-ChildItem -Path $PythonDir -Filter "__pycache__" -Recurse -Directory -Force | + Remove-Item -Recurse -Force + foreach ($rel in @("torch\include", "torch\share\cmake", "torch\test")) { + $p = Join-Path $PythonDir "Lib\site-packages\$rel" + if (Test-Path $p) { Remove-Item -Recurse -Force $p } + } + # Remove C++ static link libraries from torch — needed only for building C++ extensions, + # never for running Python. dnnl.lib alone is ~623 MB. + Get-ChildItem -Path (Join-Path $PythonDir "Lib\site-packages\torch") ` + -Filter "*.lib" -Recurse -File -Force | + Remove-Item -Force +} + +Push-Location $DesktopDir +try { + if (Test-Path "package-lock.json") { + npm ci --include=dev + } else { + npm install --include=dev + } + + if (-not $SkipTauriBuild) { + $env:CI = "true" # Woodpecker sets CI=woodpecker; Tauri only accepts true/false + rustup default stable + Invoke-TauriBuild + } else { + Assert-Fresh-TauriBuild + } +} finally { + Pop-Location +} + +if (-not (Test-Path $TargetExe)) { + throw "Tauri executable not found at $TargetExe" +} + +Copy-Item -Force $TargetExe (Join-Path $Stage "StemDeck.exe") + +Compress-Archive -Path (Join-Path $Stage "*") -DestinationPath $ZipPath -Force +$Hash = Get-FileHash -Algorithm SHA256 $ZipPath +Set-Content -Path $ChecksumPath -Value "$($Hash.Hash) $PackageName.zip" + +$Variant = if ($CpuOnly) { "CPU-only" } else { "CUDA/GPU (NVIDIA)" } +Write-Host "Variant : $Variant" +Write-Host "Staged at : $Stage" +Write-Host "Zip created : $ZipPath" +Write-Host "Checksum : $ChecksumPath" diff --git a/static/css/appbar.css b/static/css/appbar.css new file mode 100644 index 0000000..52da607 --- /dev/null +++ b/static/css/appbar.css @@ -0,0 +1,443 @@ +/* ───────────── Top bar ───────────── */ + +.app:not(.is-import) .appbar { + padding-top: 12px; + padding-bottom: 14px; +} + +/* ─── Collapsed icon strip (double-click to toggle) ─── */ + +/* Expandable body wrapper — same grid-row trick as .widget-body */ +.appbar-body { + display: grid; + grid-template-rows: 1fr; + overflow: hidden; + opacity: 1; + width: 100%; + min-height: 0; + transition: grid-template-rows 220ms ease, opacity 180ms ease; +} +.appbar-body-inner { + min-height: 0; + width: 100%; + display: grid; + grid-template-columns: minmax(220px, 300px) minmax(0, 1fr); + align-items: stretch; + gap: 22px; +} + +/* Strip wrapper — collapsed by default */ +.appbar-icon-strip { + display: grid; + grid-template-rows: 0fr; + overflow: hidden; + opacity: 0; + pointer-events: none; + min-height: 0; /* prevent flex parent from reserving space when row is 0fr */ + transition: grid-template-rows 220ms ease, opacity 200ms ease 20ms; +} +.appbar-strip-inner { + min-height: 0; + display: flex; + align-items: center; + gap: 5px; + margin: 0 auto; +} + +/* Collapsed state */ +.app.appbar-collapsed .appbar-body { + grid-template-rows: 0fr; + opacity: 0; + pointer-events: none; + height: 0; /* force flex child to zero — grid-template-rows alone isn't enough in a flex container */ +} +.app.appbar-collapsed .appbar-icon-strip { + grid-template-rows: 1fr; + opacity: 1; + pointer-events: auto; + width: 100%; + height: 40px; + align-items: center; +} +.app.appbar-collapsed .appbar { + min-height: 52px; + height: 52px; + padding: 6px 14px; + justify-content: center; + align-items: center; +} +.app.appbar-collapsed .appbar-strip-inner { + height: 40px; + align-items: center; +} + +.strip-sq { + width: 36px; + height: 36px; + border-radius: 8px; + border: 1px solid var(--glass-line); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + cursor: pointer; + transition: background 120ms, border-color 120ms, opacity 120ms; +} +.strip-sq:hover { background: rgba(255,255,255,0.08) !important; } +.strip-sq-process:hover { filter: brightness(1.1); } +.strip-sq-brand { + font-family: var(--font-mono); + font-weight: 700; + font-size: 12px; + color: var(--ink); + background: rgba(255,255,255,0.05); + letter-spacing: -0.03em; +} +.strip-sq-url { + background: rgba(255,255,255,0.03); + color: var(--ink-faint); + margin-right: 3px; +} +.strip-sq-stems { + display: flex; + align-items: center; + gap: 5px; +} +.strip-sq-stem { + width: 36px; + height: 36px; + border-radius: 8px; + border: 1px solid rgba(255,255,255,0.1); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + cursor: pointer; + transition: opacity 120ms, background 120ms; +} +.strip-sq-stem:hover { opacity: 0.85 !important; } +.strip-sq-stem.inactive { opacity: 0.2; } +.strip-sq-process { + background: linear-gradient(180deg, var(--gold-bright) 0%, var(--gold) 100%); + color: #17130c; + border-color: transparent; + margin-left: 4px; +} +.strip-sq-process.loading { + cursor: progress; + filter: saturate(0.92); +} +.strip-sq-process.loading svg { + display: none; +} +.strip-sq-process.loading::before { + content: ""; + width: 16px; + height: 16px; + flex: 0 0 16px; + border-radius: 50%; + background: conic-gradient(currentColor 0deg 270deg, transparent 270deg 360deg); + -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 0); + mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 0); + animation: spin 0.85s linear infinite; +} + +.brand-version { + font-size: 11px; + color: rgba(216,222,230,0.35); + margin-top: 3px; + display: flex; + align-items: center; + gap: 6px; +} +.brand-update-chip { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 1px 5px; + border-radius: 3px; + background: rgba(100, 200, 120, 0.10); + border: 1px solid rgba(100, 200, 120, 0.22); + color: #6dcc82; + font-size: 9px; + text-decoration: none; +} +.brand-update-chip:hover { + background: rgba(100, 200, 120, 0.2); + color: #88dfa0; +} + +.appbar { + display: flex; + flex-direction: column; + padding: 20px 24px 22px; + border: 1px solid var(--glass-line); + border-radius: 16px; + background: + radial-gradient(circle at 82% -20%, rgba(216, 168, 74, 0.11), transparent 20rem), + linear-gradient(180deg, rgba(27, 39, 48, 0.76), rgba(13, 22, 30, 0.84)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.045), + var(--shadow-panel); + transition: padding 220ms ease; +} + +.brand-group { + display: flex; + flex-direction: column; + justify-content: center; + gap: 5px; + min-width: 0; + padding-right: 8px; +} + +.brand-row { + display: flex; + align-items: center; + gap: 8px; +} + +.brand { + margin: 0; + font-family: var(--font-mono); + font-weight: 700; + font-size: 24px; + line-height: 1; + color: var(--ink); + letter-spacing: -0.01em; +} + +.brand-logo { + display: block; + width: 190px; + height: auto; +} + +.beta-badge { + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 5px; + border: 1px solid rgba(216, 168, 74, 0.65); + border-radius: 6px; + background: var(--gold-soft); + color: var(--gold-bright); + font-size: 11px; + font-weight: 700; + line-height: 1; +} + +.brand-sub { + font-size: 13px; + color: var(--ink-dim); + white-space: nowrap; +} + +.post-urlbar, +.post-process-btn { + display: none; +} + +.appbar-form { + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; +} + +.appbar-import-panel { + min-width: 0; +} + +.appbar-import-panel .import-input-row { + grid-template-columns: minmax(0, 1fr) clamp(140px, 14vw, 220px); + gap: clamp(12px, 2vw, 28px); + padding-bottom: 22px; +} + +.appbar-import-panel .url-wrap { + min-height: 58px; + padding: 0 20px; + border: 1px solid rgba(148, 163, 184, 0.24); + border-radius: 11px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.025), transparent), + rgba(3, 9, 14, 0.72); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.035), + inset 0 -1px 0 rgba(0, 0, 0, 0.32), + 0 0 0 0 rgba(216, 168, 74, 0); + transition: + border-color var(--t-base) var(--easing), + box-shadow var(--t-base) var(--easing), + background-color var(--t-base) var(--easing); +} + +.appbar-import-panel .url-wrap:focus-within { + border-color: rgba(216, 168, 74, 0.56); + background-color: rgba(6, 13, 19, 0.86); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.045), + inset 0 -1px 0 rgba(0, 0, 0, 0.34), + 0 0 0 3px rgba(216, 168, 74, 0.08), + 0 14px 34px rgba(0, 0, 0, 0.22); +} + +.appbar-import-panel .url-prefix { + color: #cbd2dc; + opacity: 0.92; +} + +.appbar-import-panel input[type="url"] { + font-size: clamp(15px, 0.95vw, 18px); + height: 100%; + color: #edf0f4; + caret-color: var(--gold-bright); + letter-spacing: 0; +} + +.appbar-import-panel .btn-primary { + height: 58px; + min-width: 0; + width: 100%; + border-radius: 11px; + font-size: clamp(14px, 0.9vw, 16px); +} + +.appbar-import-panel .stem-choice-row { + grid-template-columns: clamp(110px, 11vw, 156px) repeat(6, minmax(0, 1fr)); + gap: clamp(8px, 1vw, 14px); + padding-top: 20px; +} + +.appbar-import-panel .stem-choice-row > span { + font-size: clamp(12px, 0.8vw, 14px); +} + +.appbar-import-panel .stem-choice { + min-height: 48px; + padding: 0 14px; + border-radius: 10px; + font-size: clamp(12px, 0.8vw, 14px); + background: + linear-gradient(180deg, color-mix(in srgb, currentColor 9%, transparent), transparent), + rgba(8, 15, 22, 0.42); +} + +.url-wrap { + display: flex; + align-items: center; + gap: 16px; + flex: 1; + min-width: 0; + cursor: text; +} + +.url-prefix { + flex-shrink: 0; + color: #d7dbe2; +} + +.appbar-form input[type="url"], +.appbar-import-panel input[type="url"], +.import-card input[type="url"] { + flex: 1; + background: transparent; + border: 0; + outline: 0; + color: var(--ink); + font-family: var(--font-mono); + font-size: 19px; + padding: 0; + min-width: 0; + appearance: none; + box-shadow: none; +} + +.appbar-form input[type="url"]::placeholder, +.appbar-import-panel input[type="url"]::placeholder, +.import-card input[type="url"]::placeholder { + color: #9da3ad; + opacity: 0.78; +} + +.appbar-import-panel input[type="url"]::selection { + background: rgba(216, 168, 74, 0.28); + color: #fff6de; +} + +.appbar-import-panel input[type="url"] { + font-size: clamp(15px, 0.95vw, 18px); + color: #edf0f4; +} + +.btn-primary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + height: 56px; + min-width: 150px; + background: linear-gradient(180deg, var(--gold-bright) 0%, var(--gold) 100%); + color: #17130c; + border: 0; + border-radius: var(--radius); + padding: 0 24px; + font-family: var(--font-mono); + font-size: 16px; + font-weight: 700; + cursor: pointer; + position: relative; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.38), + 0 16px 36px rgba(216, 168, 74, 0.18); + transition: + filter var(--t-base) var(--easing), + transform var(--t-fast) var(--easing); +} + +.btn-primary:hover:not(:disabled) { + filter: brightness(1.08); +} + +.btn-primary:active:not(:disabled) { + transform: scale(0.98); +} + +.btn-primary:disabled { + opacity: 0.82; + cursor: progress; +} + +.btn-primary.loading { + filter: saturate(0.92); +} + +.btn-primary.loading .icon { + display: none; +} + +.btn-primary.loading::before { + content: ""; + width: 18px; + height: 18px; + flex: 0 0 18px; + border-radius: 50%; + background: conic-gradient(currentColor 0deg 270deg, transparent 270deg 360deg); + -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 0); + mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 0); + animation: spin 0.85s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .btn-primary.loading::before { + animation-duration: 1.8s; + } +} diff --git a/static/css/base.css b/static/css/base.css new file mode 100644 index 0000000..a3a57ba --- /dev/null +++ b/static/css/base.css @@ -0,0 +1,110 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: + radial-gradient(circle at 50% 0%, rgba(216, 168, 74, 0.1), transparent 34rem), + radial-gradient(circle at 0% 45%, rgba(216, 168, 74, 0.055), transparent 28rem), + radial-gradient(circle at 100% 45%, rgba(216, 168, 74, 0.055), transparent 28rem), + linear-gradient(180deg, #081016 0%, #050a0f 100%); + color: var(--ink); + font-family: var(--font-mono); + font-size: 14px; + line-height: 1.5; + min-height: 100vh; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.hidden { + display: none !important; +} + +:focus { + outline: 0; +} +:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; + border-radius: var(--radius-xs); +} + +.surface-panel { + border: 1px solid var(--glass-line); + background: + linear-gradient(180deg, rgba(28, 39, 48, 0.82), rgba(15, 23, 31, 0.86)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.045), + var(--shadow-panel); +} + +.app { + max-width: 1640px; + margin: 0 auto; + min-height: 100vh; + padding: 24px 28px 0; + display: flex; + flex-direction: column; + gap: 16px; +} + +.app:not(.is-import) { + max-width: none; + width: 100%; + height: 100vh; + min-height: 0; + padding: 16px 29px 0; + gap: 8px; + overflow: hidden; +} + +.app.is-import .now-playing, +.app.is-import #lanes { + display: none; +} + +.app:not(.is-import) .import-page, +.app:not(.is-import) .site-footer { + display: none; +} + +.site-footer { + display: flex; + align-items: center; + gap: 24px; + margin-top: auto; + padding: 18px 14px 22px; + color: #8d929a; + font-size: 14px; +} + +.site-footer > span { + margin-right: auto; +} + +.site-footer nav { + display: flex; + align-items: center; + gap: 26px; +} + +.site-footer a { + color: inherit; + text-decoration: none; +} + +.site-footer a:hover { + color: var(--ink); +} + +.help-btn { + height: 42px; + padding: 0 16px; + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: var(--radius); + background: rgba(18, 26, 34, 0.75); + color: var(--ink); + font: inherit; +} diff --git a/static/css/catalog.css b/static/css/catalog.css new file mode 100644 index 0000000..9420181 --- /dev/null +++ b/static/css/catalog.css @@ -0,0 +1,493 @@ +/* ────────────────────────────────────────────── + catalog.css — Left catalog / history panel + ────────────────────────────────────────────── */ + +/* ─── Panel shell ─── */ +.catalog { + display: flex; + flex-direction: column; + background: linear-gradient(180deg, rgba(28,39,48,0.82), rgba(15,23,31,0.86)); + border: 1px solid var(--glass-line); + border-radius: 12px; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.045), var(--shadow-panel); + padding: 12px 10px; + gap: 10px; + height: 100%; + min-height: 0; + position: relative; + top: 0; + overflow: hidden; + transition: padding 220ms ease; +} +.app.cat-collapsed .catalog { padding: 12px 8px; } + +/* ─── Header (click to collapse) ─── */ +.catalog-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 6px 10px; + border-bottom: 1px solid rgba(157,170,182,0.1); + cursor: pointer; + user-select: none; + border-radius: 6px; + transition: background 120ms; + flex-shrink: 0; +} +.catalog-head:hover { background: rgba(31,43,52,0.55); } + +.catalog-head h3 { + margin: 0; + font-family: var(--font-mono); + font-size: 17px; + font-weight: 600; + color: var(--ink); + white-space: nowrap; +} +.catalog-head .cat-count { + color: var(--ink-faint); + font-size: 11px; + flex: 1; + padding-left: 8px; + white-space: nowrap; +} +.cat-chevron { + width: 15px; + height: 15px; + color: var(--ink-faint); + transition: transform 220ms ease; + flex-shrink: 0; +} + +/* Hide text content when collapsed */ +.app.cat-collapsed .catalog-head h3, +.app.cat-collapsed .catalog-head .cat-count { display: none; } +.app.cat-collapsed .catalog-head { justify-content: center; padding: 10px 0; } +.app.cat-collapsed .cat-chevron { transform: rotate(180deg); } + +/* ─── Search ─── */ +.catalog-search { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(21,31,39,0.6); + color: var(--ink-faint); + font-size: 12px; + flex-shrink: 0; +} +.catalog-search svg { flex-shrink: 0; } +.catalog-search input { + min-width: 0; + width: 100%; + border: 0; + background: transparent; + color: var(--ink); + font: inherit; + font-size: 12px; + outline: none; + padding: 0; +} +.catalog-search input::placeholder { + color: var(--ink-faint); +} + +/* ─── New folder button ─── */ +.new-folder-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + border: 1px dashed var(--line); + border-radius: 8px; + background: transparent; + color: var(--ink-dim); + font-family: var(--font-mono); + font-size: 12px; + cursor: pointer; + transition: all 120ms; + width: 100%; + flex-shrink: 0; +} +.new-folder-btn:hover { + border-color: var(--gold-line); + color: var(--gold); +} +.new-folder-btn svg { width: 13px; height: 13px; flex-shrink: 0; } + +/* ─── Catalog list ─── */ +.catalog-list { + display: flex; + flex-direction: column; + gap: 4px; + overflow-y: auto; + flex: 1; + padding-right: 2px; +} + +/* ─── Folder ─── */ +.folder { display: flex; flex-direction: column; } +.folder-head { + --folder-color: var(--gold); + display: flex; + align-items: center; + gap: 7px; + padding: 6px 8px; + border-radius: 7px; + cursor: pointer; + color: var(--ink-dim); + transition: background 120ms, color 120ms; + user-select: none; +} +.folder-head:hover { background: rgba(31,43,52,0.55); color: var(--ink); } +.folder.drop-target .folder-head { + background: rgba(216,168,74,0.10); + box-shadow: inset 0 0 0 1px var(--gold-line); + color: var(--ink); +} + +.folder-head .f-chevron { + width: 11px; height: 11px; + transition: transform 180ms ease; + flex-shrink: 0; + color: var(--ink-faint); +} +.folder.collapsed .folder-head .f-chevron { transform: rotate(-90deg); } + +.folder-head .f-icon { + width: 13px; + height: 13px; + flex-shrink: 0; + color: var(--folder-color); +} + +.folder-head .f-name { + font-size: 12px; + font-weight: 500; + color: inherit; + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + background: transparent; + border: none; + padding: 0; + font-family: inherit; + outline: none; + cursor: inherit; +} +.folder-head .f-name[contenteditable="true"] { + background: rgba(28,34,43,0.8); + padding: 1px 4px; + border-radius: 3px; + cursor: text; +} + +.folder-head .f-count { font-size: 10.5px; color: var(--ink-faint); } + +.folder-color-dot { + width: 13px; + height: 13px; + border: 1px solid rgba(255,255,255,0.22); + border-radius: 999px; + background: var(--folder-color); + box-shadow: inset 0 0 0 1px rgba(0,0,0,0.22); + cursor: pointer; + padding: 0; +} +.folder-color-dot:hover, +.folder-color-dot:focus-visible { + border-color: rgba(255,255,255,0.82); + outline: none; +} +.folder-color-dot.active { + box-shadow: 0 0 0 2px rgba(255,255,255,0.18), inset 0 0 0 1px rgba(0,0,0,0.24); + border-color: rgba(255,255,255,0.86); +} + +.folder-head .f-del { + width: 18px; height: 18px; + border: none; background: transparent; + color: var(--ink-faint); + cursor: pointer; + opacity: 0; + display: flex; align-items: center; justify-content: center; + border-radius: 4px; + padding: 0; +} +.folder-head:hover .f-del { opacity: 1; } +.folder-head .f-del:hover { background: rgba(21,31,39,0.8); color: var(--danger); } + +.folder-editor-backdrop { + position: fixed; + inset: 0; + z-index: 80; + display: grid; + place-items: center; + background: rgba(3, 8, 13, 0.42); + backdrop-filter: blur(2px); +} + +.folder-editor { + width: min(300px, calc(100vw - 32px)); + border: 1px solid var(--glass-line); + border-radius: 10px; + background: linear-gradient(180deg, rgba(28,39,48,0.98), rgba(13,21,29,0.98)); + box-shadow: var(--shadow-panel); + padding: 12px; + color: var(--ink); + font-family: var(--font-mono); +} + +.folder-editor-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + font-size: 13px; + font-weight: 600; +} + +.folder-editor-close { + width: 24px; + height: 24px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--ink-faint); + cursor: pointer; + display: grid; + place-items: center; + padding: 0; +} +.folder-editor-close:hover { + background: rgba(21,31,39,0.8); + color: var(--ink); +} + +.folder-editor-field { + display: grid; + gap: 7px; + margin-bottom: 12px; + color: var(--ink-faint); + font-size: 10.5px; +} + +.folder-editor-name { + width: 100%; + min-height: 34px; + border: 1px solid var(--line); + border-radius: 7px; + background: rgba(10, 17, 24, 0.78); + color: var(--ink); + font: inherit; + font-size: 12px; + padding: 0 10px; + outline: none; +} +.folder-editor-name:focus { + border-color: var(--gold-line); + box-shadow: 0 0 0 2px rgba(216,168,74,0.12); +} + +.folder-editor-colors { + display: flex; + align-items: center; + gap: 9px; +} + +.folder-editor-colors .folder-color-dot { + width: 22px; + height: 22px; +} + +.folder-editor-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 14px; +} + +.folder-editor-actions button { + min-height: 30px; + border-radius: 7px; + font-family: var(--font-mono); + font-size: 11px; + cursor: pointer; + padding: 0 11px; +} +.folder-editor-cancel { + border: 1px solid var(--line); + background: rgba(21,31,39,0.52); + color: var(--ink-dim); +} +.folder-editor-save { + border: 1px solid var(--gold-line); + background: rgba(216,168,74,0.16); + color: var(--gold); +} + +.folder-body { + display: flex; + flex-direction: column; + gap: 3px; + padding: 3px 0 3px 14px; + margin-left: 7px; + border-left: 1px solid rgba(157,170,182,0.1); +} +.folder.collapsed .folder-body { display: none; } +.folder-empty { + color: var(--ink-faint); + font-size: 11px; + padding: 5px 8px; + font-style: italic; +} + +/* ─── Track item ─── */ +.cat-item { + display: flex; + gap: 9px; + align-items: center; + padding: 7px 8px; + border: 1px solid transparent; + border-radius: 9px; + cursor: pointer; + transition: background 120ms, border-color 120ms; +} +.cat-item:hover { background: rgba(31,43,52,0.55); border-color: var(--line); } +.cat-item.active { background: rgba(31,43,52,0.55); border-color: var(--gold-line); } +.cat-item.dragging { opacity: 0.4; } +.cat-item[draggable="true"] { cursor: grab; } + +.cat-thumb { + width: 44px; height: 44px; + border-radius: 7px; + flex-shrink: 0; + overflow: hidden; + border: 1px solid var(--line); + background: rgba(21,31,39,0.8); +} +.cat-thumb svg, +.cat-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } + +.cat-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; } +.cat-title { + color: var(--ink); + font-size: 12px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.cat-sub { + color: var(--ink-faint); + font-size: 10.5px; + display: flex; + gap: 5px; + white-space: nowrap; + overflow: hidden; +} +.cat-sub .dot { opacity: 0.4; } + +.cat-status { + width: 7px; height: 7px; + border-radius: 50%; + background: #5fbc56; + flex-shrink: 0; +} +.cat-status.processing { + background: var(--gold); + animation: cat-pulse 1.4s infinite; +} +@keyframes cat-pulse { 0%,100%{opacity:1;} 50%{opacity:0.3;} } + +.cat-del { + width: 20px; + height: 20px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--ink-faint); + cursor: pointer; + opacity: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + flex-shrink: 0; + transition: opacity 120ms, background 120ms, color 120ms; +} + +.cat-item:hover .cat-del, +.cat-del:focus-visible { + opacity: 1; +} + +.cat-del:hover { + background: rgba(21,31,39,0.8); + color: var(--danger); +} + +/* ─── Catalog footer ─── */ +.catalog-foot { + display: flex; + align-items: center; + gap: 7px; + padding: 8px 6px 0; + border-top: 1px solid rgba(157,170,182,0.08); + color: var(--ink-faint); + font-size: 11px; + flex-shrink: 0; +} +.catalog-foot svg { flex-shrink: 0; } + +/* ─── Collapsed strip: just album thumbs ─── */ +.cat-collapsed-strip { + display: none; + flex-direction: column; + gap: 8px; + align-items: center; + padding-top: 4px; + overflow-y: auto; + flex: 1; +} +.app.cat-collapsed .cat-collapsed-strip { display: flex; } +.app.cat-collapsed .catalog-search, +.app.cat-collapsed .new-folder-btn, +.app.cat-collapsed .catalog-list, +.app.cat-collapsed .catalog-foot { display: none; } + +.strip-thumb { + width: 40px; height: 40px; + border-radius: 7px; + overflow: hidden; + border: 1px solid var(--line); + cursor: pointer; + transition: border-color 120ms, transform 120ms; + flex-shrink: 0; + background: rgba(21,31,39,0.8); + color: var(--ink-dim); +} +.strip-thumb:hover { transform: scale(1.06); } +.strip-thumb.active { + border-color: var(--gold-line); + box-shadow: 0 0 0 2px rgba(216,168,74,0.18); +} +.strip-thumb.folder-thumb { + color: var(--folder-color, var(--gold)); +} +.strip-thumb.trash-thumb { + color: var(--ink-faint); +} +.strip-thumb svg, +.strip-thumb img { width: 100%; height: 100%; display: block; object-fit: cover; } +.strip-thumb.folder-thumb svg, +.strip-thumb.trash-thumb svg { + width: 22px; + height: 22px; + margin: 8px auto; +} diff --git a/static/css/daw.css b/static/css/daw.css new file mode 100644 index 0000000..bf2e29d --- /dev/null +++ b/static/css/daw.css @@ -0,0 +1,2424 @@ +/* ═══════════════════════════════════════════ + DAW UI — flat dark design + ═══════════════════════════════════════════ */ + +/* ── Reset ── */ +*, *::before, *::after { box-sizing: border-box; } +html, body { margin: 0; padding: 0; height: 100%; background: #0a0d10; overflow: hidden; } +body { font-family: var(--font-sans); color: var(--fg); } +button { font-family: inherit; } +input, textarea { font-family: inherit; } + +/* ── Root ── */ +.daw { + width: 100vw; + height: 100vh; + background: var(--bg); + color: var(--fg); + font-family: var(--font-sans); + font-size: 13px; + line-height: 1.4; + letter-spacing: -0.005em; + display: flex; + flex-direction: column; + overflow: hidden; + /* reset any old .app grid */ + grid-template-columns: none !important; + grid-template-rows: none !important; +} + +/* ── Utilities ── */ +.daw .uplabel { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted); +} +.daw .num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; } +.daw .hidden { display: none !important; } + +/* ═══════════════════════════════════ + TOPBAR + ═══════════════════════════════════ */ +.daw-topbar { + height: 77px; + flex-shrink: 0; + background: var(--bg-2); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + padding: 0 16px; + gap: 14px; +} + +/* Brand */ +.daw-brand { + display: flex; + align-items: center; + gap: 9px; + flex-shrink: 0; +} +.daw-brand img { height: 30px; width: auto; display: block; } +.daw-brand-name { + font-size: 18px; + font-weight: 600; + letter-spacing: -0.015em; + line-height: 1; +} +.daw-brand-name .fg { color: var(--fg); } +.daw-brand-name .accent{ color: var(--accent); } + +.daw-version { + display: none !important; /* version shown in notification panel instead */ +} + +/* Topbar separator */ +.daw-sep { + width: 1px; + height: 28px; + background: var(--border); + flex-shrink: 0; +} + +/* ── Composer pill ── */ +.daw-composer { + flex: 1; + min-width: 0; + height: 50px; + display: flex; + align-items: stretch; + background: var(--panel); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.02), 0 1px 8px rgba(0,0,0,0.3); + overflow: hidden; +} + +/* URL zone */ +.daw-url-zone { + display: flex; + align-items: center; + gap: 10px; + padding: 0 14px; + flex: 1; + min-width: 0; + cursor: text; +} +/* Hide decorative SVG icons in the topbar */ +.daw-url-zone > svg { display: none; } +.daw-process-btn > svg { display: none; } +.daw-url-zone svg { flex-shrink: 0; } + +.daw-url-zone input[type="url"] { + flex: 1; + min-width: 0; + background: none; + border: none; + outline: none; + color: var(--fg-2); + font-size: 14px; + font-family: inherit; + letter-spacing: -0.005em; +} +.daw-url-zone input[type="url"]::placeholder { color: var(--muted); } + +/* Upload button inside URL zone */ +.daw-upload-btn { + width: 30px; + height: 30px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + background: none; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--muted); + cursor: pointer; + transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast); +} +.daw-upload-btn:hover { background: var(--panel-2); color: var(--fg-2); border-color: var(--border-strong); } + +/* File pill (shown when file selected) */ +.file-pill { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 8px; + border: 1px solid var(--border-strong); + border-radius: 6px; + background: var(--panel-2); + color: var(--fg-2); + font-size: 11px; + white-space: nowrap; + overflow: hidden; +} +.file-pill.hidden { display: none !important; } +.file-name { overflow: hidden; text-overflow: ellipsis; max-width: 140px; } +.file-size { color: var(--muted); flex-shrink: 0; } +.file-clear { + width: 16px; height: 16px; + background: none; border: none; + color: var(--muted); cursor: pointer; + font-size: 14px; line-height: 1; + display: flex; align-items: center; justify-content: center; + border-radius: 3px; flex-shrink: 0; padding: 0; +} +.file-clear:hover { color: var(--fg); background: var(--panel-3); } + +/* url-wrap alias (JS uses .url-wrap for drag events) */ +.daw-url-zone { position: relative; } +.daw-url-zone.drag-over { + background: rgba(244,183,64,0.06); + box-shadow: inset 0 0 0 1px rgba(244,183,64,0.3); +} +.daw-url-zone.has-file input[type="url"] { display: none; } + +/* Composer vertical divider */ +.daw-composer-sep { + width: 1px; + background: var(--border); + align-self: stretch; + margin: 6px 0; + flex-shrink: 0; +} + +/* Stem section */ +.daw-stem-section { + display: flex; + align-items: center; + gap: 10px; + padding: 0 14px; + flex-shrink: 0; +} +.daw-extract-label { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted); +} +.daw-stem-chips { display: flex; gap: 4px; } + +/* Stem choice buttons */ +.stem-choice { + height: 30px; + padding: 0 11px; + border-radius: 6px; + border: 1px solid var(--border); + background: none; + color: var(--muted-2); + font-size: 12px; + font-weight: 600; + font-family: inherit; + letter-spacing: -0.005em; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast); +} +.stem-choice[aria-pressed="true"] { + border-color: color-mix(in srgb, var(--color) 60%, transparent); + background: color-mix(in srgb, var(--color) 14%, transparent); + color: var(--color); +} +.stem-dot { + width: 5px; height: 5px; + border-radius: 50%; + background: currentColor; + opacity: 0.85; +} +.stem-choice[aria-pressed="false"] .stem-dot { opacity: 0.4; } + +/* "All" toggle */ +.stem-choice-all { + border-color: var(--border-strong); + color: var(--fg-2); + font-size: 12px; +} +.stem-choice-all[aria-pressed="true"] { + border-color: var(--accent); + background: rgba(244,183,64,0.12); + color: var(--accent); +} + +/* Process button */ +.daw-process-btn { + height: auto; + padding: 0 20px; + background: linear-gradient(180deg, var(--accent), var(--accent-2)); + border: none; + border-left: 1px solid #b97f1c; + color: #1a1206; + font-weight: 600; + font-size: 14px; + font-family: inherit; + display: inline-flex; + align-items: center; + gap: 7px; + cursor: pointer; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.22); + flex-shrink: 0; + transition: background var(--t-fast); +} +.daw-process-btn:hover { background: linear-gradient(180deg, #f8c054, #e0a032); } +.daw-process-btn:active { transform: translateY(1px); } +.daw-process-btn:disabled { + opacity: 0.8; + cursor: progress; + filter: saturate(0.85); +} + +/* Loading spinner on process btn */ +.daw-process-btn.loading > svg { display: none; } +.daw-process-btn.loading { filter: saturate(0.85); } +.daw-process-btn.loading::before { + content: ""; + width: 14px; height: 14px; flex: 0 0 14px; + border-radius: 50%; + background: conic-gradient(currentColor 0deg 270deg, transparent 270deg 360deg); + -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 0); + mask: radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 0); + animation: daw-spin 0.85s linear infinite; +} +@keyframes daw-spin { to { transform: rotate(360deg); } } + +/* Icon button */ +.daw-iconbtn { + width: 30px; height: 30px; + display: inline-flex; align-items: center; justify-content: center; + background: transparent; + border: 1px solid transparent; + color: var(--fg-2); + border-radius: 8px; + cursor: pointer; + transition: background var(--t-fast), color var(--t-fast); + flex-shrink: 0; +} +.daw-iconbtn:hover { background: var(--panel-2); border-color: var(--border); color: var(--fg); } +.daw-iconbtn:active { transform: scale(0.96); } + +/* Notification panel */ +.daw-notif-wrap { position: relative; } +.daw-notif-panel { + display: none; + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 280px; + background: var(--panel-2); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + box-shadow: 0 8px 32px rgba(0,0,0,0.5); + z-index: 200; + overflow: hidden; +} +.daw-notif-wrap.open .daw-notif-panel { display: block; } +.daw-notif-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px 8px; + border-bottom: 1px solid var(--border); +} +.daw-notif-close { + width: 22px; height: 22px; + background: none; border: none; + color: var(--muted); cursor: pointer; + display: flex; align-items: center; justify-content: center; + border-radius: 5px; +} +.daw-notif-close:hover { background: var(--panel-3); color: var(--fg); } +.daw-notif-list { padding: 8px; display: flex; flex-direction: column; gap: 6px; } +.daw-notif-card { + display: flex; + gap: 10px; + align-items: flex-start; + padding: 10px 12px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; +} +.daw-notif-release { border-color: rgba(244,183,64,0.25); } +.daw-notif-card-icon { + width: 28px; height: 28px; flex-shrink: 0; + display: flex; align-items: center; justify-content: center; + background: rgba(244,183,64,0.12); + border-radius: 6px; + color: var(--accent); +} +.daw-notif-card-body { flex: 1; min-width: 0; } +.daw-notif-card-title { font-size: 12px; font-weight: 600; color: var(--fg); } +.daw-notif-card-desc { font-size: 11px; color: var(--muted); margin-top: 2px; } +.daw-notif-badge { + position: absolute; top: 4px; right: 4px; + width: 7px; height: 7px; + background: #f4b740; border-radius: 50%; + pointer-events: none; +} +.daw-notif-dismiss { + flex-shrink: 0; align-self: flex-start; + width: 18px; height: 18px; + background: none; border: none; cursor: pointer; + color: var(--muted); display: flex; align-items: center; justify-content: center; + border-radius: 4px; padding: 0; +} +.daw-notif-dismiss:hover { background: var(--panel-3); color: var(--fg); } +.daw-notif-empty { + font-size: 12px; color: var(--muted); + text-align: center; padding: 16px 0; margin: 0; +} + +/* Hidden collapsed strip (JS compat, not shown in new design) */ +.appbar-icon-strip.hidden { display: none !important; } + +/* ═══════════════════════════════════ + BODY LAYOUT + ═══════════════════════════════════ */ +.daw-body { + flex: 1; + display: flex; + min-height: 0; +} + +/* ═══════════════════════════════════ + SIDEBAR / CATALOG + ═══════════════════════════════════ */ +.sidebar { + width: 390px; + flex-shrink: 0; + background: var(--bg-2); + border-right: 1px solid var(--border); + display: flex; + overflow: hidden; + transition: width 0.28s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Rail */ +.sidebar-rail { + width: 66px; + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + align-items: center; + padding: 12px 0; + gap: 4px; + flex-shrink: 0; +} + +.rail-btn { + width: 40px; height: 40px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + border-radius: 8px; + color: var(--muted); + font-size: 9px; + font-family: var(--font-sans); + cursor: pointer; + transition: background var(--t-fast), color var(--t-fast); + border: none; + background: none; + /* remove old pseudo-element strip */ +} +.rail-btn::before { display: none !important; } +.rail-btn:hover { background: var(--panel); color: var(--fg-2); } +.rail-btn.active { color: var(--accent); background: var(--panel); } +.rail-btn svg { flex-shrink: 0; } +.rail-btn span { white-space: nowrap; } +/* "We Recommend" is wider than the other one-word rail labels, so it stacks onto + two centered lines; let the button grow so the second line + icon aren't clipped. */ +.rail-btn.rail-recommend { height: auto; min-height: 40px; padding: 3px 0; } +.rail-btn.rail-recommend span { white-space: normal; text-align: center; line-height: 1.1; } + +/* Sidebar body */ +.sidebar-body { + flex: 1; + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; + overflow: hidden; + opacity: 1; + transition: opacity 0.15s ease; +} + +/* Search */ +.daw-search { + height: 32px; + border: 1px solid var(--border); + background: var(--panel); + border-radius: 8px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 10px; + color: var(--muted); + font-size: 12px; + flex-shrink: 0; + position: relative; +} +.daw-search svg { flex-shrink: 0; } +.daw-search input { + flex: 1; min-width: 0; + border: none; outline: none; + background: transparent; + color: var(--fg); + font: inherit; +} +.daw-search input::placeholder { color: var(--muted); } +.search-kbd { + font-size: 10px; + color: var(--muted-2); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0 5px; + flex-shrink: 0; +} + +/* Lib header row */ +.daw-lib-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 4px; + min-height: 22px; + flex-shrink: 0; +} +.daw-lib-label { + display: flex; + align-items: center; + gap: 4px; +} +.daw-lib-count { color: var(--muted-2); font-weight: 500; margin-left: 4px; } +.new-folder-btn { + display: inline-flex; align-items: center; gap: 5px; + padding: 3px 9px 3px 7px; + background: none; + border: 1px solid var(--border); + border-radius: 5px; + color: var(--muted); + font-size: 11px; font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast); +} +.new-folder-btn:hover { + background: var(--panel); + color: var(--fg-2); + border-color: var(--border-2, rgba(255,255,255,0.15)); +} + +/* Lib list (JS populates with .lib-item, .folder, .cat-item) */ +.daw-lib-list { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 2px; +} + +/* Thin auto-hide scrollbar shared by the library list, the mixer column and the + waveform panel (both axes) — hidden until the area is hovered/focused, reserves + no visible gutter. `.daw .wave-scroll` overrides the gold bar from waves.css. */ +.daw-lib-list, +.mixer-column, +.daw .wave-scroll { + /* Firefox: thin overlay-style bar, fades to transparent track */ + scrollbar-width: thin; + scrollbar-color: transparent transparent; +} +.daw-lib-list:hover, +.daw-lib-list:focus-within, +.mixer-column:hover, +.mixer-column:focus-within, +.daw .wave-scroll:hover, +.daw .wave-scroll:focus-within { + scrollbar-color: rgba(148, 163, 184, 0.4) transparent; +} +/* WebKit (Chrome/Safari/WKWebView): thin thumb, hidden until hover/scroll */ +.daw-lib-list::-webkit-scrollbar, +.mixer-column::-webkit-scrollbar, +.daw .wave-scroll::-webkit-scrollbar { + width: 8px; + height: 8px; +} +.daw-lib-list::-webkit-scrollbar-track, +.mixer-column::-webkit-scrollbar-track, +.daw .wave-scroll::-webkit-scrollbar-track { + background: transparent; +} +.daw-lib-list::-webkit-scrollbar-thumb, +.mixer-column::-webkit-scrollbar-thumb, +.daw .wave-scroll::-webkit-scrollbar-thumb { + background: transparent; + border: 2px solid transparent; + background-clip: padding-box; + border-radius: 999px; +} +.daw-lib-list:hover::-webkit-scrollbar-thumb, +.daw-lib-list:focus-within::-webkit-scrollbar-thumb, +.mixer-column:hover::-webkit-scrollbar-thumb, +.mixer-column:focus-within::-webkit-scrollbar-thumb, +.daw .wave-scroll:hover::-webkit-scrollbar-thumb, +.daw .wave-scroll:focus-within::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.4); + background-clip: padding-box; +} +.daw-lib-list::-webkit-scrollbar-thumb:hover, +.mixer-column::-webkit-scrollbar-thumb:hover, +.daw .wave-scroll::-webkit-scrollbar-thumb:hover { + background: rgba(148, 163, 184, 0.6); + background-clip: padding-box; +} + +/* Library item */ +.lib-item { + display: flex; + gap: 10px; + align-items: center; + padding: 8px; + border-radius: 8px; + cursor: pointer; + position: relative; + transition: background var(--t-fast); +} +.lib-item:hover { background: var(--panel); } +.lib-item.active { + background: linear-gradient(90deg, rgba(244,183,64,0.10) 0%, var(--panel-2) 60%); +} +.lib-item.active::before { + content: ''; + position: absolute; left: -2px; top: 6px; bottom: 6px; + width: 3px; border-radius: 2px; + background: var(--accent); +} +.lib-cover { + width: 36px; height: 36px; border-radius: 6px; + display: flex; align-items: center; justify-content: center; + font-size: 9px; font-weight: 700; letter-spacing: 0.1em; + color: rgba(255,255,255,0.85); + flex-shrink: 0; +} +.lib-meta { flex: 1; min-width: 0; } +.lib-meta .t { font-size: 13px; color: var(--fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.lib-meta .s { font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.lib-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } + +/* cat-item (legacy catalog items JS creates) */ +.cat-item { + display: flex; + gap: 9px; + align-items: center; + padding: 7px 8px; + border: 1px solid transparent; + border-radius: 8px; + cursor: pointer; + transition: background var(--t-fast), border-color var(--t-fast); +} +.cat-item:hover { background: var(--panel-2); border-color: var(--border); } +.cat-item.active { background: var(--panel-2); border-color: rgba(244,183,64,0.3); } +.cat-thumb { + width: 36px; height: 36px; + border-radius: 6px; flex-shrink: 0; + overflow: hidden; border: 1px solid var(--border); + background: var(--panel); + display: flex; align-items: center; justify-content: center; + color: var(--muted); +} +.cat-thumb svg, .cat-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } +.cat-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; } +.cat-title { color: var(--fg); font-size: 12px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.cat-sub { color: var(--muted); font-size: 10.5px; display: flex; gap: 5px; white-space: nowrap; overflow: hidden; } +.cat-status { width: 6px; height: 6px; border-radius: 50%; background: #5fbc56; flex-shrink: 0; } +.cat-status.processing { background: var(--accent); animation: cat-pulse 1.4s infinite; } +.cat-status.unavailable { background: #666; } +.cat-item.unavailable { cursor: not-allowed; } +.cat-item.unavailable .cat-meta { opacity: 0.45; } +@keyframes cat-pulse { 0%,100%{opacity:1;} 50%{opacity:0.3;} } +.cat-del { + width: 20px; height: 20px; border: 0; border-radius: 5px; + background: transparent; color: var(--muted); cursor: pointer; + opacity: 0; display: flex; align-items: center; justify-content: center; + padding: 0; flex-shrink: 0; transition: opacity var(--t-fast), background var(--t-fast); +} +.cat-item:hover .cat-del { opacity: 1; } +.cat-del:hover { background: rgba(21,31,39,0.8); color: var(--danger); } + +/* Folder items (legacy catalog) */ +.folder { display: flex; flex-direction: column; } +.folder-head { + --folder-color: var(--accent); + display: flex; align-items: center; gap: 7px; + padding: 6px 8px; border-radius: 7px; + cursor: pointer; color: var(--muted); + transition: background var(--t-fast), color var(--t-fast); + user-select: none; +} +.folder-head:hover { background: var(--panel); color: var(--fg); } +.folder-head .f-chevron { width: 11px; height: 11px; transition: transform 180ms ease; flex-shrink: 0; color: var(--muted); } +.folder.collapsed .folder-head .f-chevron { transform: rotate(-90deg); } +.folder-head .f-icon { width: 13px; height: 13px; flex-shrink: 0; color: var(--folder-color); } +.folder-head .f-name { + font-size: 12px; font-weight: 500; color: inherit; + flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + background: transparent; border: none; padding: 0; font-family: inherit; outline: none; cursor: inherit; +} +.folder-head .f-count { font-size: 10.5px; color: var(--muted); } +.folder-color-dot { + width: 13px; height: 13px; border: 1px solid rgba(255,255,255,0.22); + border-radius: 999px; background: var(--folder-color); cursor: pointer; padding: 0; + box-shadow: inset 0 0 0 1px rgba(0,0,0,0.22); +} +.folder-head .f-del, +.folder-head .f-subfolder { + width: 18px; height: 18px; border: none; background: transparent; + color: var(--muted); cursor: pointer; opacity: 0; + display: flex; align-items: center; justify-content: center; + border-radius: 4px; padding: 0; transition: opacity var(--t-fast), color var(--t-fast); + flex-shrink: 0; +} +.folder-head:hover .f-del, +.folder-head:hover .f-subfolder { opacity: 1; } +.folder-head .f-del:hover { background: rgba(21,31,39,0.8); color: var(--danger); } +.folder-head .f-subfolder:hover { background: rgba(21,31,39,0.8); color: var(--fg-2); } +/* Drag grip */ +.f-grip { + display: none; align-items: center; justify-content: center; + cursor: grab; color: var(--muted); flex-shrink: 0; + padding: 2px; border-radius: 3px; + transition: color var(--t-fast); +} +.f-grip:active { cursor: grabbing; } +.folder-head:hover .f-grip { display: flex; } +/* Drop indicators for folder reorder / nest */ +.folder.drop-before > .folder-head { box-shadow: 0 -2px 0 var(--accent); border-radius: 7px 7px 0 0; } +.folder.drop-after > .folder-head { box-shadow: 0 2px 0 var(--accent); border-radius: 0 0 7px 7px; } +.folder.drop-into > .folder-head { box-shadow: 0 0 0 1px var(--accent); background: var(--panel); } +/* Folder being dragged */ +.folder.folder-dragging { opacity: 0.35; pointer-events: none; } +/* Subfolder indentation */ +.folder.subfolder { margin-left: 12px; } +.folder-body { display: flex; flex-direction: column; gap: 3px; padding: 3px 0 3px 14px; margin-left: 7px; border-left: 1px solid rgba(157,170,182,0.1); } +.folder.collapsed .folder-body { display: none; } +.folder-empty { color: var(--muted); font-size: 11px; padding: 5px 8px; font-style: italic; } + +/* Folder editor modal */ +.folder-editor-backdrop { + position: fixed; inset: 0; z-index: 80; + display: grid; place-items: center; + background: rgba(3,8,13,0.42); backdrop-filter: blur(2px); +} +.folder-editor { + width: min(300px, calc(100vw - 32px)); + border: 1px solid var(--border-strong); + border-radius: 10px; + background: linear-gradient(180deg, rgba(28,39,48,0.98), rgba(13,21,29,0.98)); + box-shadow: var(--shadow-panel); + padding: 12px; color: var(--fg); font-family: var(--font-mono); +} +.folder-editor-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; font-size: 13px; font-weight: 600; } +.folder-editor-close { width: 24px; height: 24px; border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--muted); cursor: pointer; display: grid; place-items: center; padding: 0; } +.folder-editor-close:hover { background: rgba(21,31,39,0.8); color: var(--fg); } +.folder-editor-field { display: grid; gap: 7px; margin-bottom: 12px; color: var(--muted); font-size: 10.5px; } +.folder-editor-name { width: 100%; min-height: 34px; border: 1px solid var(--border); border-radius: 7px; background: rgba(10,17,24,0.78); color: var(--fg); font: inherit; font-size: 12px; padding: 0 10px; outline: none; } +.folder-editor-name:focus { border-color: rgba(244,183,64,0.5); box-shadow: 0 0 0 2px rgba(244,183,64,0.12); } +.folder-editor-colors { display: flex; align-items: center; gap: 9px; } +.folder-editor-colors .folder-color-dot { width: 22px; height: 22px; } +.folder-editor-msg { color: var(--danger); font-size: 11px; margin-top: 8px; } +.folder-editor-msg:empty { display: none; } +.folder-editor-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; } +.folder-editor-actions button { min-height: 30px; border-radius: 7px; font-family: var(--font-mono); font-size: 11px; cursor: pointer; padding: 0 11px; } +.folder-editor-cancel { border: 1px solid var(--border); background: rgba(21,31,39,0.52); color: var(--muted); } +.folder-editor-save { border: 1px solid rgba(244,183,64,0.3); background: rgba(244,183,64,0.16); color: var(--accent); } + +/* Edit Library modal (Settings → opens this centered window) */ +.library-editor-backdrop { + position: fixed; inset: 0; z-index: 80; + display: grid; place-items: center; + background: rgba(3,8,13,0.42); backdrop-filter: blur(2px); +} +.library-editor { + width: min(560px, calc(100vw - 32px)); + max-height: min(70vh, 620px); + display: flex; flex-direction: column; + border: 1px solid var(--border-strong); + border-radius: 10px; + background: linear-gradient(180deg, rgba(28,39,48,0.98), rgba(13,21,29,0.98)); + box-shadow: var(--shadow-panel); + padding: 12px; color: var(--fg); font-family: var(--font-mono); +} +.library-editor-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; font-size: 13px; font-weight: 600; } +.library-editor-close { width: 24px; height: 24px; border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--muted); cursor: pointer; display: grid; place-items: center; padding: 0; } +.library-editor-close:hover { background: rgba(21,31,39,0.8); color: var(--fg); } +.library-editor-table-wrap { flex: 1; min-height: 0; overflow-y: auto; border: 1px solid var(--border); border-radius: 7px; background: rgba(10,17,24,0.5); } +.library-editor-table { width: 100%; border-collapse: collapse; font-size: 11.5px; } +.library-editor-table thead th { + position: sticky; top: 0; + text-align: left; font-weight: 600; font-size: 10px; + text-transform: uppercase; letter-spacing: 0.04em; + color: var(--muted); background: rgba(13,21,29,0.96); + padding: 8px 10px; border-bottom: 1px solid var(--border); +} +.library-editor-table tbody td { padding: 7px 10px; border-bottom: 1px solid rgba(148,163,184,0.07); color: var(--fg-2); vertical-align: middle; } +.library-editor-table tbody tr:last-child td { border-bottom: none; } +.library-editor-table .le-name { color: var(--fg); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.library-editor-table .le-loc { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.library-editor-table tr.unavailable .le-name { color: var(--danger); } +.le-badge { margin-left: 7px; padding: 1px 6px; border-radius: 5px; font-size: 9px; text-transform: uppercase; letter-spacing: 0.04em; background: rgba(214,90,74,0.16); color: var(--danger); } +.library-editor-empty { color: var(--muted); text-align: center; padding: 22px 10px !important; } +/* Settings → tabs */ +.settings-tabs { display: flex; gap: 2px; margin-bottom: 12px; border-bottom: 1px solid var(--border); } +.settings-tab { background: none; border: none; border-bottom: 2px solid transparent; color: var(--muted); font-family: var(--font-mono); font-size: 12px; font-weight: 600; padding: 5px 12px 9px; cursor: pointer; margin-bottom: -1px; } +.settings-tab:hover { color: var(--fg-2); } +.settings-tab.active { color: var(--fg); border-bottom-color: var(--accent); } +.settings-pane { display: flex; flex-direction: column; min-height: 0; } +.settings-pane[data-pane="general"] { flex: 1; } +.settings-pane[data-pane="advanced"] { flex: 1; overflow-y: auto; } +.settings-pane.hidden { display: none; } +.settings-pane[data-pane="advanced"] .library-editor-table-wrap { flex: none; max-height: 240px; margin-bottom: 2px; } +.settings-empty { color: var(--muted); font-size: 12px; text-align: center; padding: 28px 10px; } +.settings-foot { display: flex; justify-content: flex-end; margin-top: 12px; padding-top: 11px; border-top: 1px solid var(--border); } +.settings-done { min-height: 32px; border-radius: 7px; border: 1px solid rgba(244,183,64,0.35); background: rgba(244,183,64,0.16); color: var(--accent); font-family: var(--font-mono); font-size: 12px; font-weight: 600; padding: 0 22px; cursor: pointer; } +.settings-done:hover { background: rgba(244,183,64,0.24); } +/* Right-aligned form controls share a fixed width so they line up down the column. */ +.settings-num-input, .settings-select { flex-shrink: 0; width: 84px; background: rgba(10,17,24,0.6); border: 1px solid var(--border-strong); border-radius: 7px; color: var(--fg); font-family: var(--font-mono); font-size: 12px; padding: 6px 9px; } +.settings-num-input { text-align: right; } +.settings-select { cursor: pointer; } +.settings-num-input:focus, .settings-select:focus { outline: none; border-color: rgba(244,183,64,0.5); } + +/* Settings → network access section */ +.settings-section { margin-bottom: 12px; } +.settings-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; } +.settings-row-text { min-width: 0; } +.settings-row-title { font-size: 12.5px; font-weight: 600; color: var(--fg); } +.settings-row-desc { font-size: 10.5px; color: var(--muted); margin-top: 3px; line-height: 1.45; } +.settings-switch { position: relative; flex-shrink: 0; width: 42px; height: 24px; cursor: pointer; } +.settings-switch input { position: absolute; opacity: 0; width: 100%; height: 100%; margin: 0; cursor: pointer; } +.settings-switch-track { position: absolute; inset: 0; border-radius: 999px; background: rgba(148,163,184,0.22); border: 1px solid var(--border-strong); transition: background 0.15s ease; } +.settings-switch-thumb { position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: var(--fg); transition: transform 0.15s ease; } +.settings-switch input:checked + .settings-switch-track { background: rgba(244,183,64,0.55); border-color: rgba(244,183,64,0.5); } +.settings-switch input:checked + .settings-switch-track .settings-switch-thumb { transform: translateX(18px); background: var(--accent); } +.settings-switch.disabled { cursor: default; opacity: 0.7; } +.settings-switch.disabled input { cursor: default; } +.settings-net { margin-top: 10px; font-size: 11px; color: var(--muted); } +.settings-net.hidden { display: none; } +.settings-net-empty { color: var(--muted); } +.settings-net-qr { display: flex; flex-direction: column; gap: 14px; margin-top: 6px; } +.qr-hint { font-size: 10px; color: var(--muted); margin: 0; line-height: 1.4; } +.qr-cards-row { display: flex; flex-wrap: wrap; gap: 28px; } +.qr-card { display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; } +.qr-img-wrap { width: 130px; height: 130px; border-radius: 6px; border: 3px solid var(--accent); overflow: hidden; box-sizing: border-box; } +.qr-card img { width: 100%; height: 100%; display: block; transition: filter 0.25s ease, scale 0.25s ease; } +.qr-card.qr-blurred img { filter: blur(10px); scale: 1.12; } +.qr-label { font-size: 9.5px; color: var(--muted); text-align: center; max-width: 130px; word-break: break-all; } +.settings-server-note { font-size: 10.5px; color: var(--muted); margin: 0 0 12px; line-height: 1.5; } +.settings-subhead { font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); font-weight: 600; margin: 4px 0 7px; } + +.library-editor-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 11px; } +.library-editor-status { font-size: 10.5px; color: var(--muted); } +.library-editor-status.out-of-sync { color: var(--danger); font-weight: 600; } +.library-editor-sync { min-height: 30px; border-radius: 7px; border: 1px solid rgba(244,183,64,0.3); background: rgba(244,183,64,0.16); color: var(--accent); font-family: var(--font-mono); font-size: 11px; padding: 0 13px; cursor: pointer; } +.library-editor-sync:disabled { opacity: 0.55; cursor: default; } + +/* Color picker popover */ +.color-picker-popover { + position: absolute; z-index: 90; + background: var(--panel-2); border: 1px solid var(--border-strong); + border-radius: 8px; padding: 8px; display: flex; gap: 6px; + box-shadow: var(--shadow-panel); +} + +/* Collapsed sidebar strip */ +.cat-collapsed-strip { display: none; } +.daw.cat-collapsed .sidebar { width: 66px; } +.daw.cat-collapsed .sidebar-body { opacity: 0; pointer-events: none; visibility: hidden; } +.daw.cat-collapsed .cat-collapsed-strip { display: flex; flex-direction: column; gap: 8px; align-items: center; padding: 8px 0; overflow-y: auto; } + +/* ═══════════════════════════════════ + MAIN AREA (#lanes) + ═══════════════════════════════════ */ +.daw-main-col { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} +.daw-main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +/* ── Job progress overlay ── */ +.job { + position: absolute; + inset: 0; + z-index: 20; + background: rgb(7, 13, 19); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 32px; +} +/* Gold ambient glow -- matches the wave-loading-overlay aesthetic */ +.job::before { + content: ""; + position: absolute; + inset: 0; + background: radial-gradient(circle at 50% 44%, rgba(216, 168, 74, 0.13), transparent 55%); + pointer-events: none; +} +.job > * { position: relative; z-index: 1; } +.job.hidden { display: none !important; } +.job .title { font-size: 15px; font-weight: 600; color: var(--fg); text-align: center; } +.job .stage { font-size: 12px; color: var(--muted); text-align: center; } +.job progress { + width: 280px; max-width: 100%; + height: 4px; + border-radius: 2px; + appearance: none; border: none; + background: var(--panel-3); + overflow: hidden; +} +.job progress::-webkit-progress-bar { background: var(--panel-3); border-radius: 2px; } +.job progress::-webkit-progress-value { background: var(--accent); border-radius: 2px; } +.job progress::-moz-progress-bar { background: var(--accent); border-radius: 2px; } +.job .job-detail { font-size: 11px; color: var(--muted-2); text-align: center; } +.cancel-btn { + height: 30px; padding: 0 16px; + background: var(--panel-2); border: 1px solid var(--border-strong); + color: var(--fg-2); border-radius: 8px; font-size: 12px; + font-family: inherit; cursor: pointer; + transition: background var(--t-fast); +} +.cancel-btn:hover { background: var(--panel-3); color: var(--fg); } +.cancel-btn.hidden { display: none !important; } + +/* Error banner */ +.error { + position: absolute; + top: 64px; left: 260px; right: 0; + z-index: 15; + padding: 12px 20px; + background: rgba(214,90,74,0.12); + border-bottom: 1px solid rgba(214,90,74,0.3); + color: #ff9090; + font-size: 13px; +} +.error.hidden { display: none !important; } + +/* ── Track header ── */ +.daw-track-header { + display: flex; + flex-direction: column; + border-bottom: 1px solid var(--border); + background: var(--bg-2); + flex-shrink: 0; +} + +/* Row 1 */ +.daw-info-row { + display: flex; + border-bottom: 1px solid var(--border); +} + +/* Track card */ +.daw-track-card { + display: flex; + gap: 14px; + align-items: flex-start; + padding: 14px 16px; + flex-shrink: 0; + width: 340px; + border-right: 1px solid var(--border); +} + +/* Metadata cards */ +.daw-meta-card { + flex: 1; + padding: 14px 16px; + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; +} +.daw-meta-card:last-child { border-right: none; } +.meta-card-label { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--muted); +} +.meta-card-main { + display: flex; + align-items: center; + gap: 8px; +} +.meta-card-value { + font-size: 22px; + font-weight: 700; + color: var(--fg); + line-height: 1; + font-family: var(--font-mono); +} +.meta-card-value.accent { color: var(--accent); } +.meta-card-value.stability-high { color: #4caf7d; } +.meta-card-sub { + font-size: 11px; + color: var(--muted); +} +.daw-cover { + width: 64px; height: 64px; + border-radius: 8px; + background: var(--panel-2); + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; color: var(--muted); + position: relative; overflow: hidden; +} +.np-art-placeholder { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; } +.np-art-img { + position: absolute; inset: 0; + width: 100%; height: 100%; object-fit: cover; display: none; +} +.np-art-img[src]:not([src=""]) { display: block; } + +.daw-track-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; } +.daw-track-title { font-size: 16px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.daw-track-sub { font-size: 11px; color: var(--muted); } +.daw-track-time-row { + display: flex; + align-items: center; + gap: 6px; +} +.daw-track-sep { color: var(--border); font-size: 12px; } +.daw-track-stems-label { + font-size: 11px; + color: var(--muted); + display: flex; + align-items: center; + gap: 4px; +} +.daw-track-stems-label::before { + content: ''; + display: inline-block; + width: 7px; height: 7px; + border-radius: 50%; + background: #4caf7d; + flex-shrink: 0; +} +.daw-fav-btn { + margin-left: auto; + background: none; + border: none; + cursor: pointer; + color: var(--muted); + padding: 2px; + display: flex; + align-items: center; + transition: color 150ms; +} +.daw-fav-btn:hover { color: var(--fg); } +.daw-fav-btn.active { color: #e54e4e; } +.daw-fav-btn.active svg { fill: #e54e4e; stroke: #e54e4e; } +.daw-track-details { display: flex; flex-direction: column; gap: 3px; margin-top: 2px; } +.daw-detail-row { display: flex; gap: 8px; align-items: baseline; } +.daw-detail-label { font-size: 11px; color: var(--muted); width: 64px; flex-shrink: 0; } +.daw-detail-val { font-size: 11px; color: var(--fg-2); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.daw-chip { + display: inline-flex; align-items: center; + padding: 2px 8px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--panel-2); + font-size: 11px; + color: var(--fg-2); + font-family: var(--font-mono); +} + +/* Energy panel */ +.daw-energy-panel { + padding: 12px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} +.daw-energy-header { + display: flex; + align-items: center; + justify-content: space-between; +} +.daw-playhead-label { color: var(--accent); display: flex; align-items: center; gap: 4px; } +.daw-energy-bars { flex: 1; display: flex; flex-direction: column; justify-content: space-between; gap: 4px; } + +/* Energy rows (JS-driven) */ +.energy-row { + display: flex; + align-items: center; + gap: 10px; +} +.energy-row span { + font-size: 11px; + font-weight: 500; + width: 44px; + flex-shrink: 0; +} +.energy-row.vocals span { color: var(--vocals); } +.energy-row.drums span { color: var(--drums); } +.energy-row.bass span { color: var(--bass); } +.energy-row.guitar span { color: var(--guitar); } +.energy-row.piano span { color: var(--piano); } +.energy-row.other span { color: var(--other); } +.energy-row.original span { color: var(--fg-2); } + +.energy-row b { + flex: 1; + height: 6px; + border-radius: 3px; + background: var(--bg); + overflow: hidden; + position: relative; + display: block; +} +.energy-row b::after { + content: ''; + position: absolute; inset: 0; + width: var(--v, 0%); + border-radius: 3px; + transition: width 160ms; +} +.energy-row.vocals b::after { background: var(--vocals); } +.energy-row.drums b::after { background: var(--drums); } +.energy-row.bass b::after { background: var(--bass); } +.energy-row.guitar b::after { background: var(--guitar); } +.energy-row.piano b::after { background: var(--piano); } +.energy-row.other b::after { background: var(--other); opacity: 0.5; } +.energy-row.original b::after { background: var(--fg-2); } + +.energy-row em { + font-style: normal; + font-family: var(--font-mono); + font-size: 11px; + color: var(--fg-2); + width: 36px; + text-align: right; + flex-shrink: 0; +} + +/* Stem presence cards — row 2 */ +.stem-presence-panel { + display: grid; + grid-template-columns: repeat(6, 1fr); +} +.stem-card { + padding: 14px 16px; + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 6px; +} +.stem-card:last-child { border-right: none; } +.stem-card-label { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--muted); +} +.stem-card-pct { + font-size: 28px; + font-weight: 700; + font-family: var(--font-mono); + line-height: 1; + color: var(--fg); + transition: color 200ms; +} +.stem-card:not(.inactive)[data-stem="vocals"] .stem-card-pct { color: var(--vocals); } +.stem-card:not(.inactive)[data-stem="drums"] .stem-card-pct { color: var(--drums); } +.stem-card:not(.inactive)[data-stem="bass"] .stem-card-pct { color: var(--bass); } +.stem-card:not(.inactive)[data-stem="guitar"] .stem-card-pct { color: var(--guitar); } +.stem-card:not(.inactive)[data-stem="piano"] .stem-card-pct { color: var(--piano); } +.stem-card:not(.inactive)[data-stem="other"] .stem-card-pct { color: var(--other); } +.stem-card.inactive .stem-card-pct { color: var(--muted); } + +/* Analysis panel */ +.daw-analysis-panel { + padding: 12px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + display: flex; + gap: 16px; + flex-shrink: 0; +} +.daw-analysis-item { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 80px; +} +.daw-analysis-val { display: flex; align-items: center; gap: 8px; margin-top: 2px; } +.daw-analysis-num { + font-size: 22px; + font-weight: 600; + line-height: 1.1; + color: var(--fg); +} +.daw-analysis-num.accent { color: var(--accent); } +.daw-analysis-sub { font-size: 10px; color: var(--muted); margin-top: 2px; } +.daw-analysis-divider { width: 1px; background: var(--border); align-self: stretch; } +.daw-camelot-ring { + width: 28px; height: 28px; border-radius: 50%; + border: 1.5px solid var(--accent); + display: flex; align-items: center; justify-content: center; + font-size: 10px; font-weight: 700; color: var(--accent); + background: rgba(244,183,64,0.08); + font-family: var(--font-mono); +} + +/* ── Section ribbon ── */ +.daw-section-ribbon { + display: flex; + flex-shrink: 0; + border-bottom: 1px solid var(--border); +} +.daw-mixer-label { + width: 300px; + flex-shrink: 0; + background: var(--bg-2); + border-right: 1px solid var(--border); + padding: 6px 14px; + display: flex; + align-items: center; + justify-content: space-between; +} +.daw-label-title { font-size: 12px; font-weight: 600; } +.daw-label-sub { font-size: 9px; } +.daw-sections-header { justify-content: space-between; } +.daw-sections-area { + flex: 1; + position: relative; + height: 36px; + background: var(--bg-2); + overflow: hidden; + user-select: none; +} + +/* Section blocks (sections.js populates) */ +.section-block { + position: absolute; + top: 4px; bottom: 4px; + box-sizing: border-box; + border: 1px solid var(--sc, #4a7fff); + border-radius: 4px; + display: flex; + align-items: center; + overflow: hidden; + cursor: grab; + transition: filter 80ms; +} +.section-block:hover { filter: brightness(1.15); } +.section-block.sec-dragging { cursor: grabbing; filter: brightness(1.2); z-index: 10; } +.section-block.sec-resizing { filter: brightness(1.2); z-index: 10; } + +.section-label { + flex: 1; + padding: 0 6px 0 10px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--sc, #4a7fff); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: auto; +} + +.section-del { + display: none; + flex-shrink: 0; + width: 16px; height: 16px; + margin-right: 4px; + border: none; background: none; + color: var(--sc, #4a7fff); + font-size: 13px; line-height: 1; + cursor: pointer; border-radius: 3px; + align-items: center; justify-content: center; + opacity: 0.7; +} +.section-del:hover { opacity: 1; background: rgba(0,0,0,0.3); } +.section-block:hover .section-del { display: flex; } + +.section-handle { + position: absolute; + top: 0; bottom: 0; + width: 6px; + cursor: col-resize; + z-index: 2; + flex-shrink: 0; +} +.section-handle-l { left: 0; } +.section-handle-r { right: 0; } +.section-handle:hover { background: rgba(255,255,255,0.12); } + +.section-rename-input { + flex: 1; + min-width: 0; + background: transparent; + border: none; + outline: none; + font: inherit; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--sc, #4a7fff); + padding: 0 6px 0 10px; + caret-color: var(--sc, #4a7fff); +} + +/* Sections save indicator */ +.sections-save-indicator { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 10px; + color: var(--muted); + white-space: nowrap; + transition: opacity 400ms; +} +.sections-save-indicator.hidden { display: none; } +.sections-save-indicator.saved { color: #4caf7d; } +.sections-save-indicator::before { + content: ""; + display: block; + width: 8px; height: 8px; + border-radius: 50%; + border: 1.5px solid currentColor; + border-top-color: transparent; + animation: sections-spin 600ms linear infinite; +} +.sections-save-indicator.saved::before { + animation: none; + border: none; + width: auto; height: auto; + content: "✓"; + font-size: 11px; +} +@keyframes sections-spin { + to { transform: rotate(360deg); } +} + +/* Add section button — lives in the Sections label column */ +.sections-add-btn-label { + display: inline-flex; align-items: center; gap: 4px; + padding: 3px 8px 3px 6px; + background: none; + border: 1px solid var(--border); + border-radius: 5px; + color: var(--muted); + font-size: 10px; font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast); +} +.sections-add-btn-label:hover { + background: var(--panel-2); + color: var(--fg); + border-color: rgba(255,255,255,0.18); +} + +/* Wave label (left column in wave header — Mixer label) */ +.daw-wave-label { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 14px; +} + +/* ── Waveform header ── */ +.daw-wave-header { + display: flex; + flex-shrink: 0; + border-bottom: 1px solid var(--border); +} +.daw-wave-label { + width: 300px; + flex-shrink: 0; + background: var(--bg-2); + border-right: 1px solid var(--border); + min-height: 32px; +} +.daw-ruler-area { + flex: 1; + background: var(--bg-2); + min-height: 32px; + /* Clip the horizontally-scrolled ruler spill. overflow-x: clip (not hidden) + keeps overflow-y visible so the vertical playhead line still extends down + over the waveform lanes. */ + overflow-x: clip; +} + +/* Ruler */ +.lanes-ruler { position: relative; height: 18px; } +.lanes-ruler-time { + position: absolute; inset: 0; + font-family: var(--font-mono); + font-size: 9px; + color: var(--muted); +} +/* Match the zoomed waveform width so ticks/playhead stay aligned; JS translates + it by scrollLeft. Left-anchored (right: auto) so width drives the size. */ +.daw .lanes-ruler-time { + right: auto; + width: calc(100% * var(--zoom, 1)); +} +.playhead-marker { + position: absolute; + top: -1px; + transform: translateX(-50%); + pointer-events: none; +} + +/* ── Content area (mixer + waveform) ── */ +.daw-content { + flex: 1; + display: flex; + min-height: 0; + overflow: hidden; +} + +/* ── Stems panel (left 300px) ── */ +.daw-stems-panel { + width: 300px; + flex-shrink: 0; + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--bg-2); +} + +/* Stem list rows — kept in DOM for JS hook (.stem-mute/.stem-solo toggling, hidden visually) */ +.stem-list { + position: absolute; + width: 0; height: 0; overflow: hidden; + visibility: hidden; pointer-events: none; + opacity: 0; +} +/* Also update the --lane-h var to match waveform lane height */ +:root { --lane-h: 72px; } /* 70px wave + 2px separator */ +.stem-icon { width: 14px; height: 14px; flex-shrink: 0; } +.stem-mute, .stem-solo { display: none; } +.stem-monitor { display: none; } +.drag-handle { display: none; } +.mini-meter { display: none; } + +/* ── Mixer column (JS-built horizontal lanes) ── */ +.mixer-column { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; +} + +/* Each mixer row height is driven by --lane-h (set dynamically in JS to fill available space) */ +.lane-header.mx-row { + display: flex; + align-items: center; + gap: 6px; + padding: 0 10px; + height: var(--lane-h, 72px); + min-height: 72px; + max-height: none; + border-bottom: 2px solid var(--border); + background: var(--bg-2); + flex-shrink: 0; + position: relative; + transition: opacity var(--t-base); + box-sizing: border-box; +} +.lane-header.mx-row.muted { opacity: 0.45; } +.lane-header.mx-row.hidden { display: none !important; } + +.lane-stripe { display: none; } + +/* Name + VU stacked vertically */ +.lane-name-vu { + display: flex; + flex-direction: column; + justify-content: center; + gap: 5px; + width: 76px; + flex-shrink: 0; +} + +/* Stem icon — hidden per user preference */ +.mx-icon { display: none; } + +/* Stem name */ +.mx-name { + font-size: 13px; font-weight: 500; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Fader (horizontal, native range input) */ +.lane-knob.mx-fader { + flex: 1; + display: flex; + align-items: center; + min-width: 0; +} +.lane-knob-indicator { display: none; } +.mx-fader-input { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 22px; + background: transparent; + cursor: ew-resize; + outline: none; + margin: 0; +} +.mx-fader-input::-webkit-slider-runnable-track { + height: 4px; + border-radius: 2px; + background: linear-gradient( + to right, + var(--fader-color, #888) 0%, + var(--fader-color, #888) calc(var(--lane-pos, 0.5) * 100%), + rgba(255,255,255,0.08) calc(var(--lane-pos, 0.5) * 100%) + ); +} +.mx-fader-input::-webkit-slider-thumb { + -webkit-appearance: none; + width: 14px; height: 14px; + border-radius: 50%; + background: var(--fader-color, #888); + margin-top: -5px; + border: 2px solid rgba(0,0,0,0.35); + box-shadow: 0 0 0 1px rgba(255,255,255,0.18); + cursor: ew-resize; +} +.mx-fader-input::-moz-range-track { + height: 4px; border-radius: 2px; + background: rgba(255,255,255,0.08); +} +.mx-fader-input::-moz-range-thumb { + width: 14px; height: 14px; + border-radius: 50%; + background: var(--fader-color, #888); + border: 2px solid rgba(0,0,0,0.35); + cursor: ew-resize; +} + +/* VU meter — sits below stem name in .lane-left-col, spans full column width */ +.lane-vu.mx-meter { + position: relative; + height: 5px; + width: 100%; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 3px; + overflow: hidden; + --vu-level: 0%; + --vu-peak: 0%; +} +.lane-vu-bar { + display: none; +} +.lane-vu-bar.mx-meter-fill { + display: block; + position: absolute; + inset: 0 auto 0 0; + width: var(--vu-level, 0%); + background: linear-gradient(90deg, #22c55e 0%, #86efac 80%, #f97316 95%, #ef4444 100%); + border-radius: 2px; + transition: width 40ms linear; +} + +/* dB value */ +.mx-val { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + color: var(--fg); + width: 36px; + text-align: right; + flex-shrink: 0; + font-variant-numeric: tabular-nums; +} + +/* M/S buttons (JS-built) */ +.lane-icon-toggle.mx-btn, +.ms-btn.mx-btn, +.lane-dl.mx-btn { + width: 22px; height: 20px; + background: transparent; + border: 1px solid var(--border-strong); + border-radius: 5px; + color: var(--muted); + font-size: 10px; font-weight: 700; + font-family: var(--font-mono); + letter-spacing: 0.05em; + cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; + transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast); + flex-shrink: 0; + text-decoration: none; + padding: 0; + opacity: 1; +} +.lane-icon-toggle.mx-btn:hover, +.ms-btn.mx-btn:hover { color: var(--fg); border-color: #3a4550; } + +/* Mute button: "active" = NOT muted (lit up) */ +.lane-icon-toggle.mx-btn.mute.active { color: var(--fg-2); border-color: var(--border-strong); } +.lane-icon-toggle.mx-btn.mute:not(.active) { opacity: 0.35; } + +/* Solo button: "active" = soloed */ +.ms-btn.mx-btn.active { background: rgba(244,183,64,0.18); color: var(--accent); border-color: rgba(244,183,64,0.4); } + +/* Download button */ +.lane-dl.mx-btn { + color: var(--muted); + border-color: transparent; +} +.lane-dl.mx-btn:hover { color: var(--fg); border-color: var(--border); } +.lane-dl.mx-btn svg { width: 13px; height: 13px; } + +/* Unavailable stems — gray entire mixer row and waveform row */ +.lane-header.unavailable { opacity: 0.25; pointer-events: none; } +.stem-waveform-row.unavailable { opacity: 0.2; pointer-events: none; } +.mx-fader-input { touch-action: pan-y; } + +/* ── Waveform panel (right) ── */ +/* Layout only — waves.css handles all visual styling inside (.wave-scroll bg, + golden scrollbar, .loop-region, .waves-grid, .stem-waveform-layer etc.) */ +.daw-wave-panel { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; +} + +/* waves.css gives .wave-editor padding + border-radius for the old rounded panel + style. In the flat DAW layout those are unwanted — strip them. */ +.daw .wave-editor, +.app:not(.is-import) .daw-wave-panel.wave-editor { + padding: 0 !important; + min-height: 0 !important; + border-radius: 0 !important; +} + +/* Our zoom toolbar sits outside .wave-editor (in .daw-wave-header), so + the wave-scroll should fill the full panel height instead of calc(100% - 32px). + Also remove the rounded corners waves.css adds in active state. */ +.daw .wave-scroll { + flex: 1 !important; + height: auto !important; + border-radius: 0 !important; + border: none !important; + overflow-y: auto; + /* waves.css sets scroll-behavior: smooth. In the studio the mixer column and + the wave lanes mirror each other's scrollTop (wireLaneScrollSync), and a + smooth programmatic scroll turns that mirror into a feedback loop: each + echo re-targets the in-flight animation, so a vertical wheel-scroll to the + middle oscillates instead of settling, i.e. the lanes shake (#182). Instant + scrolling makes the mirror a no-op once both panes match. */ + scroll-behavior: auto; +} + +/* Canvas fills the panel when lanes fit, but grows with the track stack when + they overflow (#159) so .wave-scroll can scroll the extra lanes vertically. */ +.daw .wave-canvas { min-height: 100%; height: auto; } + +/* In the original layout a 48px stem-icon strip lived INSIDE the wave panel, + offsetting waves-column/multitrack/waveform-layer by 48px. In our layout the + mixer is a separate 300px panel, so all those 48px offsets must be zero. */ +.daw .waves-column, +.app:not(.is-import) .daw-wave-panel .waves-column { + padding-left: 0 !important; + --wave-gutter: 0px !important; +} +/* Show WaveSurfer native bar rendering; hide the SVG overlay layer */ +.daw #multitrack-container { + opacity: 1 !important; + pointer-events: all !important; + position: relative !important; /* in flow → waves-column height driven by WaveSurfer */ + inset: 0 !important; /* waves.css sets inset: 0 0 0 48px; zero left offset */ +} +/* The wavesurfer-multitrack bundle wraps its tracks in its own + `
`, which renders a permanently visible + horizontal scrollbar — an empty light bar when the content fits (the "white + bar" bug). We fit the bundle to its container and let the outer .wave-scroll + own horizontal scrolling, so suppress the bundle's wrapper scrollbar. */ +.daw #multitrack-container > div { + overflow-x: hidden !important; +} +/* Zero waves.css 48px left offsets that assumed an icon strip inside the wave panel */ +.daw .waves-grid { left: 0 !important; } +.daw .lanes-ruler { margin-left: 0 !important; } +.daw .stem-waveform-layer { + display: none !important; + /* waves.css offsets this layer by left:48px for the legacy icon-strip layout. + The DAW layout has no in-panel strip, so it must start at 0 like .waves-grid + and #multitrack-container above; otherwise the overview waveform is shifted + 48px right of the ruler/playhead, so t=0 in the audio lands ~48px before the + drawn waveform begins (a constant gap that scales with track length, #189). */ + left: 0 !important; + right: 0 !important; +} +/* When the Web Audio engine owns playback, the multitrack is mounted with null + URLs and never decodes audio, so the WaveSurfer canvas is empty. Show the SVG + overview layer (rendered from peaks.json) as the visual source instead. */ +.daw.engine-waveforms .stem-waveform-layer { + display: flex !important; +} +/* waves-column must size naturally now that multitrack is in flow */ +.daw .waves-column { + height: auto !important; + min-height: 0 !important; +} + +.loop-region.hidden { display: none !important; } + +/* Wave loading overlay */ +.wave-loading-overlay { + position: absolute; inset: 0; z-index: 10; + display: flex; flex-direction: column; + align-items: center; justify-content: center; + gap: 16px; + background: rgb(11, 15, 18); +} +.wave-loading-overlay.hidden { display: none !important; } +.wave-loading-glow { + width: 80px; height: 80px; border-radius: 50%; + background: radial-gradient(circle, rgba(244,183,64,0.2) 0%, transparent 70%); + animation: daw-pulse 2s ease-in-out infinite; +} +@keyframes daw-pulse { 0%,100%{opacity:0.5;transform:scale(0.9);} 50%{opacity:1;transform:scale(1.1);} } +.wave-loading-lines { + display: flex; gap: 3px; align-items: center; +} +.wave-loading-lines i { + display: block; width: 3px; height: 20px; + background: var(--accent); border-radius: 2px; + animation: daw-bar 1.2s ease-in-out infinite; +} +.wave-loading-lines i:nth-child(2) { animation-delay: 0.1s; } +.wave-loading-lines i:nth-child(3) { animation-delay: 0.2s; } +.wave-loading-lines i:nth-child(4) { animation-delay: 0.3s; } +.wave-loading-lines i:nth-child(5) { animation-delay: 0.4s; } +.wave-loading-lines i:nth-child(6) { animation-delay: 0.5s; } +@keyframes daw-bar { 0%,100%{transform:scaleY(0.4);opacity:0.5;} 50%{transform:scaleY(1.4);opacity:1;} } +.wave-loading-msg { font-size: 12px; color: var(--muted); } + +/* Presence panel (hidden, JS compat) */ +.presence-panel { display: none; } + +/* ── Transport footer ── */ + +.daw-footer { + height: 200px; + flex-shrink: 0; + border-top: 1px solid var(--border); + background: var(--bg-2); + display: flex !important; + flex-direction: column; +} + +.daw-footer-left { + display: flex; + align-items: baseline; + gap: 6px; + min-width: 140px; + flex: 1; +} +.daw-time-big { + font-size: 20px; font-weight: 600; letter-spacing: -0.01em; +} +.daw-time-total { font-size: 13px; color: var(--muted); } +.daw-footer-right { + display: flex; align-items: center; gap: 8px; + min-width: 140px; justify-content: flex-end; + flex: 1; +} +.daw-master-fader { accent-color: var(--accent); cursor: pointer; } +.daw-btn { + display: inline-flex; align-items: center; justify-content: center; + background: var(--panel-2); border: 1px solid var(--border-strong); + color: var(--fg); border-radius: 8px; font-size: 12px; + font-family: inherit; cursor: pointer; + transition: background var(--t-fast); +} +.daw-btn:hover { background: var(--panel-3); } + +/* Transport controls */ +.daw-transport-controls { + display: flex; align-items: center; justify-content: center; gap: 8px; +} +.daw-play-btn { + background: var(--accent); + color: #1a1206; + width: 56px; height: 56px; + border-radius: 50%; border: none; + display: inline-flex; align-items: center; justify-content: center; + cursor: pointer; + box-shadow: 0 2px 10px rgba(244,183,64,0.35), inset 0 1px 0 rgba(255,255,255,0.18); + transition: transform var(--t-fast), box-shadow var(--t-fast), background var(--t-fast); + flex-shrink: 0; +} +.daw-play-btn:hover { box-shadow: 0 2px 16px rgba(244,183,64,0.5), inset 0 1px 0 rgba(255,255,255,0.22); } +.daw-play-btn:active { transform: scale(0.96); } +/* Playing = green (go); idle/ready stays gold. */ +.daw-play-btn.playing { + background: #4caf7d; + box-shadow: 0 2px 12px rgba(76,175,125,0.45), inset 0 1px 0 rgba(255,255,255,0.2); +} +.daw-play-btn.playing:hover { box-shadow: 0 2px 18px rgba(76,175,125,0.6), inset 0 1px 0 rgba(255,255,255,0.24); } + +/* JS toggles these to show play vs pause icon */ +.btn-transport.playing .pause-icon { display: block !important; } +.btn-transport.playing > svg:first-child { display: none; } + +/* Stop/loop buttons inside the footer transport row */ +.daw-footer .daw-iconbtn.btn-transport { + width: 40px; height: 40px; + border-radius: 9px; + display: inline-flex; align-items: center; justify-content: center; +} + +/* Loop active state */ +.daw-iconbtn.loop.active, +.btn-transport.loop.active { color: var(--accent); } + +/* Export buttons in footer */ +.daw-footer .footer-btn { + padding: 0 14px; + height: 36px; + border-radius: 8px; + gap: 6px; + white-space: nowrap; +} +.daw-footer .footer-btn:disabled { + opacity: 0.4; + cursor: default; +} + +/* ── Footer redesign ── */ + +/* ── Footer layout ── */ +.footer-content { + display: flex; flex-direction: row; align-items: center; + padding: 0 20px; flex: 1; min-height: 0; + justify-content: space-between; +} +.footer-center { + flex: 0 0 auto; + display: flex; flex-direction: column; align-items: center; gap: 6px; + padding: 0 24px; +} +.footer-chips { + flex: 1; + display: flex; align-items: center; justify-content: flex-end; gap: 8px; + min-width: 0; +} +/* Wave bar at the bottom of the footer */ +.footer-wave-bar { + height: 40px; flex-shrink: 0; + position: relative; overflow: hidden; +} +#footer-waveform { + position: absolute; inset: 0; + width: 100%; height: 100%; display: block; +} +.footer-scrub { + position: absolute; inset: 0; + cursor: pointer; z-index: 2; + background: transparent; +} +.footer-scrub-fill { display: none; } + +/* Track info (art + title + meta) */ +.footer-track { + flex: 1; min-width: 0; overflow: hidden; + display: flex; flex-direction: column; justify-content: center; + gap: 6px; +} +.footer-track-title-row { + display: flex; align-items: center; gap: 8px; min-width: 0; +} +.footer-track-title-row .daw-fav-btn { margin-left: 0; } +.footer-track-title { + min-width: 0; flex-shrink: 1; + font-size: 14px; font-weight: 600; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + color: var(--fg); +} +.footer-track-body { + display: flex; align-items: flex-start; gap: 10px; min-width: 0; +} +.footer-cover { + width: 56px !important; height: 56px !important; + flex-shrink: 0; +} +.footer-track-body .daw-track-meta { flex: 1; min-width: 0; gap: 4px; } +.footer-track .daw-track-details { gap: 2px; } +.footer-track .daw-detail-label { font-size: 10px; width: 58px; } +.footer-track .daw-detail-val { font-size: 10px; } +.footer-art { + width: 44px; height: 44px; border-radius: 6px; + background: var(--panel-3); flex-shrink: 0; overflow: hidden; + display: none; +} +.footer-art.has-art { display: block; } +.footer-thumb-img { + width: 100%; height: 100%; object-fit: cover; + opacity: 0; transition: opacity 0.2s; +} +.footer-thumb-img.loaded { opacity: 1; } +.footer-info { + display: flex; flex-direction: column; gap: 3px; min-width: 0; +} +.footer-title { + font-size: 13px; font-weight: 600; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + color: var(--fg); +} +.footer-meta { + font-size: 11px; color: var(--muted); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} + +/* Time display below transport buttons */ +.footer-time-row { + display: flex; align-items: baseline; gap: 4px; + justify-content: center; +} +.footer-elapsed { + font-size: 17px; font-weight: 700; letter-spacing: -0.03em; color: var(--fg); +} +.footer-total { + font-size: 13px; font-weight: 400; color: var(--muted); +} +.footer-time-sep { font-size: 13px; color: var(--muted); } + +/* Exact loop start/end inputs (inline, right of the loop button) */ +.footer-loop-times { + display: flex; align-items: center; gap: 5px; + margin-left: 4px; cursor: default; user-select: none; +} +.loop-times-sep { font-size: 12px; color: var(--muted); flex-shrink: 0; } +.loop-time-input { + width: 8ch; box-sizing: content-box; + padding: 2px 6px; + background: var(--panel); border: 1px solid var(--border-strong); + border-radius: 5px; outline: none; + color: var(--fg); font-family: inherit; font-size: 12px; font-weight: 600; + font-variant-numeric: tabular-nums; text-align: center; +} +.loop-time-input:focus { border-color: var(--accent); } +.loop-time-input:disabled { opacity: 0.45; cursor: not-allowed; } + +/* Transport buttons */ +.footer-transport { + display: flex; align-items: center; gap: 8px; flex-shrink: 0; +} +.footer-transport .daw-iconbtn.btn-transport { + background: var(--panel-2); + border-color: var(--border-strong); + color: var(--fg-2); + transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast); +} +.footer-transport .daw-iconbtn.btn-transport:hover { + background: var(--panel-3); + color: var(--fg); +} +/* Active transport states: stop = red while stopped, loop = blue while looping. */ +.footer-transport .btn-transport.stopped, +.footer-transport .btn-transport.stopped:hover { + background: rgba(214,90,74,0.16); + border-color: rgba(214,90,74,0.55); + color: var(--danger); +} +.footer-transport .btn-transport.loop.active, +.footer-transport .btn-transport.loop.active:hover { + background: rgba(74,140,255,0.16); + border-color: rgba(74,140,255,0.55); + color: #4a8cff; +} + +/* Tempo bar */ +.tempo-bar { + display: flex; align-items: center; gap: 12px; + width: 100%; padding: 8px 4px 0; + border-top: 1px solid var(--border-strong); + margin-top: 6px; cursor: default; user-select: none; +} +.tempo-bar-label { + font-size: 10px; font-weight: 700; letter-spacing: 0.08em; + text-transform: uppercase; color: var(--fg-2); flex-shrink: 0; + white-space: nowrap; +} +.tempo-bar-divider { + width: 1px; height: 16px; background: var(--border-strong); flex-shrink: 0; +} +.tempo-bar-val { + font-size: 12px; font-weight: 700; font-variant-numeric: tabular-nums; + color: var(--fg-2); white-space: nowrap; flex-shrink: 0; min-width: 34px; + text-align: right; +} +#t-speed { + -webkit-appearance: none; appearance: none; flex: 1; + height: 4px; border-radius: 999px; outline: none; cursor: pointer; + background: linear-gradient( + 90deg, + var(--gold) 0 var(--speed-pct, 50%), + rgba(148,163,184,0.18) var(--speed-pct, 50%) 100% + ); +} +#t-speed::-webkit-slider-thumb { + -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; + background: var(--gold-bright); cursor: pointer; + box-shadow: 0 2px 8px rgba(216,168,74,0.35); +} +#t-speed::-moz-range-thumb { + width: 16px; height: 16px; border-radius: 50%; border: none; + background: var(--gold-bright); cursor: pointer; +} + +/* Speed + export chip buttons */ +.footer-chip-wrap { + position: relative; flex-shrink: 0; + display: flex; align-items: center; +} +.footer-chip { + display: inline-flex; align-items: center; gap: 6px; + background: var(--panel-2); border: 1px solid var(--border-strong); + color: var(--fg); border-radius: 8px; font-size: 12px; font-weight: 500; + font-family: inherit; cursor: pointer; padding: 0 12px; height: 36px; + white-space: nowrap; + transition: background var(--t-fast); +} +.footer-chip:hover { background: var(--panel-3); } +.footer-chip-primary { + background: var(--accent); color: #1a1206; + border-color: transparent; +} +.footer-chip-primary:hover { background: color-mix(in srgb, var(--accent) 85%, white); } +.footer-chip-primary:disabled { + background: var(--panel-2); color: var(--fg-muted); + border-color: var(--border-strong); cursor: not-allowed; opacity: 0.5; +} + +.footer-chip-panel { + position: absolute; bottom: calc(100% + 6px); right: 0; z-index: 30; + background: var(--panel-2); border: 1px solid var(--border-strong); + border-radius: 10px; padding: 4px; + min-width: 120px; + box-shadow: 0 8px 24px rgba(0,0,0,0.4); +} +.footer-chip-panel.hidden { display: none !important; } + +.chip-panel-item { + display: flex; align-items: center; gap: 8px; + width: 100%; padding: 7px 12px; + background: none; border: none; border-radius: 7px; + color: var(--fg); font-size: 13px; font-family: inherit; + cursor: pointer; text-align: left; white-space: nowrap; +} +.chip-panel-item:hover { background: var(--panel-3); } +.chip-panel-item.active { color: var(--accent); font-weight: 600; } + +/* ── Export split-button dropdown ── */ +/* The whole button toggles the menu, so the caret is purely a decorative + open/close indicator (no separate hit area). */ +.export-caret { opacity: 0.8; } +/* Primary button busy state: swap the download icon for a spinner. */ +.export-spinner { display: none; } +.footer-chip.is-busy .export-dl-icon { display: none; } +.footer-chip.is-busy .export-spinner { display: inline-block; animation: sections-spin 700ms linear infinite; } + +.chip-panel-export { min-width: 260px; padding: 6px; } + +/* WAV / MP3 segmented toggle in the panel header */ +.export-format-toggle { + display: flex; gap: 3px; padding: 3px; + margin-bottom: 4px; + background: var(--bg); border: 1px solid var(--border); + border-radius: 8px; +} +.export-fmt { + flex: 1; padding: 5px 0; border: none; border-radius: 6px; + background: transparent; color: var(--muted); + font-family: var(--font-mono); font-size: 11px; font-weight: 600; + letter-spacing: 0.04em; cursor: pointer; + transition: background var(--t-fast), color var(--t-fast); +} +.export-fmt:hover { color: var(--fg-2); } +.export-fmt.active { background: var(--panel-3); color: var(--accent); } + +/* Action rows — two-line: icon + (title/desc) + trailing check/count */ +.export-item { + align-items: center; gap: 11px; + padding: 9px 10px; white-space: normal; +} +.export-item .chip-item-icon { + flex-shrink: 0; width: 22px; + display: flex; align-items: center; justify-content: center; + color: var(--muted); +} +.export-item .chip-item-text { + flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; +} +.chip-item-title { font-size: 13px; font-weight: 500; color: var(--fg); } +.chip-item-desc { font-size: 11px; color: var(--muted); } +.chip-item-check { color: var(--accent); flex-shrink: 0; } +.export-item:hover:not([aria-disabled="true"]) { background: var(--panel-3); } +.export-item[aria-disabled="true"] { opacity: 0.4; pointer-events: none; } + +/* MP4 is offered as an export format only when the job has a preserved video + track (mp4 upload or YouTube). player.js toggles .has-video on the wrap. */ +.export-fmt-video { display: none; } +#footer-export-wrap.has-video .export-fmt-video { display: block; flex: 1; } + +/* In MP4 mode only "Export Mix" applies — the audio-only Stems/Region rows + are hidden. main.js toggles .fmt-mp4 on the panel. */ +#t-export-panel.fmt-mp4 #t-export-stems, +#t-export-panel.fmt-mp4 #t-export-region { display: none; } + +/* ── About dialog ── */ +.about-backdrop { + position: fixed; inset: 0; z-index: 100; + display: grid; place-items: center; + background: rgba(3,8,13,0.6); backdrop-filter: blur(6px); +} +.about-backdrop.hidden { display: none !important; } +.about-card { + width: min(300px, calc(100vw - 32px)); + background: var(--panel-2); border: 1px solid var(--border-strong); + border-radius: 18px; box-shadow: 0 24px 64px rgba(0,0,0,0.6); + padding: 28px 24px 24px; text-align: center; + position: relative; display: flex; flex-direction: column; align-items: center; gap: 0; +} +.about-close { + position: absolute; top: 12px; right: 12px; + width: 26px; height: 26px; + background: transparent; border: 1px solid var(--border); + border-radius: 6px; color: var(--muted); + cursor: pointer; display: flex; align-items: center; justify-content: center; +} +.about-close:hover { background: var(--panel-3); color: var(--fg); } +.about-logo { + width: 56px; height: 56px; border-radius: 14px; + background: linear-gradient(135deg, rgba(139,92,246,0.2), rgba(244,183,64,0.15)); + border: 1px solid rgba(139,92,246,0.25); + display: flex; align-items: center; justify-content: center; + color: var(--accent); margin-bottom: 14px; +} +.about-card h2 { margin: 0 0 4px; font-size: 18px; font-family: var(--font-mono); font-weight: 700; } +.about-tagline { margin: 0 0 12px; font-size: 12px; color: var(--muted); } +.about-version-badge { + display: inline-block; font-size: 11px; font-family: var(--font-mono); + color: var(--muted); background: var(--panel); border: 1px solid var(--border); + border-radius: 20px; padding: 2px 10px; margin-bottom: 20px; +} +.about-primary-links { + display: flex; gap: 8px; width: 100%; margin-bottom: 16px; +} +.about-link { + flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px; + padding: 8px 10px; border-radius: 8px; font-size: 12px; + text-decoration: none; transition: background 0.15s; +} +.about-link-primary { + background: var(--panel); border: 1px solid var(--border); + color: var(--fg-2); font-weight: 600; +} +.about-link-primary:hover { + background: var(--accent); border-color: var(--accent); color: #000; +} +.about-link-secondary { + background: var(--panel); border: 1px solid var(--border); + color: var(--fg-2); +} +.about-link-secondary:hover { background: var(--accent); border-color: var(--accent); color: #000; } +.about-divider { + width: 100%; height: 1px; background: var(--border); margin-bottom: 16px; +} +.about-socials { + display: flex; gap: 10px; align-items: center; justify-content: center; +} +.about-social-btn { + width: 36px; height: 36px; border-radius: 8px; + background: var(--panel); border: 1px solid var(--border); + display: flex; align-items: center; justify-content: center; + color: var(--muted); text-decoration: none; transition: all 0.15s; +} +.about-social-btn:hover { background: var(--accent); border-color: var(--accent); color: #000; } + +/* ── Library sections (Recent · Stem Collections · Tags) ── */ +.lib-section { + display: flex; + flex-direction: column; + gap: 4px; + padding-top: 14px; +} +.lib-section + .lib-section { + border-top: 1px solid var(--border); +} +.lib-section-head { + padding: 0 4px 6px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--muted); + display: flex; + align-items: center; + justify-content: space-between; +} + +/* Tag chips */ +/* ── Tag autocomplete dropdown ── */ +.tag-suggest { + position: absolute; + top: calc(100% + 4px); + left: 0; right: 0; + z-index: 50; + background: var(--panel-2); + border: 1px solid var(--border-strong); + border-radius: 8px; + padding: 4px; + list-style: none; + margin: 0; + box-shadow: var(--shadow-panel); +} +.tag-suggest:empty { display: none; } +.tag-suggest-item { + padding: 7px 10px; + border-radius: 5px; + font-size: 13px; + color: var(--fg-2); + cursor: pointer; +} +.tag-suggest-item:hover, +.tag-suggest-item.focused { + background: var(--panel-3); + color: var(--fg); +} + +.lib-tags-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 2px 0 8px; +} +.lib-tag-chip { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 11px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 20px; + font-size: 12px; + color: var(--fg-2); + cursor: pointer; + transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast); + white-space: nowrap; +} +.lib-tag-chip:hover { + background: var(--panel-2); + color: var(--fg); + border-color: rgba(255,255,255,0.15); +} +.lib-tag-chip.active { + background: rgba(244,183,64,0.12); + color: var(--accent); + border-color: rgba(244,183,64,0.3); +} +.lib-tag-count { color: var(--muted); font-size: 11px; } + +/* Supporters (partner tiles, shown in the TV-icon dialog) */ +/* Slightly wider card so 3 tiles breathe; grid spans the card. */ +.friends-card { width: min(360px, calc(100vw - 32px)); } +.friends-card .lib-friends-grid { width: 100%; margin-top: 4px; } +.lib-friends-grid { + /* Masonry columns: each column stacks independently so a tall tile does not + push others down. Tiles are round-robined into these columns in JS. */ + display: flex; + align-items: flex-start; + gap: 6px; + padding: 2px 0 0; +} +.lib-friends-col { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} +.lib-friend { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + min-width: 0; + padding: 9px 5px 12px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--fg-2); + text-decoration: none; + text-align: center; + /* deliberately-uneven "frames on a wall" tilt; per-tile angle set in JS */ + transform: rotate(var(--tilt, 0deg)); + transition: background var(--t-fast), color var(--t-fast), + border-color var(--t-fast), transform var(--t-fast); +} +.lib-friend:hover { + background: var(--panel-2); + color: var(--fg); + border-color: rgba(255,255,255,0.15); + /* straighten the frame on hover */ + transform: rotate(0deg); +} +/* Logos are transparent wordmarks; span the tile width, keep aspect, cap height. */ +.lib-friend-logo { + width: 100%; + height: auto; + max-height: 30px; + object-fit: contain; +} +/* Instagram profile photos render as round avatars */ +.lib-friend-avatar { + width: 44px; + height: 44px; + border-radius: 50%; + object-fit: cover; + border: 1px solid var(--border); +} +/* Monogram fallback when a tile has no image (or it fails to load): matches the + round avatar size, in the accent colour, so the grid stays on-brand. */ +.lib-friend-monogram { + width: 44px; + height: 44px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-sans); + font-size: 20px; + font-weight: 600; + color: var(--accent); + background: var(--panel-3); + border: 1px solid var(--border); +} +/* Small Instagram glyph under the text on tiles that link to Instagram */ +.lib-friend-ig { + /* block + no-shrink avoids the WebKit baseline clip on small inline SVGs */ + display: block; + flex: 0 0 auto; + width: 14px; + height: 14px; + margin-top: 2px; + fill: currentColor; + opacity: 0.65; +} +.lib-friend:hover .lib-friend-ig { + opacity: 1; +} +.lib-friend-name { + width: 100%; + font-size: 10.5px; + line-height: 1.2; + /* full name across up to 2 lines, then ellipsis */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.lib-friend-role { + width: 100%; + margin-top: 2px; + font-size: 9px; + line-height: 1.2; + color: var(--fg-3, var(--fg-2)); + /* show the full role; the tile grows to fit it */ +} + +/* ── Clear bin bar (trash view only) ── */ +.clear-bin-bar { display: none; padding: 6px 10px 4px; } +.sidebar.trash-view .clear-bin-bar { display: block; } +.sidebar.trash-view .lib-section-head .new-folder-btn { display: none; } +.sidebar.favorites-view .lib-section-head .new-folder-btn { display: none; } +.clear-bin-btn { + display: flex; align-items: center; gap: 5px; + width: 100%; padding: 6px 10px; border-radius: 6px; + background: rgba(229, 78, 78, 0.12); border: 1px solid rgba(229, 78, 78, 0.28); + color: #e54e4e; font-size: 12px; font-weight: 500; cursor: pointer; + transition: background 150ms, border-color 150ms; +} +.clear-bin-btn:hover { background: rgba(229, 78, 78, 0.22); border-color: rgba(229, 78, 78, 0.5); } +.clear-bin-btn:active { background: rgba(229, 78, 78, 0.32); } + +/* ── App state classes (set by JS) ── */ +/* No track loaded: hide main waveform content */ +.daw.is-import .daw-track-header, +.daw.is-import .daw-section-ribbon, +.daw.is-import .daw-wave-header, +.daw.is-import .daw-content { opacity: 0.3; pointer-events: none; } +.daw.is-import .daw-content .mixer-column { opacity: 1; pointer-events: auto; } + +.daw.no-track .daw-track-header, +.daw.no-track .daw-section-ribbon, +.daw.no-track .daw-wave-header, +.daw.no-track .daw-content { opacity: 0.3; pointer-events: none; } +/* Overlay and job progress live inside .daw-content but must stay fully visible while processing */ +.daw.no-track .daw-content .wave-loading-overlay, +.daw.no-track .daw-content .job { opacity: 1; pointer-events: auto; } + +.app.no-track #t-export-btn, +.app.is-import #t-export-btn { + opacity: 0.35; + pointer-events: none; + cursor: default; +} + +/* When track is playing */ +.daw-play-btn .pause-icon { display: none; } + +/* ── Responsive: hide zoom in small windows ── */ +@media (max-width: 900px) { + .daw-info-row { flex-wrap: wrap; } + .daw-track-card { width: 100%; border-right: none; border-bottom: 1px solid var(--border); } + .stem-presence-panel { grid-template-columns: repeat(3, 1fr); } +} diff --git a/static/css/job.css b/static/css/job.css new file mode 100644 index 0000000..3b0fd29 --- /dev/null +++ b/static/css/job.css @@ -0,0 +1,124 @@ +/* ───────────── Job progress ───────────── */ + +.job { + display: none !important; + background: var(--panel); + border: var(--border-w) solid var(--line); + border-radius: var(--radius); + padding: 14px 16px; +} +.job-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 10px; +} +#job-title { + font-size: 14px; + flex: 1; + word-break: break-word; +} +#job-stage { + font-size: 13px; + color: var(--ink-dim); + white-space: nowrap; + flex-shrink: 0; + font-variant-numeric: tabular-nums; +} +progress { + width: 100%; + height: 4px; + appearance: none; + background: var(--line); + border: 0; + border-radius: 2px; + overflow: hidden; +} +progress::-webkit-progress-bar { + background: var(--line); +} +progress::-webkit-progress-value { + background: var(--ink); + transition: width var(--t-base) var(--easing); +} +progress::-moz-progress-bar { + background: var(--ink); +} + +/* Compact technical detail line under the progress bar -- shows the + truthful backend `stage` value while #job-stage rotates phrases. */ +.job-detail { + margin-top: 6px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--ink-dim); + opacity: 0.65; + letter-spacing: 0.02em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cancel-btn { + flex-shrink: 0; + background: transparent; + color: var(--ink-dim); + border: var(--border-w) solid var(--line); + border-radius: var(--radius-xs); + padding: 4px 10px; + font: inherit; + font-family: var(--font-mono); + font-size: 11px; + cursor: pointer; + transition: + background-color var(--t-base) var(--easing), + color var(--t-base) var(--easing), + border-color var(--t-base) var(--easing); +} +.cancel-btn:hover:not(:disabled) { + background: var(--danger); + color: var(--bg); + border-color: var(--danger); +} +.cancel-btn:disabled { + opacity: 0.5; + cursor: default; +} + +/* ───────────── Error ───────────── */ + +.error { + background: rgba(214, 90, 74, 0.1); + border: var(--border-w) solid rgba(214, 90, 74, 0.5); + color: var(--danger); + border-radius: var(--radius); + padding: 12px 16px; + display: flex; + align-items: flex-start; + gap: 12px; +} +.error-msg { + flex: 1; + word-break: break-word; + font-size: 13px; +} +.retry-btn { + flex-shrink: 0; + background: transparent; + color: var(--danger); + border: var(--border-w) solid var(--danger); + border-radius: var(--radius-xs); + padding: 5px 12px; + font: inherit; + font-family: var(--font-mono); + font-size: 12px; + cursor: pointer; + transition: + background-color var(--t-base) var(--easing), + color var(--t-base) var(--easing); +} +.retry-btn:hover { + background: var(--danger); + color: var(--bg); +} diff --git a/static/css/master.css b/static/css/master.css new file mode 100644 index 0000000..9b84342 --- /dev/null +++ b/static/css/master.css @@ -0,0 +1,106 @@ +/* ───────────── Master fader + VU ───────────── + * Console-style: tick-marked vertical fader on the left, twin segmented + * LED VU meters on the right, both inside a recessed dark panel. + * Custom rendering throughout — `appearance: none` so the browser + * doesn't fall back to its blue-track default vertical slider, which + * ignores ::-webkit-slider-thumb overrides and offsets the thumb. */ + +.np-master { + display: flex; + align-items: stretch; + gap: 10px; + height: 140px; + padding: 8px 10px; + background: linear-gradient(180deg, #141414, #0d0d0d); + border: 1px solid var(--line); + border-radius: 6px; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.4); +} + +/* Fader column with tick marks down both sides. */ +.master-fader-track { + position: relative; + height: 100%; + width: 44px; + display: flex; + align-items: stretch; + justify-content: center; +} +.master-fader-track::before, +.master-fader-track::after { + content: ""; + position: absolute; + top: 4px; + bottom: 4px; + width: 5px; + background-image: repeating-linear-gradient( + to bottom, + var(--line-strong) 0, + var(--line-strong) 1px, + transparent 1px, + transparent 8px + ); + pointer-events: none; +} +.master-fader-track::before { left: 4px; } +.master-fader-track::after { right: 4px; } + +.master-fader { + /* No `appearance: slider-vertical` — that's a legacy webkit shortcut + that forces a blue-track default vertical slider AND ignores + ::-webkit-slider-thumb overrides (which is why the thumb floats + left of center). `writing-mode: vertical-lr` rotates the + horizontal range input, `direction: rtl` puts max at the top. */ + -webkit-appearance: none; + appearance: none; + -moz-appearance: none; + writing-mode: vertical-lr; + direction: rtl; + width: 100%; + height: 100%; + background: transparent; + cursor: pointer; + outline: none; + margin: 0; + padding: 0; +} +.master-fader::-webkit-slider-runnable-track { + width: 4px; + height: 100%; + background: #050505; + border: 1px solid var(--line); + border-radius: 2px; +} +.master-fader::-moz-range-track { + width: 4px; + height: 100%; + background: #050505; + border: 1px solid var(--line); + border-radius: 2px; +} +.master-fader::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + /* In writing-mode: vertical-lr, `width` is the long (horizontal) axis + and `height` is the thin (vertical) axis. Pill-shaped horizontal + handle. margin-left: -12px centers it on the 4px track. */ + width: 28px; + height: 14px; + margin-left: -12px; + background: linear-gradient(180deg, #6a6a6a 0%, #2a2a2a 55%, #161616 100%); + border: 1px solid #050505; + border-radius: 3px; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.18), + inset 0 -1px 0 rgba(0, 0, 0, 0.7), + 0 1px 2px rgba(0, 0, 0, 0.7); + cursor: pointer; +} +.master-fader::-moz-range-thumb { + width: 28px; + height: 14px; + background: linear-gradient(180deg, #6a6a6a 0%, #2a2a2a 55%, #161616 100%); + border: 1px solid #050505; + border-radius: 3px; + cursor: pointer; +} diff --git a/static/css/mixer.css b/static/css/mixer.css new file mode 100644 index 0000000..65db16f --- /dev/null +++ b/static/css/mixer.css @@ -0,0 +1,418 @@ +/* ───────────── Preview mixer ───────────── */ + +.mixer-column { + min-width: 0; + display: grid; + grid-template-columns: repeat(6, minmax(68px, 1fr)); + gap: 10px; +} + +.lane-header { + min-height: 190px; + display: grid; + grid-template-rows: auto 1fr; + justify-items: center; + position: relative; + transition: opacity var(--t-base) var(--easing); + color: var(--stem-color, #d8dde4); +} + +.lane-header[data-stem="vocals"] { --stem-color: #e54249; } +.lane-header[data-stem="drums"] { --stem-color: #f06a14; } +.lane-header[data-stem="bass"] { --stem-color: #eab414; } +.lane-header[data-stem="guitar"] { --stem-color: #5bbf50; } +.lane-header[data-stem="piano"] { --stem-color: #7f45d8; } +.lane-header[data-stem="other"] { --stem-color: #3e8dcf; } +.lane-header[data-stem="original"] { --stem-color: #a8b0bd; } + +.lane-header + .lane-header { + border-left: 1px solid var(--glass-line); +} + +.lane-header.muted { + opacity: 0.45; +} + +.lane-stripe { + display: none; +} + +.lane-content { + display: contents; +} + +.lane-name-row { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + min-width: 0; +} + +.lane-icon { + width: 29px; + height: 29px; + flex: 0 0 auto; + color: var(--stem-color); + filter: drop-shadow(0 0 10px color-mix(in srgb, currentColor 34%, transparent)); +} + +.lane-icon-toggle { + width: 42px; + height: 42px; + display: grid; + place-items: center; + padding: 0; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--stem-color); + cursor: pointer; + opacity: 0.38; + transition: + opacity var(--t-base) var(--easing), + border-color var(--t-base) var(--easing), + background-color var(--t-base) var(--easing), + transform var(--t-fast) var(--easing); +} + +.lane-icon-toggle.active { + opacity: 1; + border-color: color-mix(in srgb, currentColor 24%, transparent); + background: color-mix(in srgb, currentColor 8%, transparent); + box-shadow: inset 0 0 18px color-mix(in srgb, currentColor 8%, transparent); +} + +.lane-icon-toggle:hover:not(:disabled) { + opacity: 1; + transform: translateY(-1px); +} + +.lane-icon-toggle:focus-visible { + outline: 2px solid var(--gold); + outline-offset: 2px; +} + +.lane-icon-toggle:disabled { + cursor: default; + opacity: 0.25; +} + +.lane-name { + color: #d7dce3 !important; + font-size: 14px; +} + +.lane-controls { + display: grid; + grid-template-columns: 1fr; + grid-template-rows: 106px; + align-items: end; + justify-items: center; + gap: 10px; +} + +.lane-mini-wave { + display: none; +} + +.lane-dl { + display: none; + color: #d8dde4; + text-decoration: none; +} + +.lane-knob { + grid-column: 1 / -1; + grid-row: 1; + width: 48px; + height: 104px; + border-radius: 0; + border: 0; + background: + repeating-linear-gradient(to bottom, rgba(148, 163, 184, 0.22) 0 1px, transparent 1px 12px), + linear-gradient(90deg, transparent 0 44%, rgba(148, 163, 184, 0.2) 44% 56%, transparent 56% 100%); + position: relative; + cursor: ns-resize; + /* --lane-pos: 0..1 = bottom..top. Default 0.5 = unity gain (vol=1 + of LANE_VOLUME_MAX=2). JS rewrites this when volume changes. */ + --lane-pos: 0.5; +} + +/* Fader thumb. Travels vertically along the track based on --lane-pos. + The travel envelope is 100% - 18px (the thumb's own height) so the + thumb never overshoots the track edges. The transition is only for + programmatic moves (Reset, dblclick, wheel) -- during an active + drag we kill the transition (.lane-knob.dragging rule below) so the + thumb tracks the cursor 1:1 instead of chasing it through an + easing curve, which reads as a rigid/laggy fader. */ +.lane-knob::before { + content: ""; + position: absolute; + left: 50%; + top: calc((1 - var(--lane-pos)) * (100% - 14px)); + width: 30px; + height: 14px; + transform: translateX(-50%); + border-radius: 3px; + border: 1px solid #050505; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.2), transparent 42%), + linear-gradient(180deg, #6a6a6a 0%, #2a2a2a 55%, #161616 100%); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.18), + inset 0 -1px 0 rgba(0, 0, 0, 0.7), + 0 0 0 1px color-mix(in srgb, currentColor 34%, transparent), + 0 1px 2px rgba(0, 0, 0, 0.72); + transition: top 120ms var(--easing); + will-change: top; + pointer-events: none; +} + +.lane-knob.dragging::before { + transition: none; +} + +/* Slight grip cue while actively dragging -- thumb gets a tighter + shadow ring so it feels "picked up". */ +.lane-knob.dragging { + cursor: grabbing; +} + +.lane-knob-indicator { + display: none; +} + +.lane-knob.disabled { + opacity: 0.45; + pointer-events: none; +} + +.ms-btn { + width: 30px; + height: 30px; + border: 0; + border-radius: 6px; + background: rgba(255, 255, 255, 0.055); + color: #d8dde4; + font-family: var(--font-mono); + font-size: 14px; + font-weight: 700; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: + background-color var(--t-base) var(--easing), + transform var(--t-fast) var(--easing); +} + +.ms-btn:hover { + background: rgba(255, 255, 255, 0.1); +} + +.ms-btn:active { + transform: scale(0.92); +} + +.ms-btn.mute.active { + background: var(--danger); + color: white; +} + +.ms-btn.solo.active { + background: var(--gold); + color: #17130c; +} + +.ms-btn:disabled { + opacity: 0.38; + pointer-events: none; +} + +.lane-controls .ms-btn.mute { + grid-column: 1; + grid-row: 2; +} + +.lane-controls .ms-btn.solo { + grid-column: 2; + grid-row: 2; +} + +.lane-vu { + display: none; +} + +.app:not(.is-import) .preview-mixer-body { + height: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 0; + padding-top: 0; +} + +.app:not(.is-import) .mixer-column { + height: 100%; + grid-template-columns: repeat(var(--visible-track-count, 6), minmax(52px, 1fr)); + gap: 0; +} + +.app:not(.is-import) .lane-header { + min-height: 0; + height: 100%; + grid-template-rows: 64px 1fr; + padding: 7px 0 6px; + overflow: hidden; +} + +.app:not(.is-import) .lane-name-row { + gap: 6px; +} + +.app:not(.is-import) .lane-icon { + width: 26px; + height: 26px; +} + +.app:not(.is-import) .lane-icon-toggle { + width: 38px; + height: 38px; +} + +.app:not(.is-import) .lane-name { + font-size: 12px; +} + +.app:not(.is-import) .lane-controls { + width: 100%; + height: auto; + min-height: 0; + align-self: stretch; + grid-template-columns: 1fr; + grid-template-rows: 1fr; + gap: 0; + padding: 0 5px 38px; +} + +.app:not(.is-import) .lane-knob { + width: 42px; + height: 100%; + min-height: 0; + justify-self: center; +} + +.app:not(.is-import) .lane-vu { + display: block; + position: absolute; + left: 50%; + top: 72px; + bottom: 48px; + width: 12px; + height: auto; + transform: translateX(18px); + border-radius: 2px; + overflow: hidden; + background: + linear-gradient(to top, + rgba(48, 197, 87, 0.14) 0 55%, + rgba(225, 178, 46, 0.14) 55% 80%, + rgba(214, 64, 64, 0.14) 80% 100%), + rgba(4, 10, 15, 0.82); + box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.12); +} + +.app:not(.is-import) .lane-dl { + display: grid; + place-items: center; + position: absolute; + left: 8px; + right: 8px; + bottom: 6px; + height: 34px; + transform: none; + border: 1px solid color-mix(in srgb, var(--stem-color) 28%, rgba(148, 163, 184, 0.18)); + border-radius: 8px; + background: + linear-gradient(180deg, color-mix(in srgb, var(--stem-color) 13%, transparent), transparent), + rgba(255, 255, 255, 0.055); + color: var(--stem-color); + opacity: 0.9; + box-shadow: + inset 0 0 0 1px rgba(255, 255, 255, 0.035), + 0 6px 14px rgba(0, 0, 0, 0.16); + transition: + opacity var(--t-base) var(--easing), + background-color var(--t-base) var(--easing), + border-color var(--t-base) var(--easing), + transform var(--t-fast) var(--easing); +} + +.app:not(.is-import) .lane-dl svg { + width: 19px; + height: 19px; + stroke-width: 2.15; +} + +.app:not(.is-import) .lane-dl:hover:not(.disabled) { + opacity: 1; + border-color: color-mix(in srgb, var(--stem-color) 58%, transparent); + background: + linear-gradient(180deg, color-mix(in srgb, var(--stem-color) 18%, transparent), transparent), + rgba(255, 255, 255, 0.075); + transform: translateY(-1px); +} + +.app:not(.is-import) .lane-dl:focus-visible { + outline: 2px solid var(--gold); + outline-offset: 2px; +} + +.app:not(.is-import) .lane-dl.disabled { + opacity: 0.28; + pointer-events: none; +} + +.app:not(.is-import) .lane-vu::before { + content: ""; + position: absolute; + left: 1px; + right: 1px; + top: 1px; + bottom: 1px; + background: + repeating-linear-gradient(to top, transparent 0 3px, rgba(0, 0, 0, 0.4) 3px 5px), + linear-gradient(to top, #24bf55 0 58%, #e1b22e 58% 80%, #d64040 80% 100%); + clip-path: inset(calc(100% - var(--vu-level, 0%)) 0 0 0); + transition: clip-path 30ms linear; +} + +.app:not(.is-import) .lane-vu::after { + content: ""; + position: absolute; + left: 1px; + right: 1px; + bottom: var(--vu-peak, 0%); + height: 2px; + background: rgba(255, 255, 255, 0.78); + transition: bottom 80ms linear; +} + +.app:not(.is-import) .master-strip { + height: 100%; + min-height: 0; + grid-template-columns: 1fr; + grid-template-rows: 72px 1fr; + gap: 8px; + overflow: hidden; +} + +.app:not(.is-import) .master-strip .master-fader-track { + height: calc(100% - 4px); + min-height: 0; +} + +.app:not(.is-import) .master-strip .master-fader-track::before, +.app:not(.is-import) .master-strip .master-fader-track::after { + display: none; +} diff --git a/static/css/responsive.css b/static/css/responsive.css new file mode 100644 index 0000000..1dfbb04 --- /dev/null +++ b/static/css/responsive.css @@ -0,0 +1,311 @@ +/* ───────────── Responsive ───────────── + * Breakpoints: + * ≤1100px — laptop / portrait tablet: now-playing card reflows to 3-col + * with master spanning full width below transport. + * ≤900px — landscape phone / small tablet: appbar wraps, lanes scroll + * horizontally with a sticky mixer column, master compresses. + * ≤640px — phone: mixer column narrower, brand smaller, transport + * buttons full-width touch-friendly, master inline-compact. + * ≤480px — small phone: tightest layout, art shrinks, paddings minimal. + * pointer:coarse — touch input regardless of screen size: bump + * interactive targets to a comfortable hit area. + */ + +@media (max-width: 1100px) { + .import-card { + width: min(100%, 900px); + } + .stem-choice-row { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .stem-choice-row > span { + grid-column: 1 / -1; + } + .appbar { + grid-template-columns: 1fr; + } + .appbar-import-panel .import-input-row { + grid-template-columns: minmax(0, 1fr) minmax(144px, 180px); + } + .appbar-import-panel .stem-choice-row { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .appbar-import-panel .stem-choice-row > span { + grid-column: 1 / -1; + } + .feature-strip { + grid-template-columns: 1fr; + gap: 22px; + } + .feature-item + .feature-item { + border-left: 0; + border-top: 1px solid var(--gold-line); + padding-left: 0; + padding-top: 22px; + } + .now-playing { + grid-template-columns: auto 1fr auto; + grid-template-areas: + "art info transport" + "art master master"; + gap: 14px 16px; + } + .np-art { grid-area: art; } + .np-info { grid-area: info; } + .np-transport { grid-area: transport; } + .np-master { grid-area: master; } +} + +@media (max-width: 900px) { + .app { + padding: 16px; + gap: 12px; + } + .appbar { + gap: 12px; + } + .import-page { + padding: 54px 18px 20px; + } + .import-copy h2 { + font-size: 31px; + } + .import-card { + margin-top: 36px; + padding: 18px; + } + .import-input-row { + grid-template-columns: 1fr; + } + .appbar-import-panel { + padding: 16px; + } + .appbar-import-panel .import-input-row { + grid-template-columns: 1fr; + gap: 12px; + } + .btn-primary { + width: 100%; + } + .brand-group { + flex: 1; + } + .appbar-form { + order: 3; + width: 100%; + flex: none; + } + /* Compress the master section to recover vertical space — the full + 140px panel makes the now-playing card too tall when stacked. */ + .np-master { + height: 90px; + padding: 6px 8px; + } + /* Lane scrolling: mixer column stays put, waves scroll under it. */ + .lanes-body, + .lanes-ruler { + overflow-x: auto; + } + .mixer-column, + .lanes-ruler-gutter { + position: sticky; + left: 0; + z-index: 4; + background: var(--panel); + } + /* Keep the waves area readable when scrolling — without a min-width + the waveform canvases collapse below ~250px and become unusable. */ + .waves-column, + .lanes-ruler-time { + min-width: 480px; + } +} + +@media (max-width: 640px) { + :root { + --header-w: 200px; + } + .brand { + font-size: 22px; + } + .brand-logo { + width: 146px; + } + .brand-sub { + font-size: 12px; + } + .import-copy h2 { + font-size: 26px; + } + .import-copy p { + font-size: 15px; + } + .stem-choice-row { + grid-template-columns: 1fr 1fr; + } + .feature-strip { + padding: 22px; + } + .feature-item { + grid-template-columns: 52px 1fr; + } + .now-playing { + grid-template-columns: auto 1fr; + grid-template-areas: + "art info" + "transport master"; + gap: 12px; + } + /* Inline transport + master side-by-side rather than stacked, so the + card stays compact. Master is already compressed by the 900px rule. */ + .np-master { + height: 80px; + } + .np-transport { + align-self: center; + } + .lane-mini-wave { + display: none; /* save horizontal space in the mixer column */ + } + .np-title { + font-size: 14px; + -webkit-line-clamp: 3; + } +} + +@media (max-width: 480px) { + :root { + --header-w: 180px; + --lane-h: 84px; + } + .app { + padding: 12px; + gap: 10px; + } + /* Single-column stem buttons; the 2-col grid still squeezes labels at + ~360px because each button needs the icon + text + 8px gap. */ + .stem-choice-row { + grid-template-columns: 1fr; + } + .stem-choice { + min-height: 44px; + } + .feature-strip { + margin-top: 24px; + } + .privacy-note { + margin-top: 22px; + font-size: 13px; + text-align: center; + } + .now-playing { + padding: 10px 12px; + grid-template-columns: auto 1fr; + grid-template-areas: + "art info" + "transport transport" + "master master"; + gap: 10px; + } + .np-art { + width: 56px; + height: 56px; + } + .np-art-play { + width: 28px; + height: 28px; + } + .np-master { + height: 72px; + } + .np-transport { + flex-direction: row; + justify-content: space-between; + align-items: center; + } + .chip { + padding: 3px 8px; + font-size: 12px; + } + /* Drop the second chip column to one row of just the essentials */ + .np-chips { + gap: 6px; + } + /* Mixer rows: tighten internal padding so M/S/knob/cloud fit. */ + .lane-content { + padding: 8px 36px 8px 8px; + } + .lane-name { + font-size: 13px; + } + /* Tighten lane VU on phone so the row stays readable. */ + .lane-vu { + width: 18px; + right: 4px; + } + /* Keep waves usable — lower the min-width slightly on tiny screens. */ + .waves-column, + .lanes-ruler-time { + min-width: 360px; + } +} + +/* Touch-input devices: enlarge interactive targets to ≥40px hit area + regardless of screen size. Desktop stays compact. */ +@media (pointer: coarse) { + .ms-btn { + width: 40px; + height: 40px; + font-size: 13px; + } + .lane-knob { + width: 40px; + height: 40px; + } + .lane-knob-indicator { + transform-origin: 50% 16px; + height: 11px; + top: 5px; + } + .lane-dl { + width: 36px; + height: 36px; + } + .btn-transport { + width: 44px; + height: 44px; + } + .avatar-btn { + width: 44px; + height: 44px; + } + /* Bigger master fader thumb so it's easier to grab. */ + .master-fader::-webkit-slider-thumb { + width: 32px; + height: 18px; + margin-left: -14px; + } + .master-fader::-moz-range-thumb { + width: 32px; + height: 18px; + } +} + +/* Landscape orientation on short screens (phones held sideways): the + now-playing card eats too much vertical space if it tries to stack. + Prefer a single horizontal row. */ +@media (max-height: 500px) and (orientation: landscape) { + .now-playing { + grid-template-columns: auto 1fr auto auto; + grid-template-areas: "art info transport master"; + gap: 10px; + padding: 8px 12px; + } + .np-master { + height: 70px; + } + .np-art { + width: 56px; + height: 56px; + } +} diff --git a/static/css/transport.css b/static/css/transport.css new file mode 100644 index 0000000..f224fe7 --- /dev/null +++ b/static/css/transport.css @@ -0,0 +1,632 @@ +/* ───────────── Import page ───────────── */ + +.import-page { + min-height: clamp(420px, 60vh, 704px); + position: relative; + overflow: hidden; + display: flex; + flex-direction: column; + align-items: center; + padding: clamp(28px, 6vw, 78px) clamp(14px, 3vw, 24px) clamp(18px, 3vw, 24px); + border: 1px solid var(--glass-line); + border-radius: 12px; + background: + radial-gradient(circle at 50% 22%, rgba(216, 168, 74, 0.1), transparent 23rem), + radial-gradient(circle at 8% 45%, rgba(216, 168, 74, 0.06), transparent 22rem), + radial-gradient(circle at 92% 45%, rgba(216, 168, 74, 0.06), transparent 22rem), + linear-gradient(180deg, rgba(16, 26, 34, 0.78), rgba(8, 15, 22, 0.88)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035); +} + +.import-copy { + text-align: center; + position: relative; + z-index: 2; +} + +.import-copy h2 { + margin: 0; + color: #f1f3f5; + font-size: clamp(24px, 3.4vw + 8px, 39px); + line-height: 1.12; + font-weight: 700; +} + +.import-copy h2 span { + color: var(--gold-bright); + white-space: nowrap; +} + +.import-copy p { + margin: 16px 0 0; + color: #a9b0bb; + font-size: clamp(14px, 1vw + 8px, 18px); +} + +.import-card { + width: min(100%, 1088px); + margin-top: clamp(20px, 4vw, 58px); + padding: clamp(14px, 2vw, 19px) clamp(16px, 3vw, 36px) clamp(18px, 2.5vw, 28px); + border-radius: 13px; + position: relative; + z-index: 2; +} + +.import-input-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 24px; + align-items: center; + padding-bottom: 18px; + border-bottom: 1px solid var(--glass-line); +} + +.stem-choice-row { + display: grid; + grid-template-columns: 138px repeat(6, minmax(86px, 1fr)); + gap: 14px; + align-items: center; + padding-top: 22px; +} + +.stem-choice-row > span { + color: #d9dde4; + font-weight: 600; + white-space: nowrap; +} + +.stem-choice { + min-height: 54px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 9px; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: var(--radius); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.025), transparent), + rgba(8, 15, 22, 0.34); + color: currentColor; + font: inherit; + font-weight: 700; + transition: + border-color var(--t-base) var(--easing), + background-color var(--t-base) var(--easing), + box-shadow var(--t-base) var(--easing), + transform var(--t-fast) var(--easing); +} + +.stem-choice:hover { + transform: translateY(-1px); + background-color: rgba(255, 255, 255, 0.045); + box-shadow: inset 0 0 22px color-mix(in srgb, currentColor 10%, transparent); +} + +/* Deselected state -- the user has unchecked this stem from the + "stems to extract" set, so it's greyed out and won't appear in the + studio dashboard after processing. Maintains layout (still occupies + its grid slot) so the row doesn't reflow on toggle. */ +.stem-choice[aria-pressed="false"] { + opacity: 0.32; + border-color: rgba(148, 163, 184, 0.2) !important; + background: rgba(8, 15, 22, 0.2); +} + +.stem-choice[aria-pressed="false"]:hover { + opacity: 0.55; +} + +.stem-choice svg { + flex: 0 0 auto; +} + +.stem-choice.vocals { color: #ff3f46; border-color: rgba(255, 63, 70, 0.28); } +.stem-choice.drums { color: #ff7a1a; border-color: rgba(255, 122, 26, 0.24); } +.stem-choice.bass { color: #ffd04d; border-color: rgba(255, 208, 77, 0.24); } +.stem-choice.guitar { color: #3fc952; border-color: rgba(63, 201, 82, 0.24); } +.stem-choice.piano { color: #974cff; border-color: rgba(151, 76, 255, 0.24); } +.stem-choice.other { color: #3695ff; border-color: rgba(54, 149, 255, 0.24); } + +.privacy-note { + display: inline-flex; + align-items: center; + gap: 9px; + margin-top: 34px; + color: #a6adb7; + font-size: 15px; + position: relative; + z-index: 2; +} + +.feature-strip { + width: min(100%, 1160px); + min-height: 136px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + align-items: center; + gap: clamp(18px, 2.4vw, 36px); + margin-top: clamp(22px, 4vw, 54px); + padding: clamp(16px, 2.4vw, 24px) clamp(18px, 3vw, 46px); + border-radius: 13px; + position: relative; + z-index: 2; +} + +.feature-item { + display: grid; + grid-template-columns: clamp(40px, 4vw, 68px) 1fr; + align-items: center; + gap: clamp(12px, 1.5vw, 24px); + color: var(--gold); +} + +.feature-item svg { + width: 100%; + height: auto; + max-width: 48px; +} + +.feature-item + .feature-item { + border-left: 1px solid var(--gold-line); + padding-left: clamp(18px, 2.4vw, 42px); +} + +.feature-item strong { + display: block; + color: var(--ink); + font-size: clamp(14px, 1vw + 6px, 16px); + margin-bottom: 6px; +} + +.feature-item span { + color: #a6adb7; + font-size: clamp(13px, 0.8vw + 8px, 15px); +} + +/* The HTML hard-codes
in feature descriptions for desktop layout; + on narrow viewports those forced breaks produce ragged 2-word lines. + Collapse them so the text wraps naturally. */ +@media (max-width: 720px) { + .feature-item br { display: none; } +} + +.wave-lines { + position: absolute; + top: 152px; + width: 32%; + height: 120px; + opacity: 0.46; + background: + repeating-linear-gradient(108deg, transparent 0 24px, rgba(216, 168, 74, 0.52) 25px 27px, transparent 28px 50px); + mask-image: radial-gradient(ellipse, black 0 48%, transparent 72%); +} + +.wave-left { + left: 0; + transform: skewY(-7deg); +} + +.wave-right { + right: 0; + transform: skewY(7deg); +} + +/* Decorative diagonal lines crowd the headline on narrow viewports + (they're absolutely positioned at top: 152px; width: 32%) -- hide + them below the tablet breakpoint to keep the headline area clean. */ +@media (max-width: 720px) { + .wave-lines { display: none; } +} + +/* ───────────── Now-playing card ───────────── */ + +.now-playing { + display: grid; + grid-template-columns: + clamp(110px, 11vw, 160px) + minmax(0, 1.2fr) + minmax(0, 0.9fr) + minmax(0, 0.65fr) + minmax(0, 0.65fr) + minmax(0, 0.8fr); + align-items: center; + gap: 0; + min-height: 224px; + padding: 23px 24px; + border-radius: 12px; +} + +.app:not(.is-import) .now-playing { + flex: 0 0 auto; + min-height: clamp(120px, 14vh, 160px); + padding-top: clamp(7px, 1vh, 12px); + padding-bottom: clamp(7px, 1vh, 12px); +} + +.app:not(.is-import) .np-art { + width: clamp(92px, 13vh, 132px); + height: clamp(92px, 14vh, 144px); +} + +.np-art { + position: relative; + width: 154px; + height: 168px; + border-radius: 9px; + overflow: hidden; + background: + linear-gradient(135deg, rgba(14, 22, 30, 0.95), rgba(5, 9, 13, 0.96)), + var(--panel-2); + flex-shrink: 0; +} +.np-art-placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--ink-faint); + background: linear-gradient(135deg, #1a1a1a, #0e0e0e); +} +.np-art-img { + position: absolute; + inset: 6px; + width: 100%; + height: 100%; + width: calc(100% - 12px); + height: calc(100% - 12px); + object-fit: contain; + display: none; + border-radius: 6px; +} +.np-art-img.loaded { + display: block; +} +.np-art-play { + width: 50px; + height: 50px; + border-radius: 50%; + border: 0; + background: linear-gradient(180deg, var(--gold-bright), var(--gold)); + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 12px 24px rgba(216, 168, 74, 0.2); + transition: transform var(--t-fast) var(--easing), background-color var(--t-base) var(--easing); +} +.np-art-play:hover { + background: linear-gradient(180deg, #ecc76e, #c78a30); +} +.np-art-play svg { + margin-left: 2px; +} + +.np-info { + display: flex; + flex-direction: column; + gap: 14px; + min-width: 0; + padding: 0 clamp(14px, 2vw, 28px) 0 clamp(16px, 2.2vw, 30px); + border-right: 1px solid var(--glass-line); +} +.np-title { + font-size: clamp(19px, 1.45vw, 24px); + font-weight: 500; + color: var(--ink); + line-height: 1.2; + white-space: normal; + overflow: hidden; + text-overflow: clip; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow-wrap: anywhere; + max-width: 100%; +} +.np-chips { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.chip { + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 5px 12px; + background: rgba(255, 255, 255, 0.055); + color: var(--ink); + border: var(--border-w) solid var(--line); + border-radius: var(--radius-xs); + font-size: 14px; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.np-play-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 16px; + margin-top: 6px; + color: #c6cbd2; + font-size: 14px; +} + +.np-scrub { + height: 4px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.16); + position: relative; +} + +.np-scrub span { + position: absolute; + left: 0; + top: 50%; + width: 14px; + height: 14px; + transform: translateY(-50%); + border-radius: 50%; + background: var(--gold-bright); + box-shadow: 0 0 0 4px rgba(216, 168, 74, 0.18); +} + +.stem-energy, +.analysis-card { + min-height: clamp(100px, 14vh, 156px); + padding: 0 clamp(10px, 1.4vw, 24px); + border-left: 1px solid var(--glass-line); +} + +.stem-energy { + grid-column: 3; +} + +.key-card { + grid-column: 4; +} + +.loudness-card { + grid-column: 5; +} + +.beat-card { + grid-column: 6; +} + +.loudness-card.hidden + .beat-card { + grid-column: 5 / 7; +} + +.stem-energy h3, +.analysis-card h3 { + margin: 0 0 13px; + color: #c9ced6; + font-size: 14px; + text-transform: uppercase; +} + +.stem-energy h3 span, +.analysis-card h3 span, +.panel-title span { + display: inline-grid; + place-items: center; + width: 16px; + height: 16px; + margin-left: 4px; + border: 1px solid currentColor; + border-radius: 50%; + font-size: 10px; + opacity: 0.72; +} + +.energy-row { + display: grid; + grid-template-columns: 78px 1fr 40px; + align-items: center; + gap: 9px; + margin: 5px 0; + color: var(--stem-color); + font-size: 13px; +} + +.energy-row span { + font-weight: 700; +} + +.energy-row b { + height: 12px; + border-radius: 999px; + background: + linear-gradient(90deg, currentColor 0 var(--v, 0%), rgba(148, 163, 184, 0.13) var(--v, 0%) 100%); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05); + transition: background 80ms linear; +} + +.energy-row em { + color: #c8cdd4; + font-style: normal; + font-weight: 700; +} + +.vocals { --stem-color: #e54249; color: var(--stem-color); } +.drums { --stem-color: #f06a14; color: var(--stem-color); } +.bass { --stem-color: #eab414; color: var(--stem-color); } +.guitar { --stem-color: #5bbf50; color: var(--stem-color); } +.piano { --stem-color: #7f45d8; color: var(--stem-color); } +.other { --stem-color: #3e8dcf; color: var(--stem-color); } +.original { --stem-color: #a8b0bd; color: var(--stem-color); } + +.analysis-card { + display: flex; + flex-direction: column; + justify-content: center; + gap: 8px; +} + +.key-card { + align-items: center; + text-align: center; +} + +.beat-card { + align-items: center; + text-align: center; +} + +.analysis-card strong { + color: var(--gold-bright); + font-size: clamp(17px, 1.7vw, 26px); + line-height: 1.1; + font-weight: 500; +} + +.analysis-card small { + color: #b9bfc7; + font-size: 12px; +} + +.confidence-ring { + width: 66px; + height: 66px; + position: relative; + display: grid; + place-items: center; + border-radius: 50%; + background: + conic-gradient( + var(--gold) calc(var(--confidence-pct, 0) * 1%), + rgba(216, 168, 74, 0.16) 0 + ); + color: #e9edf2; + isolation: isolate; + font-size: 14px; + font-variant-numeric: tabular-nums; +} + +.confidence-ring::before { + content: ""; + position: absolute; + inset: 4px; + border-radius: 50%; + background: var(--panel); +} + +.confidence-ring::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 50%; + box-shadow: + inset 0 0 0 1px rgba(216, 168, 74, 0.12), + 0 0 14px rgba(216, 168, 74, 0.1); + pointer-events: none; +} + +.confidence-ring span { + position: relative; + z-index: 1; +} + +.loudness-card strong { + display: grid; + grid-template-columns: auto 1fr; + gap: 12px; + align-items: baseline; + color: #f0f2f5; +} + +.beat-values { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 16px; + min-width: 0; +} + +.beat-values strong { + display: grid; + grid-template-columns: auto 1fr; + gap: 12px; + align-items: baseline; + color: var(--gold-bright); + text-align: left; +} + +.beat-values strong small { + display: block; + margin-top: 0; + color: #b9bfc7; +} + +.np-transport { + display: flex; + align-items: center; + gap: 26px; + color: #cfd4db; + font-size: 14px; + font-variant-numeric: tabular-nums; +} +.np-transport-row { + display: flex; + align-items: center; + gap: 22px; +} +.btn-transport { + width: 58px; + height: 58px; + background: rgba(255, 255, 255, 0.055); + color: var(--ink); + border: 1px solid var(--glass-line); + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: + background-color var(--t-base) var(--easing), + color var(--t-base) var(--easing), + border-color var(--t-base) var(--easing), + transform var(--t-fast) var(--easing); +} +.btn-transport:hover { + background: rgba(255, 255, 255, 0.09); + border-color: var(--line-strong); +} +.btn-transport:active { + transform: scale(0.94); +} +.btn-transport .pause-icon { + display: none; +} +.btn-transport.playing .pause-icon { + display: block; +} +.btn-transport.playing svg:not(.pause-icon) { + display: none; +} +.btn-transport.playing { + background: linear-gradient(180deg, #4ade80, #16a34a); + color: white; + border-color: #15803d; + box-shadow: 0 16px 34px rgba(34, 197, 94, 0.22); +} +.btn-transport.stopped { + background: linear-gradient(180deg, #f87171, #dc2626); + color: white; + border-color: #b91c1c; + box-shadow: 0 16px 34px rgba(239, 68, 68, 0.22); +} +.btn-transport.loop.active { + background: linear-gradient(180deg, var(--gold-bright), var(--gold)); + color: white; + border-color: var(--gold-line); + box-shadow: 0 16px 34px rgba(216, 168, 74, 0.18); +} +.np-time { + font-size: 13px; + color: var(--ink-dim); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} diff --git a/static/css/variables.css b/static/css/variables.css new file mode 100644 index 0000000..a6f2366 --- /dev/null +++ b/static/css/variables.css @@ -0,0 +1,71 @@ +/* ── Design tokens ── */ +:root { + color-scheme: dark; + + /* Surfaces */ + --bg: #0b0f12; + --bg-2: #0f1418; + --panel: #131a1f; + --panel-2: #182026; + --panel-3: #1d262d; + --border: #232c34; + --border-strong: #2e3942; + + /* Text */ + --fg: #e8ecf0; + --fg-2: #c2c9d1; + --muted: #8a939c; + --muted-2: #5d666e; + + /* Stem colours */ + --vocals: #ef4444; + --drums: #f97316; + --bass: #eab308; + --guitar: #22c55e; + --piano: #a855f7; + --other: #9ca3af; + + /* Accent */ + --accent: #f4b740; + --accent-2: #d99a2b; + + /* Legacy compat aliases used by waves.css / player.js */ + --gold: var(--accent); + --gold-bright: #f8c054; + --gold-soft: rgba(244,183,64,0.16); + --gold-line: rgba(244,183,64,0.28); + --ink: var(--fg); + --ink-dim: var(--fg-2); + --ink-faint: var(--muted); + --line: var(--border); + --line-strong: var(--border-strong); + --glass-line: var(--border); + --danger: #d65a4a; + --focus-ring: rgba(236,236,236,0.55); + + /* Typography */ + --font-sans: 'Inter', -apple-system, system-ui, sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace; + + /* Layout */ + --header-w: 300px; + --lane-h: 86px; + --radius: 10px; + --radius-sm: 8px; + --radius-xs: 6px; + + /* Motion */ + --t-fast: 80ms; + --t-base: 120ms; + --easing: cubic-bezier(0.2,0.8,0.2,1); + --shadow-panel: 0 4px 24px rgba(0,0,0,0.4); +} + +@media (prefers-reduced-motion: reduce) { + :root { --t-fast: 0ms; --t-base: 0ms; } + *, *::before, *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } +} diff --git a/static/css/waves.css b/static/css/waves.css new file mode 100644 index 0000000..ebdfcfc --- /dev/null +++ b/static/css/waves.css @@ -0,0 +1,1011 @@ +/* ─── Post-import studio dashboard ─── */ + +.studio-dashboard { + display: flex; + flex-direction: column; + gap: 10px; +} + +.studio-top-grid { + display: grid; + grid-template-columns: minmax(760px, 1.42fr) minmax(560px, 1fr); + gap: 12px; +} + +.presence-panel, +.preview-panel, +.stems-panel, +.wave-editor { + border-radius: 12px; +} + +.panel-title, +.preview-head { + color: #d4d8de; + font-size: 15px; + font-weight: 700; + text-transform: uppercase; +} + +.presence-panel { + min-height: 292px; + padding: 17px 20px 16px; +} + +.presence-ruler { + display: grid; + grid-template-columns: 136px repeat(8, 1fr); + margin: 13px 0 5px; + color: #bec4cb; + font-size: 16px; + font-variant-numeric: tabular-nums; +} + +.presence-grid { + display: grid; + grid-template-columns: 136px 1fr; + gap: 12px; +} + +.presence-labels { + display: grid; + grid-template-rows: repeat(6, 33px); + align-items: center; + color: #cbd0d7; + font-weight: 700; +} + +.presence-labels span { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + font-size: 16px; +} + +.presence-icon { + width: 26px; + height: 26px; + flex: 0 0 auto; + filter: drop-shadow(0 0 8px color-mix(in srgb, currentColor 28%, transparent)); +} + +.presence-bars { + position: relative; + display: grid; + grid-template-rows: repeat(6, 33px); + border: 1px solid rgba(148, 163, 184, 0.12); + background: + repeating-linear-gradient(90deg, rgba(148, 163, 184, 0.07) 0 1px, transparent 1px 6.25%), + rgba(2, 9, 14, 0.45); +} + +/* DAW-style playhead overlay -- thin vertical line that rides the + top edge of the bars container and tracks the current play time. + transform: translateX(-50%) so the line is centered on the exact + percentage. */ +.presence-playhead { + position: absolute; + top: -2px; + bottom: -2px; + left: 0; + width: 1.5px; + background: var(--gold); + transform: translateX(-50%); + pointer-events: none; + z-index: 4; + box-shadow: 0 0 6px color-mix(in srgb, var(--gold) 70%, transparent); +} + +.presence-playhead.hidden { + display: none; +} + +.presence-bars i { + position: relative; + display: block; + overflow: hidden; + border-bottom: 1px solid rgba(148, 163, 184, 0.09); + background: rgba(4, 13, 19, 0.34); + color: var(--stem-color); + --cell-hi: color-mix(in srgb, currentColor 78%, transparent); + --cell-md: color-mix(in srgb, currentColor 52%, transparent); + --cell-lo: color-mix(in srgb, currentColor 28%, transparent); + --cells: + repeating-linear-gradient(90deg, + var(--cell-lo) 0 7px, + transparent 7px 9px, + var(--cell-md) 9px 16px, + transparent 16px 18px, + var(--cell-hi) 18px 27px, + transparent 27px 31px, + var(--cell-md) 31px 36px, + transparent 36px 42px, + var(--cell-lo) 42px 49px, + transparent 49px 54px); + --presence-mask: linear-gradient(90deg, black 0 100%); +} + +.presence-bars i::before, +.presence-bars i::after { + content: ""; + position: absolute; + left: 16px; + right: 10px; + top: 6px; + bottom: 6px; + border-radius: 2px; + background: var(--cells); + -webkit-mask-image: var(--presence-mask); + mask-image: var(--presence-mask); + filter: drop-shadow(0 0 7px color-mix(in srgb, currentColor 22%, transparent)); +} + +.presence-bars i::after { + top: 9px; + bottom: 9px; + opacity: 0.34; + transform: translateX(5px); + background: + repeating-linear-gradient(90deg, + transparent 0 8px, + var(--cell-lo) 8px 13px, + transparent 13px 18px, + var(--cell-md) 18px 22px, + transparent 22px 29px); +} + +.presence-bars i:nth-child(2) { + --cell-hi: color-mix(in srgb, currentColor 82%, transparent); + --cell-md: color-mix(in srgb, currentColor 58%, transparent); + --cell-lo: color-mix(in srgb, currentColor 34%, transparent); + --presence-mask: + linear-gradient(90deg, + black 0 72%, transparent 72% 73.5%, black 73.5% 100%); +} + +.presence-bars i:nth-child(3) { + --cell-hi: color-mix(in srgb, currentColor 72%, transparent); + --cell-md: color-mix(in srgb, currentColor 48%, transparent); + --cell-lo: color-mix(in srgb, currentColor 24%, transparent); + --presence-mask: + linear-gradient(90deg, + black 0 15%, transparent 15% 31.8%, black 31.8% 63.5%, + transparent 63.5% 85.7%, black 85.7% 98%, transparent 98%); +} + +.presence-bars i:nth-child(4) { + --cell-hi: color-mix(in srgb, currentColor 70%, transparent); + --cell-md: color-mix(in srgb, currentColor 46%, transparent); + --cell-lo: color-mix(in srgb, currentColor 24%, transparent); + --presence-mask: + linear-gradient(90deg, + transparent 0 3.2%, black 3.2% 12.8%, transparent 12.8% 16.5%, + black 16.5% 91.5%, transparent 91.5% 94.7%, black 94.7% 99%, + transparent 99%); +} + +.presence-bars i:nth-child(5) { + --cell-hi: color-mix(in srgb, currentColor 76%, transparent); + --cell-md: color-mix(in srgb, currentColor 50%, transparent); + --cell-lo: color-mix(in srgb, currentColor 26%, transparent); + --presence-mask: + linear-gradient(90deg, + black 0 4.5%, transparent 4.5% 15.8%, black 15.8% 62.5%, + transparent 62.5% 70.5%, black 70.5% 96%, transparent 96%); +} + +.presence-bars i:nth-child(6) { + --cell-hi: color-mix(in srgb, currentColor 68%, transparent); + --cell-md: color-mix(in srgb, currentColor 45%, transparent); + --cell-lo: color-mix(in srgb, currentColor 23%, transparent); + --presence-mask: + linear-gradient(90deg, + black 0 12%, transparent 12% 23.5%, black 23.5% 52.2%, + transparent 52.2% 60.8%, black 60.8% 97%, transparent 97%); +} + +.presence-bars i:first-child { + --cell-hi: color-mix(in srgb, currentColor 74%, transparent); + --cell-md: color-mix(in srgb, currentColor 50%, transparent); + --cell-lo: color-mix(in srgb, currentColor 25%, transparent); + --presence-mask: + linear-gradient(90deg, + black 0 15.2%, transparent 15.2% 19%, black 19% 42.7%, + transparent 42.7% 50.3%, black 50.3% 60.6%, transparent 60.6% 65.8%, + black 65.8% 88.6%, transparent 88.6% 93.7%, black 93.7% 97.6%, + transparent 97.6%); +} + +.preview-panel { + min-height: 292px; + padding: 14px 18px 16px; +} + +.preview-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding-bottom: 10px; + border-bottom: 1px solid var(--glass-line); +} + +.preview-head div { + display: flex; + gap: 12px; +} + +.preview-head button, +.footer-btn { + min-height: 36px; + padding: 0 16px; + border: 1px solid var(--glass-line); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.04); + color: #d8dde3; + font: inherit; + text-decoration: none; + cursor: pointer; +} + +.footer-btn:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.18); + color: #fff; +} + +.preview-head button { + display: inline-flex; + align-items: center; + gap: 9px; + font-size: 14px; + font-weight: 700; +} + +.preview-mixer-body { + display: grid; + grid-template-columns: minmax(0, 1fr) 94px; + gap: 22px; + align-items: stretch; + padding-top: 14px; +} + +.master-strip { + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: auto 128px; + gap: 10px 8px; + justify-items: center; + color: #d1d6dd; +} + +.master-strip .master-fader-track { + height: 128px; +} + +.master-strip > span { + grid-column: 1 / -1; +} + +.master-scale { + display: flex; + flex-direction: column; + justify-content: space-between; + color: #a8b0ba; + font-size: 12px; +} + +.studio-wave-grid { + display: grid; + grid-template-columns: 424px minmax(0, 1fr); + gap: 10px; +} + +.stems-panel { + min-height: 326px; + padding: 16px 18px; +} + +.stem-list { + display: flex; + flex-direction: column; + gap: 1px; + /* Aligns the first row with the first waveform lane in the wave-editor + panel next door. The wave editor stacks (zoom-toolbar) + (ruler) + above its first lane; the stems panel only has the "Stems" title. + This top padding fills the difference so each Vocals/Drums/etc. + label sits exactly opposite its corresponding waveform. */ + margin-top: 46px; +} + +.stem-list span { + /* Match the multitrack lane height (48px set in player.js wireUpAudio + options.height, plus 1px border between lanes). Combined with the + 1px gap above, each row strides 49px -- identical to wave lanes. */ + height: 48px; + display: grid; + grid-template-columns: 18px minmax(0, 1fr) 34px 34px 36px 30px; + align-items: center; + gap: 10px; + color: #d5dae0; + font-size: 17px; +} + +.stem-list em { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + color: #d5dae0; + font-style: normal; + font-weight: 700; + white-space: nowrap; +} + +.stem-icon { + width: 27px; + height: 27px; + flex: 0 0 auto; + color: var(--stem-color); + filter: drop-shadow(0 0 8px color-mix(in srgb, currentColor 26%, transparent)); +} + +.drag-handle { + width: 14px; + height: 22px; + opacity: 0.56; + background: + radial-gradient(circle, #c9ced6 1.4px, transparent 1.7px) 0 0 / 7px 7px; +} + +.stem-list b { + display: inline-grid; + place-items: center; + width: 30px; + height: 30px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.055); + color: #d8dde4; + font-size: 14px; + cursor: pointer; + user-select: none; + transition: + background-color var(--t-base) var(--easing), + color var(--t-base) var(--easing), + transform var(--t-fast) var(--easing); +} + +.stem-list b:hover { + background: rgba(255, 255, 255, 0.1); +} + +.stem-list b:active { + transform: scale(0.92); +} + +.stem-list b:focus-visible { + outline: 2px solid var(--gold); + outline-offset: 1px; +} + +.stem-list .stem-mute.active { + background: var(--danger); + color: white; +} + +.stem-list .stem-solo.active { + background: var(--gold); + color: #17130c; +} + +/* Mute the row visually when its stem is muted (matches lane-header). */ +.stem-list span.muted { + opacity: 0.45; +} + +.stem-list button { + width: 34px; + height: 34px; + border: 1px solid var(--glass-line); + border-radius: 7px; + background: + radial-gradient(circle at 50% 52%, transparent 0 6px, currentColor 6px 8px, transparent 8px), + linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.025)); + color: #cfd5dc; + cursor: pointer; + transition: + border-color var(--t-base) var(--easing), + color var(--t-base) var(--easing), + background-color var(--t-base) var(--easing); +} + +.stem-list button:hover { + border-color: var(--gold); + color: var(--gold); +} + +.stem-list .stem-monitor.active { + color: var(--gold); + border-color: var(--gold); + background: + radial-gradient(circle at 50% 52%, transparent 0 6px, currentColor 6px 8px, transparent 8px), + color-mix(in srgb, var(--gold) 18%, transparent); +} + +.mini-meter { + position: relative; + width: 20px; + height: 34px; + border-radius: 4px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: + linear-gradient(to top, + rgba(48, 197, 87, 0.14) 0 55%, + rgba(225, 178, 46, 0.14) 55% 80%, + rgba(214, 64, 64, 0.14) 80% 100%), + rgba(8, 14, 20, 0.85); + overflow: hidden; + box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.45); +} + +.mini-meter::before { + content: ""; + position: absolute; + inset: 1px; + background: linear-gradient(to top, + #30c557 0%, + #30c557 50%, + #e1b22e 60%, + #e1b22e 78%, + #d64040 88%, + #d64040 100%); + transform: scaleY(var(--vu-scale, 0)); + transform-origin: bottom; + /* Short transition so the meter still reads as fluid rather than + stepwise, but quick enough that drum-hit transients aren't washed + out. The peak-hold logic in audio.js does the real "VU feel". */ + transition: transform 30ms linear; + pointer-events: none; +} + +.mini-meter::after { + content: ""; + position: absolute; + left: 1px; + right: 1px; + height: 2px; + bottom: calc(var(--vu-peak-pct, 0) * 1%); + background: rgba(255, 255, 255, 0.78); + opacity: var(--vu-peak-opacity, 0); + transition: bottom 80ms linear, opacity 180ms linear; + pointer-events: none; +} + +.wave-editor { + min-width: 0; + min-height: 326px; + padding: 12px 14px 14px; +} + +.wave-toolbar { + display: flex; + justify-content: flex-end; + align-items: center; + margin-bottom: 6px; +} + +/* Shared scroll viewport for the ruler + waves. Putting both in the + same scroll container with a single inner canvas guarantees the + ruler-time element and the loop-region overlay share one coordinate + system, so click positions on the ruler line up perfectly with the + visual loop bars. The canvas also widens with --zoom so a single CSS + variable drives both visual layout and multitrack pxPerSec. */ +.wave-scroll { + overflow-x: auto; + overflow-y: hidden; + border: 1px solid rgba(148, 163, 184, 0.1); + background: rgba(3, 10, 15, 0.52); + scrollbar-width: thin; + scrollbar-color: rgba(216, 168, 74, 0.65) rgba(148, 163, 184, 0.12); + scroll-behavior: smooth; +} + +.wave-scroll::-webkit-scrollbar { + height: 10px; +} + +.wave-scroll::-webkit-scrollbar-track { + background: rgba(148, 163, 184, 0.1); + border-radius: 999px; +} + +.wave-scroll::-webkit-scrollbar-thumb { + background: linear-gradient(90deg, rgba(216, 168, 74, 0.55), rgba(245, 203, 110, 0.9)); + border: 2px solid rgba(3, 10, 15, 0.72); + border-radius: 999px; +} + +.wave-canvas { + position: relative; + width: calc(100% * var(--zoom, 1)); + min-width: 100%; +} + +.lanes-ruler { + height: 30px; +} + +.lanes-ruler-time { + position: relative; + height: 100%; + cursor: crosshair; + touch-action: none; + user-select: none; +} + +.tick { + position: absolute; + top: 0; + bottom: 0; + border-left: 1px solid rgba(148, 163, 184, 0.2); + padding-left: 4px; + display: flex; + align-items: flex-start; + pointer-events: none; +} + +.tick-label { + color: #b8bec7; + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.playhead-marker { + position: absolute; + top: 0; + left: 0; + transform: translateX(-50%); + pointer-events: none; + z-index: 8; +} + +.app:not(.is-import) .playhead-marker::after { + content: ""; + position: absolute; + left: 50%; + top: 8px; + width: 1.5px; + height: calc(var(--wave-playhead-h, 480px) + 26px); + transform: translateX(-50%); + background: #e54e4e; + box-shadow: 0 0 7px rgba(229, 78, 78, 0.34); + pointer-events: none; +} + +.zoom-tools { + display: flex; + align-items: center; + gap: 10px; + color: #c9ced6; + font-size: 18px; +} + +.zoom-slider { + width: 116px; + height: 18px; + accent-color: var(--gold); + background: transparent; + cursor: pointer; +} + +.zoom-slider::-webkit-slider-runnable-track { + height: 4px; + border-radius: 999px; + background: linear-gradient(90deg, var(--gold) calc(var(--zoom-frac, 0) * 100%), rgba(148, 163, 184, 0.2) calc(var(--zoom-frac, 0) * 100%)); +} + +.zoom-slider::-webkit-slider-thumb { + appearance: none; + width: 14px; + height: 14px; + margin-top: -5px; + border-radius: 50%; + background: #f3d37c; + border: 2px solid rgba(255, 255, 255, 0.78); + box-shadow: 0 0 10px rgba(216, 168, 74, 0.28); +} + +.zoom-slider::-moz-range-track { + height: 4px; + border-radius: 999px; + background: linear-gradient(90deg, var(--gold) calc(var(--zoom-frac, 0) * 100%), rgba(148, 163, 184, 0.2) calc(var(--zoom-frac, 0) * 100%)); +} + +.zoom-slider::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + background: #f3d37c; + border: 2px solid rgba(255, 255, 255, 0.78); + box-shadow: 0 0 10px rgba(216, 168, 74, 0.28); +} + +.zoom-tools button { + min-height: 32px; + min-width: 32px; + padding: 0 12px; + border: 1px solid var(--glass-line); + border-radius: 6px; + background: rgba(255, 255, 255, 0.04); + color: #d8dde3; + font: inherit; + cursor: pointer; + transition: background-color var(--t-base) var(--easing); +} + +.zoom-tools button:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); +} + +.zoom-tools button:disabled { + opacity: 0.4; + cursor: default; +} + +.zoom-tools #zoom-out, +.zoom-tools #zoom-in { + font-size: 22px; + padding: 0; + width: 32px; +} + +.zoom-tools .kebab { + width: 32px; + padding: 0; + font-size: 22px; +} + +.waves-column { + min-width: 0; + position: relative; + overflow: hidden; + min-height: 274px; + --wave-gutter: 0px; +} + +.waves-grid { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 1; +} + +.waves-grid .grid-line { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: rgba(148, 163, 184, 0.14); +} + +#multitrack-container { + width: 100%; + position: relative; + z-index: 2; +} + +.stem-waveform-layer { + position: absolute; + inset: 0; + z-index: 3; + pointer-events: none; + display: flex; + flex-direction: column; +} + +.stem-waveform-row { + /* Fixed 48px height matches the multitrack lane height + (set in player.js wireUpAudio options.height: 48) and the + stem-list row height in the Stems sidebar. Without this the + overlay used flex distribution across the whole waves-column, + producing rows ~0.8 px taller than the stem-list rows -- the + drift accumulated across 6 rows and visibly misaligned the + stem labels with their waveforms. */ + flex: 0 0 48px; + height: 48px; + position: relative; + border-bottom: 1px solid rgba(148, 163, 184, 0.08); +} + +.stem-waveform-row:last-child { + border-bottom: 0; +} + +.stem-waveform-svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; +} + +.stem-waveform-svg path, +.stem-waveform-svg rect { + fill: var(--stem-color); + opacity: 0.78; + filter: drop-shadow(0 0 7px color-mix(in srgb, var(--stem-color) 30%, transparent)); +} + +#multitrack-container > div { + border-bottom: 1px solid rgba(148, 163, 184, 0.08); + background: transparent !important; +} + +#multitrack-container > div:last-child { + border-bottom: 0; +} + +#multitrack-container [part="timeline-wrapper"], +#multitrack-container [part^="timeline-"] { + display: none !important; +} + +.waves-column [style*="z-index: 10"] { + transform: translateX(-50%); +} + +.loop-region { + position: absolute; + top: 0; + bottom: 0; + background: rgba(216, 168, 74, 0.1); + border-left: 2px solid var(--gold); + border-right: 2px solid var(--gold); + box-shadow: + inset 1px 0 0 rgba(255, 255, 255, 0.18), + inset -1px 0 0 rgba(255, 255, 255, 0.18); + pointer-events: none; + z-index: 3; +} + +.lane-placeholder { + height: 48px; + position: relative; +} + +.lane-placeholder::after { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 50%; + height: 2px; + background: var(--lane-color, var(--line)); + opacity: 0.55; +} + +.transport-footer { + min-height: 86px; + display: grid; + grid-template-columns: minmax(0, 1fr); + justify-items: center; + align-items: center; + gap: 24px; + padding: 6px 4px 0; +} + +.transport-footer .np-transport { + justify-self: center; +} + +.footer-btn { + min-height: 36px; + font-size: 13px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +/* ─── Screenshot-aligned DAW layout ─── */ + +.app:not(.is-import) .studio-dashboard { + flex: 1 1 auto; + display: grid; + grid-template-columns: + minmax( + clamp(340px, calc((var(--visible-track-count, 6) * 70px) + 112px), 650px), + clamp(360px, calc((var(--visible-track-count, 6) * 78px) + 128px), 720px) + ) + minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) minmax(74px, 86px); + gap: 10px; + min-height: 0; + overflow: hidden; +} + +.app:not(.is-import) .studio-top-grid, +.app:not(.is-import) .studio-wave-grid { + display: contents; +} + +.app:not(.is-import) .presence-panel { + display: none; +} + +.app:not(.is-import) .preview-panel { + grid-column: 1; + grid-row: 1; + min-height: 0; + padding: 10px 8px 12px; +} + +.app:not(.is-import) .wave-editor { + grid-column: 2; + grid-row: 1; + min-height: 0; + padding: 6px 12px; + overflow: hidden; +} + +.app:not(.is-import) .stems-panel { + grid-column: 2; + grid-row: 1; + align-self: stretch; + width: 48px; + /* 6px panel padding + 32px toolbar + 34px ruler = 72px top; + 6px matches wave-editor bottom padding so height == waves-column height */ + margin: 72px 0 6px 12px; + height: auto; + overflow: hidden; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; + z-index: 4; + pointer-events: none; +} + +.app:not(.is-import) .stems-panel .panel-title { + display: none; +} + +.app:not(.is-import) .stem-list { + height: 100%; + margin: 0; + gap: 0; + display: flex; + flex-direction: column; +} + +.app:not(.is-import) .stem-list span { + flex: 1 1 0; + min-height: 0; + display: grid; + grid-template-columns: 1fr; + place-items: center; + gap: 0; + color: var(--stem-color); +} + +.app:not(.is-import) .stem-list .drag-handle, +.app:not(.is-import) .stem-list b, +.app:not(.is-import) .stem-list button, +.app:not(.is-import) .stem-list .mini-meter { + display: none; +} + +.app:not(.is-import) .stem-list em { + display: grid; + place-items: center; + font-size: 0; +} + +.app:not(.is-import) .stem-icon { + width: 24px; + height: 24px; +} + +.app:not(.is-import) .wave-toolbar { + height: 30px; + margin: 0 0 2px; +} + +.app:not(.is-import) .wave-scroll { + height: calc(100% - 32px); + overflow-x: auto; + overflow-y: hidden; + border-radius: 8px; +} + +.app:not(.is-import) .wave-canvas { + height: 100%; +} + +.app:not(.is-import) .lanes-ruler { + height: 34px; + margin-left: 48px; +} + +.app:not(.is-import) .waves-column { + min-height: calc(100% - 34px); + height: calc(100% - 34px); + padding-left: 48px; + --wave-gutter: 48px; +} + +.app:not(.is-import) .waves-grid { + left: 48px; +} + +.app:not(.is-import) #multitrack-container { + position: absolute; + inset: 0 0 0 48px; + opacity: 0; + pointer-events: none; +} + +.app:not(.is-import) .stem-waveform-layer { + left: 48px; + right: 0; + /* waves-column already starts below the ruler, no offset needed */ + top: 0; + bottom: 0; + height: auto; +} + +.app:not(.is-import) .stem-waveform-row { + flex: 1 1 0; + height: auto; + min-height: 0; + border-bottom: 1px solid rgba(148, 163, 184, 0.11); +} + +.app:not(.is-import) .stem-waveform-svg { + inset: 0; + height: 100%; +} + +.app:not(.is-import) .stem-waveform-svg path, +.app:not(.is-import) .stem-waveform-svg rect { + opacity: 0.92; + filter: none; +} + +.app:not(.is-import) .transport-footer { + grid-column: 1 / -1; + grid-row: 2; + min-height: 0; + display: grid; + grid-template-columns: minmax(120px, 1fr) auto minmax(120px, 1fr); + align-items: center; + justify-items: stretch; + gap: 22px; + padding: 14px 16px; + border: 1px solid var(--glass-line); + border-radius: 10px; + background: + radial-gradient(circle at 50% 0, rgba(216, 168, 74, 0.05), transparent 28rem), + rgba(11, 22, 30, 0.68); +} + +.app:not(.is-import) .footer-btn { + min-width: 100px; + min-height: 36px; + padding: 0 14px; + font-size: 13px; + gap: 7px; +} + +.app:not(.is-import) .transport-footer .np-transport { + grid-column: 2; + justify-self: center; +} + +.footer-actions { + display: flex; + grid-column: 3; + justify-self: end; + gap: 18px; +} diff --git a/static/css/widgets.css b/static/css/widgets.css new file mode 100644 index 0000000..38b604a --- /dev/null +++ b/static/css/widgets.css @@ -0,0 +1,1609 @@ +/* ────────────────────────────────────────────── + widgets.css — New layout + collapsible widgets + horizontal mixer + Loaded last; overrides base.css / appbar.css / mixer.css where needed. + ────────────────────────────────────────────── */ + +/* ─── 1. App shell: 2-column grid (catalog + main) ─── */ +.app { + display: grid !important; + grid-template-columns: var(--cat-w, 370px) 1fr !important; + grid-template-rows: 74px minmax(0, 1fr) !important; + max-width: none !important; + width: 100% !important; + height: 100vh !important; + min-height: 0 !important; + padding: 12px !important; + gap: 12px !important; + overflow: hidden !important; + margin: 0 !important; + transition: grid-template-columns 240ms ease; +} +.app.cat-collapsed { --cat-w: 78px; } + +.app > .topbar.appbar { + grid-column: 1 / -1; + grid-row: 1; +} + +.app > .catalog { + grid-column: 1; + grid-row: 2; +} + +.app > #lanes { + grid-column: 2; + grid-row: 2; +} + +/* ─── 2. Main area (#lanes) ─── */ +#lanes { + position: relative; + overflow-y: auto; + overflow-x: hidden; + display: flex !important; + flex-direction: column !important; + gap: 10px !important; + padding-bottom: 0; + height: 100%; + min-height: 0; + /* Remove the studio-dashboard layout */ +} + +#lanes.library-drop-target { + outline: 1px solid rgba(216, 168, 74, 0.54); + outline-offset: -4px; +} + +#lanes.library-drop-target::after { + content: "Drop to load track"; + position: fixed; + right: 28px; + top: 28px; + z-index: 30; + padding: 8px 12px; + border: 1px solid rgba(216, 168, 74, 0.44); + border-radius: 8px; + background: rgba(15, 23, 31, 0.92); + color: var(--gold); + font-family: var(--font-mono); + font-size: 12px; + pointer-events: none; + box-shadow: var(--shadow-panel); +} + +/* Suppress the old studio-dashboard nested flex that conflicts */ +#lanes > .studio-dashboard { + display: contents; +} + +/* ─── 3. Topbar: 2-col (brand + form) ─── */ +.topbar.appbar { + min-height: 70px !important; + height: 70px !important; + gap: 0 !important; + padding: 10px 22px !important; + flex-shrink: 0; + align-items: center !important; + border-radius: 12px !important; + overflow: visible !important; +} + +.app.appbar-collapsed .topbar.appbar { + min-height: 70px !important; + height: 70px !important; + padding: 10px 22px !important; + align-items: center !important; + justify-content: center !important; +} + +.topbar .appbar-body { + display: block !important; + width: 100%; + opacity: 1 !important; + pointer-events: auto !important; +} + +.topbar .appbar-body-inner { + display: grid !important; + grid-template-columns: 270px minmax(0, 1fr); + align-items: center; + gap: 18px; + width: 100%; +} + +.app-menu-btn { + width: 56px; + height: 56px; + display: grid; + place-items: center; + border: 1px solid var(--glass-line); + border-radius: 12px; + background: + linear-gradient(180deg, rgba(255,255,255,0.035), transparent), + rgba(17, 27, 36, 0.78); + color: var(--ink); + cursor: pointer; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.04); +} + +.app-menu-btn:hover { + background-color: rgba(31,43,52,0.78); +} + +.topbar .brand-group { + padding: 0 !important; + gap: 0; +} + +.topbar .brand-row { + gap: 14px; +} + +.topbar .brand { + font-size: 23px; + letter-spacing: 0; +} + +.topbar .brand-logo { + width: 190px; +} + +.topbar .beta-badge { + height: 20px; + border-radius: 5px; + padding: 0 6px; + font-size: 10px; +} + +.topbar .brand-sub, +.topbar .brand-version { + display: none !important; +} + +.topbar .brand-version.has-update a { + display: inline-flex; + align-items: center; + gap: 5px; + color: #78dc84; + text-decoration: none; +} + +.topbar .brand-version.has-update a::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 999px; + background: currentColor; + box-shadow: 0 0 10px rgba(120, 220, 132, 0.55); +} + +.topbar .brand-version.has-update a:hover { + color: #9af0a4; +} + +.app.appbar-collapsed .appbar-body { + display: block !important; + height: auto !important; +} + +.app.appbar-collapsed .appbar-icon-strip { + display: none !important; + align-items: center !important; + justify-content: center !important; + width: 100% !important; + height: 40px !important; + min-height: 40px !important; + overflow: visible !important; +} + +.app.appbar-collapsed .appbar-strip-inner { + height: 40px !important; + min-height: 40px !important; + align-items: center !important; + overflow: visible !important; +} + +.topbar .appbar-import-panel { + display: grid; + grid-template-columns: minmax(260px, 1fr) minmax(0, auto) auto; + align-items: center; + gap: 10px; + min-width: 0; +} + +.topbar .import-input-row { + display: contents; +} + +.topbar .appbar-import-panel .url-wrap { + min-height: 44px !important; + height: 44px !important; + padding: 0 14px !important; + border-radius: 9px !important; +} + +.topbar .appbar-import-panel input[type="url"] { + font-size: 14px !important; +} + +.topbar .appbar-import-panel .btn-primary { + height: 44px !important; + min-width: 132px !important; + border-radius: 9px !important; + font-size: 14px !important; + padding: 0 16px !important; + order: 3; +} + +.topbar .stem-choice-row { + display: grid; + grid-template-columns: repeat(6, minmax(74px, 1fr)); + gap: 6px; + padding: 0; + order: 2; + min-width: 0; +} + +.topbar .stem-choice-row > span { display: none; } + +.topbar .stem-choice { + min-height: 44px; + height: 44px; + min-width: 0; + padding: 0 10px; + border-radius: 9px; + font-size: 11.5px; + gap: 6px; + background: + linear-gradient(180deg, rgba(255,255,255,0.025), transparent), + rgba(8, 15, 22, 0.38); +} + +.topbar .stem-choice svg { + width: 15px; + height: 15px; +} + +.topbar .stem-choice.other { + color: #c492ff; + min-width: 0; +} + +@media (max-width: 1250px) { + .topbar .appbar-body-inner { + grid-template-columns: 236px minmax(0, 1fr); + gap: 14px; + } + + .topbar .brand { + font-size: 20px; + } + + .topbar .brand-logo { + width: 170px; + } + + .topbar .beta-badge { + height: 18px; + padding: 0 5px; + font-size: 9px; + } + + .topbar .appbar-import-panel { + grid-template-columns: minmax(210px, 1fr) minmax(0, auto) auto; + gap: 8px; + } + + .topbar .appbar-import-panel .url-wrap { + min-height: 40px !important; + height: 40px !important; + padding: 0 12px !important; + } + + .topbar .appbar-import-panel input[type="url"] { + font-size: 13px !important; + } + + .topbar .stem-choice-row { + grid-template-columns: repeat(6, 42px); + gap: 5px; + } + + .topbar .stem-choice { + width: 42px; + height: 40px; + min-height: 40px; + padding: 0; + } + + .topbar .stem-choice span { + display: none; + } + + .topbar .stem-choice svg { + width: 18px; + height: 18px; + } + + .topbar .appbar-import-panel .btn-primary { + height: 40px !important; + min-width: 118px !important; + padding: 0 14px !important; + font-size: 13px !important; + } +} + +/* ─── Reference-style library sidebar with icon rail ─── */ +.catalog { + display: grid !important; + grid-template-columns: 72px minmax(0, 1fr); + grid-template-rows: 1fr; + align-items: stretch !important; + height: 100% !important; + min-height: 0 !important; + gap: 0 !important; + padding: 0 !important; + border-radius: 12px !important; + overflow: hidden !important; + transition: grid-template-columns 240ms ease; +} + +.catalog-rail { + grid-column: 1; + grid-row: 1; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 10px; + padding: 18px 8px 16px; + border-right: 1px solid rgba(157,170,182,0.12); + background: + radial-gradient(circle at 100% 0%, rgba(216,168,74,0.08), transparent 16rem), + rgba(8, 17, 24, 0.42); +} + +.rail-menu-btn { + width: 48px; + height: 48px; + margin: 0 auto 14px; + display: grid; + place-items: center; + border: 1px solid var(--glass-line); + border-radius: 11px; + background: + linear-gradient(180deg, rgba(255,255,255,0.035), transparent), + rgba(17, 27, 36, 0.78); + color: var(--ink); + cursor: pointer; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.04); + flex: 0 0 auto; +} + +.rail-menu-btn:hover { + background-color: rgba(31,43,52,0.78); +} + +.rail-btn { + min-height: 58px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--ink-faint); + display: grid; + grid-template-rows: 24px auto; + place-items: center; + gap: 4px; + padding: 7px 3px; + font-family: var(--font-mono); + font-size: 10px; + line-height: 1; + text-decoration: none; + cursor: pointer; + position: relative; +} + +.rail-btn:hover { + color: var(--ink); + background: rgba(255,255,255,0.035); +} + +.rail-btn.drop-target { + color: var(--danger); + background: rgba(232, 95, 111, 0.12); + box-shadow: inset 0 0 0 1px rgba(232, 95, 111, 0.36); +} + +.rail-btn.active { + color: var(--gold); + background: rgba(216,168,74,0.08); +} + +.rail-btn.active::before { + content: ""; + position: absolute; + left: -7px; + top: 9px; + bottom: 9px; + width: 2px; + border-radius: 999px; + background: var(--gold); +} + +.rail-spacer { + flex: 1; +} + +.catalog-main { + grid-column: 2; + grid-row: 1; + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; + min-height: 0; + padding: 12px 14px 14px; +} + +.catalog-head { + display: none !important; +} + +.catalog-search { + min-height: 40px; + padding: 0 12px !important; + border-radius: 9px !important; +} + +.catalog.trash-view .catalog-search { + color: var(--ink-faint); + border-style: dashed; +} + +.catalog.trash-view .new-folder-btn { + display: none !important; +} + +.catalog.trash-view .cat-item { + opacity: 0.82; +} + +.trash-empty { + padding: 10px 12px !important; +} + +.about-backdrop { + position: fixed; + inset: 0; + z-index: 100; + display: grid; + place-items: center; + background: rgba(3, 8, 13, 0.48); + backdrop-filter: blur(2px); +} + +.about-backdrop.hidden { + display: none !important; +} + +.about-card { + width: min(400px, calc(100vw - 32px)); + position: relative; + padding: 24px; + border: 1px solid var(--glass-line); + border-radius: 12px; + background: linear-gradient(180deg, rgba(28,39,48,0.98), rgba(13,21,29,0.98)); + box-shadow: var(--shadow-panel); + color: var(--ink); + font-family: var(--font-mono); +} + +.about-card h2 { + margin: 0; + font-size: 26px; + line-height: 1.1; +} + +.about-version { + margin: 8px 0 18px; + color: var(--gold); + font-size: 14px; + font-weight: 700; +} + +.about-link { + color: var(--ink); + font-size: 14px; + line-height: 1.4; + text-decoration: none; + overflow-wrap: anywhere; +} + +.about-link:hover { + color: var(--gold); +} + +.about-close { + position: absolute; + top: 10px; + right: 10px; + width: 26px; + height: 26px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--ink-faint); + cursor: pointer; + font: inherit; +} + +.about-close:hover { + background: rgba(21,31,39,0.8); + color: var(--ink); +} + +.new-folder-btn { + min-height: 40px; + padding: 0 12px !important; + border-radius: 9px !important; +} + +.catalog-list { + padding-right: 0 !important; +} + +.app.cat-collapsed .catalog { + grid-template-columns: 72px !important; +} + +.catalog-main { + transition: opacity 200ms ease; +} + +.app.cat-collapsed .catalog-main { + opacity: 0; + pointer-events: none; + overflow: hidden; +} + +/* ─── 4. File drop pill ─── */ +.url-wrap { position: relative; } + +.file-pill { + display: none; + align-items: center; + gap: 6px; + padding: 3px 6px 3px 10px; + border: 1px solid rgba(95, 188, 86, 0.45); + background: rgba(95, 188, 86, 0.08); + border-radius: 7px; + font-size: 11.5px; + color: #5fbc56; + max-width: 100%; +} +.file-pill:not(.hidden) { display: inline-flex; } +.file-pill .file-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 180px; +} +.file-pill .file-size { color: var(--ink-faint); font-size: 10.5px; } +.file-pill .file-clear { + background: transparent; + border: none; + color: var(--ink-dim); + cursor: pointer; + padding: 2px 4px; + line-height: 1; + font-size: 14px; + border-radius: 4px; + font-family: inherit; +} +.file-pill .file-clear:hover { background: var(--panel-2, rgba(21,31,39,0.82)); color: var(--ink); } + +/* When a file is loaded, hide the text input and icon */ +.url-wrap.has-file .url-prefix, +.url-wrap.has-file input[type="url"], +.url-wrap.has-file input[type="text"] { display: none; } + +.url-wrap.drag-over { + border-color: rgba(216, 168, 74, 0.56) !important; + background-color: rgba(216, 168, 74, 0.04) !important; +} + +/* ─── 5. Job progress: fix always-hidden CSS bug ─── */ +.job:not(.hidden) { + display: block !important; +} + +/* ─── 6. Grafana-style collapsible widget ─── */ +.widget { + flex-shrink: 0; + border-radius: 12px; + overflow: hidden; + border: 1px solid var(--glass-line); + background: linear-gradient(180deg, rgba(28,39,48,0.82), rgba(15,23,31,0.86)); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.045), var(--shadow-panel); +} + +.widget-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 16px; + cursor: pointer; + user-select: none; + border-bottom: 1px solid var(--glass-line); + transition: background var(--t-fast, 120ms) ease; +} +.widget-head:hover { background: rgba(31,43,52,0.55); } + +.widget-head .title-block { + display: flex; + align-items: center; + gap: 10px; +} +.widget-head h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--ink); +} +.widget-head .chevron { + width: 16px; + height: 16px; + color: var(--ink-faint); + transition: transform 180ms ease; + flex-shrink: 0; +} +.widget-head .widget-hint { + color: var(--ink-faint); + font-size: 11px; +} + +.widget-body { + display: grid; + grid-template-rows: 1fr; + opacity: 1; + overflow: hidden; + transition: grid-template-rows 220ms ease, opacity 180ms ease; +} +.widget-body > .body-inner { + min-height: 0; + overflow: hidden; +} + +.widget.collapsed .widget-body { + grid-template-rows: 0fr; + opacity: 0; +} +.widget.collapsed .widget-head { + border-bottom-color: transparent; +} +.widget.collapsed .widget-head .chevron { + transform: rotate(-90deg); +} +/* Collapsed widgets must not hold onto flex-grow or min-height */ +.widget.collapsed { + flex: 0 0 auto !important; + min-height: 0 !important; +} + +/* ─── 7. Information widget: override now-playing layout ─── */ +.widget .now-playing { + display: grid !important; + grid-template-columns: 82px minmax(150px, 1.15fr) minmax(145px, 1fr) repeat(3, minmax(58px, 0.55fr)) !important; + gap: 12px 16px !important; + min-height: 0 !important; + padding: 9px 16px 10px !important; + border: none !important; + background: transparent !important; + box-shadow: none !important; + border-radius: 0 !important; + align-items: start !important; +} + +/* Override transport.css responsive rules that break widget layout */ +@media (max-width: 1100px) { + .widget .now-playing { + grid-template-columns: 72px minmax(120px, 1fr) minmax(120px, 1fr) repeat(3, minmax(50px, 0.5fr)) !important; + grid-template-areas: none !important; + gap: 9px 10px !important; + } + .widget .now-playing .key-card { + grid-column: auto !important; + grid-row: auto !important; + } + .widget .now-playing .loudness-card, + .widget .now-playing .beat-card { + grid-column: auto !important; + } +} + +@media (max-width: 800px) { + .widget .now-playing { + grid-template-columns: 72px 1fr 1fr !important; + } +} + +/* np-art stays as-is inside widget */ +.widget .np-art { + grid-area: unset !important; + width: 82px !important; + height: 82px !important; + align-self: center !important; +} + +@media (max-width: 1100px) { + .widget .np-art { + width: 72px !important; + height: 72px !important; + } +} + +/* np-info → track info column */ +.widget .np-info { + grid-area: unset !important; + gap: 8px !important; + padding: 0 14px 0 12px !important; +} + +.widget .np-title { + font-size: 16px !important; + line-height: 1.15 !important; + -webkit-line-clamp: 1 !important; +} + +.widget .chip { + min-height: 26px !important; + padding: 3px 8px !important; + font-size: 11px !important; +} + +.widget .np-play-row { + gap: 10px !important; + margin-top: 0 !important; + font-size: 11px !important; +} + +.widget .stem-energy, +.widget .analysis-card { + min-height: 82px !important; + padding: 0 10px !important; +} + +.widget .stem-energy h3, +.widget .analysis-card h3 { + margin-bottom: 7px !important; + font-size: 11px !important; +} + +.widget .energy-row { + grid-template-columns: 62px 1fr 32px !important; + gap: 7px !important; + margin: 2px 0 !important; + font-size: 10.5px !important; +} +.widget .energy-row[data-stem="original"] { display: none !important; } + +.widget .energy-row b { + height: 8px !important; +} + +.widget .analysis-card { + gap: 5px !important; +} + +.widget .analysis-card strong { + font-size: 17px !important; +} + +.widget .analysis-card small { + font-size: 10px !important; +} + +.widget .confidence-ring { + width: 46px !important; + height: 46px !important; + font-size: 11px !important; +} + +.widget[data-widget="mixer"] { +} + +/* ─── 8. Waveform widget: give wave-editor flex space ─── */ +.widget .studio-wave-grid { + display: grid !important; + grid-template-columns: var(--header-w, 320px) 1fr !important; + gap: 0 !important; + min-height: 0 !important; +} + +.widget .stems-panel { + border-radius: 0; +} + +.widget .wave-editor { + position: relative; + border-radius: 0; +} + +.wave-loading-overlay { + position: absolute; + inset: var(--wave-widget-ruler-h, 35px) 0 0 0; + z-index: 9; + overflow: hidden; + border-top: 1px solid rgba(148, 163, 184, 0.10); + background: + linear-gradient(90deg, rgba(7, 13, 19, 0.42), rgba(14, 25, 34, 0.50), rgba(7, 13, 19, 0.42)); + backdrop-filter: blur(2px); + pointer-events: none; + opacity: 1; + transition: opacity 180ms ease; +} + +.wave-loading-overlay.hidden { + opacity: 0; + visibility: hidden; +} + +.wave-loading-msg { + display: none; + position: absolute; + bottom: 16px; + left: 0; + right: 0; + text-align: center; + font-size: 0.75rem; + color: rgba(148, 163, 184, 0.6); + letter-spacing: 0.04em; + pointer-events: none; +} + +.wave-loading-overlay.stalled .wave-loading-msg { + display: block; +} + +.wave-loading-glow { + position: absolute; + inset: 0; + background: + radial-gradient(circle at 18% 32%, rgba(255, 64, 72, 0.10), transparent 18rem), + radial-gradient(circle at 52% 54%, rgba(216, 168, 74, 0.09), transparent 22rem), + radial-gradient(circle at 84% 42%, rgba(80, 160, 255, 0.08), transparent 18rem); + filter: blur(16px); + opacity: 0.72; + animation: waveLoadGlow 4.2s ease-in-out infinite alternate; +} + +.wave-loading-lines { + position: absolute; + inset: 10px 14px 14px 14px; + display: grid; + grid-template-rows: repeat(6, 1fr); + gap: 1px; + opacity: 0.54; +} + +.wave-loading-lines i { + display: block; + position: relative; + overflow: hidden; + border-radius: 2px; + opacity: 0.70; + background: + linear-gradient(90deg, transparent, color-mix(in srgb, currentColor 28%, transparent), transparent), + linear-gradient(0deg, transparent calc(50% - 1px), currentColor 50%, transparent calc(50% + 1px)); + animation: waveLoadLane 2.7s ease-in-out infinite; +} + +.wave-loading-lines i::before { + content: ""; + position: absolute; + inset: 9% 0; + background: + repeating-linear-gradient( + 90deg, + currentColor 0 3px, + transparent 3px 9px + ); + opacity: 0.82; + filter: blur(0.45px); + transform-origin: center; + mask-image: + radial-gradient(ellipse 11% 48% at 12% 50%, #000 0 44%, transparent 70%), + radial-gradient(ellipse 18% 70% at 32% 50%, #000 0 48%, transparent 74%), + radial-gradient(ellipse 15% 54% at 55% 50%, #000 0 45%, transparent 72%), + radial-gradient(ellipse 21% 78% at 78% 50%, #000 0 50%, transparent 76%), + radial-gradient(ellipse 10% 42% at 94% 50%, #000 0 42%, transparent 70%); + animation: waveLoadBars 1.95s ease-in-out infinite; +} + +.wave-loading-lines i::after { + content: ""; + position: absolute; + inset: 0; + transform: translateX(-55%); + background: + linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, currentColor 10%, transparent) 38%, + rgba(255, 255, 255, 0.34) 50%, + color-mix(in srgb, currentColor 14%, transparent) 62%, + transparent 100% + ); + filter: blur(9px); + animation: waveLoadTravel 1.85s cubic-bezier(0.42, 0, 0.2, 1) infinite; +} + +.wave-loading-lines i:nth-child(1) { color: #a9b5c6; animation-delay: -0.05s; } +.wave-loading-lines i:nth-child(2) { color: #ff4f62; animation-delay: -0.18s; } +.wave-loading-lines i:nth-child(3) { color: #ff8b2d; animation-delay: -0.31s; } +.wave-loading-lines i:nth-child(4) { color: #ffd04d; animation-delay: -0.44s; } +.wave-loading-lines i:nth-child(5) { color: #4fd06a; animation-delay: -0.57s; } +.wave-loading-lines i:nth-child(6) { color: #9a6dff; animation-delay: -0.70s; } + +.wave-loading-lines i:nth-child(1)::before { animation-delay: -0.05s; } +.wave-loading-lines i:nth-child(2)::before { animation-delay: -0.18s; } +.wave-loading-lines i:nth-child(3)::before { animation-delay: -0.31s; } +.wave-loading-lines i:nth-child(4)::before { animation-delay: -0.44s; } +.wave-loading-lines i:nth-child(5)::before { animation-delay: -0.57s; } +.wave-loading-lines i:nth-child(6)::before { animation-delay: -0.70s; } + +.wave-loading-lines i:nth-child(1)::after { animation-delay: -0.05s; } +.wave-loading-lines i:nth-child(2)::after { animation-delay: -0.18s; } +.wave-loading-lines i:nth-child(3)::after { animation-delay: -0.31s; } +.wave-loading-lines i:nth-child(4)::after { animation-delay: -0.44s; } +.wave-loading-lines i:nth-child(5)::after { animation-delay: -0.57s; } +.wave-loading-lines i:nth-child(6)::after { animation-delay: -0.70s; } + +@keyframes waveLoadLane { + 0%, 100% { opacity: 0.44; } + 50% { opacity: 0.70; } +} + +@keyframes waveLoadBars { + 0%, 100% { + transform: translateX(-1.2%) scaleY(0.56); + opacity: 0.54; + } + 45% { + transform: translateX(1.2%) scaleY(0.95); + opacity: 0.86; + } +} + +@keyframes waveLoadTravel { + from { transform: translateX(-58%); opacity: 0; } + 18% { opacity: 0.78; } + 72% { opacity: 0.78; } + to { transform: translateX(112%); opacity: 0; } +} + +@keyframes waveLoadGlow { + from { transform: translateX(-2%) scale(1); opacity: 0.74; } + to { transform: translateX(2%) scale(1.04); opacity: 1; } +} + +/* ─── 8b. Waveform widget: height + override waves.css .app:not(.is-import) rules ─── */ + +/* Waveform widget: natural height from WaveSurfer content. + CSS vars used by stem-list alignment rules below: + lane-h = WaveSurfer height(100px) + trackBorderColor(1px) + margin = wave-editor padding-top(6) + toolbar(30) + toolbar-margin-bottom(2) + + wave-scroll border-top(1) + ruler(34) = 73px */ +.widget[data-widget="waveform"] { + flex: 0 0 auto; + height: auto; + min-height: 0; + --wave-widget-lane-h: 101px; + --wave-widget-separator-h: 0px; + --wave-widget-editor-top-offset: 6px; + --wave-widget-toolbar-h: 32px; + --wave-widget-ruler-h: 35px; +} + +.widget[data-widget="waveform"] .widget-body, +.widget[data-widget="waveform"] .widget-body > .body-inner { + min-height: 0; + overflow: visible; +} + +/* stems-panel in old layout was a 48px overlay (grid-column:2 = same cell as wave-editor, + width:48px, pointer-events:none, margin-top:72px). Reset all of that. */ +.widget .stems-panel { + grid-column: unset !important; + grid-row: unset !important; + width: unset !important; + min-height: 326px !important; + pointer-events: auto !important; + margin: 0 !important; + z-index: auto !important; + overflow: auto !important; + padding: 16px 18px !important; + border: 1px solid var(--glass-line) !important; + background: rgba(21,31,39,0.6) !important; + align-self: stretch !important; + height: auto !important; +} +.widget .stems-panel .panel-title { display: block !important; } + +.widget .stem-list { + height: auto !important; + margin-top: 46px; + gap: 1px !important; +} + +/* ─── Waveform: slim icon-only column (matches design's 32px icon | waveform layout) ─── */ + +/* Override the 2-col wave grid to use a slim 36px icon column */ +.widget[data-widget="waveform"] .studio-wave-grid { + grid-template-columns: 36px 1fr !important; + min-height: var(--wave-widget-content-h) !important; +} + +/* Slim the stems-panel to an icon-only strip. + padding-top mirrors original waves.css offset: + wave-editor padding(6) + toolbar(30+2) + ruler(34) = 72px */ +.widget[data-widget="waveform"] .stems-panel { + width: 36px !important; + min-width: 36px !important; + min-height: 0 !important; + padding: 72px 0 0 0 !important; + border: none !important; + background: transparent !important; + box-shadow: none !important; + border-radius: 0 !important; + overflow: hidden !important; + align-self: stretch !important; + height: 100% !important; +} + +/* Hide panel title and interactive controls — M/S/monitor are in the mixer */ +.widget[data-widget="waveform"] .panel-title, +.widget[data-widget="waveform"] .drag-handle, +.widget[data-widget="waveform"] .stem-mute, +.widget[data-widget="waveform"] .stem-solo, +.widget[data-widget="waveform"] .stem-monitor, +.widget[data-widget="waveform"] .mini-meter { display: none !important; } + +/* stem-list fills the stems-panel content area (below the 72px offset). + flex: 1 1 0 on spans auto-distributes height — same as original non-import layout. */ +.widget[data-widget="waveform"] .stem-list { + margin-top: 0 !important; + gap: 0 !important; + display: flex !important; + flex-direction: column !important; + height: 100% !important; +} +.widget[data-widget="waveform"] .stem-list span { + flex: 1 1 0 !important; + height: auto !important; + min-height: 0 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + padding: 0 !important; + border: none !important; + background: transparent !important; +} + +.widget[data-widget="waveform"] .stem-list span.hidden { + display: none !important; +} + +/* em contains both the SVG icon and the text label — zero font-size hides the text */ +.widget[data-widget="waveform"] .stem-list em { + font-size: 0 !important; + line-height: 0 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; +} +.widget[data-widget="waveform"] .stem-list .stem-icon { + width: 16px !important; + height: 16px !important; +} + +/* ─── Waveform widget: expose WaveSurfer bar canvases ─── */ + +/* non-import mode hides multitrack canvases (opacity:0) and uses SVG overlays. + In the widget we want the real WaveSurfer bar rendering, so undo that. */ +.widget[data-widget="waveform"] #multitrack-container { + opacity: 1 !important; + pointer-events: auto !important; + position: static !important; + inset: unset !important; + min-height: 0 !important; +} + +/* Allow each track div to show its WaveSurfer trackBackground color */ +.widget[data-widget="waveform"] #multitrack-container > div { + background: transparent !important; + overflow: visible !important; +} + +/* Remove the 48px left gutter (stems-panel is in its own column, not overlapping) */ +.widget[data-widget="waveform"] .waves-column { + height: auto !important; + min-height: 0 !important; + overflow: visible !important; + padding-left: 0 !important; + --wave-gutter: 0px !important; +} +.widget[data-widget="waveform"] .waves-grid { + left: 0 !important; +} +.widget[data-widget="waveform"] .lanes-ruler { + margin-left: 0 !important; +} + +/* Let wave-scroll grow vertically with its content */ +.widget[data-widget="waveform"] .wave-scroll { + height: calc(var(--wave-widget-ruler-h) + var(--wave-widget-track-stack-h, 394px)) !important; + overflow-y: visible !important; + border: 0 !important; + background: transparent !important; +} +.widget[data-widget="waveform"] .wave-canvas { + height: 100% !important; +} + +.widget[data-widget="waveform"] .wave-toolbar { + min-height: 46px !important; + height: 46px !important; + margin: 0 !important; + padding: 12px 24px 8px 0 !important; + align-items: center !important; +} + +.widget[data-widget="waveform"] .zoom-tools { + gap: 8px !important; + font-size: 16px !important; +} + +.widget[data-widget="waveform"] .zoom-tools > svg { + width: 18px !important; + height: 18px !important; +} + +.widget[data-widget="waveform"] .zoom-slider { + width: 104px !important; + height: 16px !important; +} + +.widget[data-widget="waveform"] .zoom-slider::-webkit-slider-thumb { + width: 12px !important; + height: 12px !important; + margin-top: -4px !important; +} + +.widget[data-widget="waveform"] .zoom-slider::-moz-range-thumb { + width: 10px !important; + height: 10px !important; +} + +.widget[data-widget="waveform"] .zoom-tools button { + min-width: 28px !important; + min-height: 28px !important; + padding: 0 10px !important; +} + +.widget[data-widget="waveform"] .zoom-tools #zoom-out, +.widget[data-widget="waveform"] .zoom-tools #zoom-in { + width: 28px !important; + font-size: 19px !important; +} + +.widget[data-widget="waveform"] .lanes-ruler { + height: var(--wave-widget-ruler-h) !important; + display: block !important; +} + +.widget[data-widget="waveform"] .lanes-ruler-time { + height: 100% !important; +} + +.widget[data-widget="waveform"] .tick { + align-items: center !important; + border-left-color: transparent !important; + padding-left: 0 !important; +} + +.widget[data-widget="waveform"] .tick-label { + color: rgba(156, 163, 178, 0.68) !important; + font-size: 14px !important; + font-weight: 600 !important; +} + +.widget[data-widget="waveform"] .waves-grid .grid-line { + display: none !important; +} + +.widget[data-widget="waveform"] .waves-column [style*="height: 2px"] { + background-color: rgba(148, 163, 184, 0.08) !important; +} + +/* Hide SVG overlay — WaveSurfer canvas is the visual source in widget mode */ +.widget[data-widget="waveform"] .stem-waveform-layer { + display: none !important; +} + +/* ─── 9. Horizontal mixer ─── */ + +/* Hide waveform + mixer widgets when no track is loaded */ +.app.no-track .widget[data-widget="waveform"], +.app.no-track .widget[data-widget="mixer"] { display: none !important; } + +/* preview-mixer-body: old layout had 2-col grid (mixer | 94px master fader). + Flatten to single block so the mixer-column fills the full width. */ +.widget .preview-mixer-body { + display: block !important; + height: auto !important; + overflow: visible !important; + padding-top: 0 !important; +} + +/* Mixer column: 2-up grid of rows — matches design's .mixer-grid */ +.widget .mixer-column { + display: grid !important; + grid-template-columns: repeat(2, 1fr) !important; + column-gap: 22px !important; + row-gap: 10px !important; + padding: 14px 18px 16px !important; + width: 100% !important; + min-width: 0 !important; + background: transparent !important; + border: none !important; + box-shadow: none !important; +} + +@media (max-width: 1100px) { + .widget .mixer-column { grid-template-columns: 1fr !important; } +} + +/* Horizontal mixer row — exact design column widths: + icon(24) | name(60) | fader(1fr) | VU(90) | val(50) | M(28) | S(28) | dl(28) */ +.widget .lane-header.mx-row { + display: grid !important; + grid-template-columns: 24px 60px 1fr 90px 50px 28px 28px 28px !important; + grid-template-rows: auto !important; + gap: 10px !important; + align-items: center !important; + justify-items: stretch !important; + padding: 7px 4px !important; + border: none !important; + border-bottom: 1px solid transparent !important; + background: transparent !important; + flex-direction: unset !important; + min-height: unset !important; + height: auto !important; + width: 100% !important; + overflow: visible !important; +} +/* all borders transparent — row-gap provides the spacing */ + +/* Remove the old stripe */ +.widget .lane-header.mx-row .lane-stripe { display: none !important; } + +/* Explicitly show the download link — mixer.css sets display:none and + .app:not(.is-import) .lane-dl uses display:grid with absolute positioning. + Both must be defeated. */ +.widget .lane-header.mx-row .lane-dl { + display: flex !important; + position: static !important; + left: unset !important; + right: unset !important; + bottom: unset !important; + top: unset !important; + width: 26px !important; + height: 26px !important; + border-radius: 6px !important; +} + +/* Icon — 16px to match design */ +.mx-icon { display: flex; align-items: center; justify-content: center; } +.mx-icon svg { width: 16px; height: 16px; } + +/* Name — 12.5px matching design */ +.mx-name { + font-size: 12.5px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Horizontal fader */ +.lane-knob.mx-fader { + position: relative !important; + height: 20px !important; + display: flex !important; + align-items: center !important; + cursor: ew-resize !important; + width: 100% !important; + background: transparent !important; + border: none !important; + border-radius: 0 !important; + justify-self: stretch !important; + min-height: 0 !important; + grid-column: auto !important; + grid-row: auto !important; +} +.lane-knob.mx-fader::before { display: none !important; } +.lane-knob.mx-fader .lane-knob-indicator { display: none !important; } + +/* Fader track: 4px height, matching design */ +.mx-fader-track { + width: 100%; + height: 4px; + background: var(--line, rgba(35,42,55,1)); + border-radius: 2px; + position: relative; +} +.mx-fader-fill { + position: absolute; + left: 0; + top: 0; + height: 100%; + width: calc(var(--lane-pos, 0.5) * 100%); + border-radius: 2px; + opacity: 1; + pointer-events: none; +} +/* Fader knob: 14px, stem-colored, translate(-50%,-50%) centers it on the fill end */ +.mx-fader-knob { + position: absolute; + top: 50%; + left: calc(var(--lane-pos, 0.5) * 100%); + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid rgba(0,0,0,0.4); + box-shadow: 0 0 0 1px rgba(255,255,255,0.18); + transform: translate(-50%, -50%); + pointer-events: none; + transition: left 80ms ease; +} +.lane-knob.mx-fader.dragging .mx-fader-knob { transition: none; } +.lane-knob.mx-fader.dragging { cursor: ew-resize !important; } +.lane-knob.mx-fader.disabled { opacity: 0.4; pointer-events: none; } + +/* VU meter: 8px tall, design gradient (#4a8c44 → #b8a83b 65% → #d65a4a 100%) */ +.lane-vu.mx-meter { + display: block !important; + height: 8px !important; + width: 100% !important; + border-radius: 2px !important; + background: var(--line-soft, #1a2030) !important; + overflow: hidden !important; + position: relative !important; + top: unset !important; + right: unset !important; + left: unset !important; + bottom: unset !important; + transform: none !important; + flex-direction: unset !important; + gap: unset !important; + padding: 0 !important; +} +.mx-meter .lane-vu-bar.mx-meter-fill { + position: absolute !important; + left: 0 !important; top: 0 !important; + height: 100% !important; + width: var(--vu-level, 0%) !important; + background: linear-gradient(90deg, #4a8c44 0%, #b8a83b 65%, #d65a4a 100%) !important; + border-radius: 2px !important; + transition: width 60ms linear !important; +} +.mx-meter .lane-vu-bar:not(.mx-meter-fill) { display: none !important; } +.lane-vu.mx-meter::before, +.lane-vu.mx-meter::after { content: none !important; } + +/* Value */ +.mx-val { + color: var(--ink-faint); + font-size: 11px; + font-family: var(--font-mono); + text-align: right; + font-variant-numeric: tabular-nums; +} + +/* M / S / download buttons — 26px matching design */ +.mx-btn { + width: 26px !important; + height: 26px !important; + border: 1px solid var(--line, #232a37) !important; + border-radius: 6px !important; + background: transparent !important; + color: var(--ink-dim) !important; + font-family: var(--font-mono) !important; + font-size: 10px !important; + font-weight: 600 !important; + cursor: pointer !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + transition: background 120ms, border-color 120ms, color 120ms !important; + padding: 0 !important; + text-decoration: none !important; +} +.mx-btn:hover { background: rgba(31,43,52,0.88) !important; border-color: rgba(157,170,182,0.38) !important; } + +/* Muted: M turns red */ +.lane-header.muted .lane-icon-toggle.mx-btn { + color: #e85d4f !important; + border-color: rgba(232,93,79,0.5) !important; + opacity: 1 !important; +} +.lane-icon-toggle.mx-btn.active { color: var(--ink-dim) !important; } + +/* Solo active: gold */ +.mx-btn.solo.active { + color: var(--gold, #d8a84a) !important; + border-color: rgba(216,168,74,0.5) !important; + background: rgba(216,168,74,0.12) !important; +} + +/* Download: stem-colored — matches STEM_COLORS in constants.js */ +.lane-header[data-stem="vocals"] .lane-dl.mx-btn { color: #e85f6f !important; border-color: rgba(232,95,111,0.45) !important; } +.lane-header[data-stem="drums"] .lane-dl.mx-btn { color: #e89048 !important; border-color: rgba(232,144,72,0.45) !important; } +.lane-header[data-stem="bass"] .lane-dl.mx-btn { color: #e8b848 !important; border-color: rgba(232,184,72,0.45) !important; } +.lane-header[data-stem="guitar"] .lane-dl.mx-btn { color: #88d878 !important; border-color: rgba(136,216,120,0.45) !important; } +.lane-header[data-stem="piano"] .lane-dl.mx-btn { color: #b88fe8 !important; border-color: rgba(184,143,232,0.45) !important; } +.lane-header[data-stem="other"] .lane-dl.mx-btn { color: #88a8c8 !important; border-color: rgba(136,168,200,0.45) !important; } +.lane-header[data-stem="original"] .lane-dl.mx-btn { color: #a8b0bd !important; border-color: rgba(168,176,189,0.45) !important; } + +.lane-dl.mx-btn svg { width: 13px !important; height: 13px !important; } +.lane-dl.mx-btn.disabled { opacity: 0.3 !important; pointer-events: none !important; } + +/* Unavailable rows are stems not produced for this extraction. Keep them + visible for orientation, but remove interaction and live meter emphasis. */ +.widget .lane-header.unavailable { + opacity: 0.34; + filter: grayscale(0.85); +} + +.widget .lane-header.unavailable .mx-name, +.widget .lane-header.unavailable .mx-val { + color: var(--ink-faint) !important; +} + +.widget .lane-header.unavailable .mx-fader-fill, +.widget .lane-header.unavailable .mx-fader-knob, +.widget .lane-header.unavailable .mx-meter .mx-meter-fill { + background: rgba(148, 163, 184, 0.36) !important; +} + +.widget .lane-header.unavailable .mx-btn, +.widget .lane-header.unavailable .lane-knob { + pointer-events: none !important; +} + +.widget .energy-row.unavailable { + opacity: 0.32; + filter: grayscale(0.85); +} + +.widget .energy-row.unavailable b { + background: rgba(148, 163, 184, 0.14) !important; +} + +.widget .energy-row.unavailable em { + color: var(--ink-faint); +} + +/* Muted row */ +.widget .lane-header.muted { opacity: 0.55; } +.widget .lane-header.muted .mx-name { text-decoration: line-through; text-decoration-thickness: 1px; } + +/* ─── 10. Transport footer stays at bottom ─── */ +.app:not(.is-import) .transport-footer, +.transport-footer { + flex: 0 0 96px; + position: sticky; + bottom: 0; + z-index: 20; + height: 96px; + min-height: 96px; + padding: 16px 18px !important; + border-radius: 12px; + border: 1px solid var(--glass-line); + background: + radial-gradient(circle at 58% 0%, rgba(216, 168, 74, 0.055), transparent 24rem), + linear-gradient(180deg, rgba(28, 39, 48, 0.82), rgba(15, 23, 31, 0.86)); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.045), var(--shadow-panel); + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: center; + justify-items: stretch; + gap: 18px; + overflow: hidden; +} + +.app:not(.is-import) .transport-footer .np-transport, +.transport-footer .np-transport { + grid-column: 2; + justify-self: center; + flex: 0 0 auto; + margin-left: 0; + margin-right: 0; + align-items: center; + line-height: 1; + min-width: max-content; +} + +.app:not(.is-import) .transport-footer .np-transport > span, +.transport-footer .np-transport > span { + display: inline-flex; + align-items: center; + height: 58px; +} + +.app:not(.is-import) .transport-footer .np-transport-row, +.transport-footer .np-transport-row { + align-items: center; + height: 58px; +} + +.app:not(.is-import) .transport-footer .btn-transport, +.transport-footer .btn-transport { + flex: 0 0 58px; + width: 58px; + height: 58px; + min-width: 58px; + min-height: 58px; + align-self: center; +} + +.app:not(.is-import) .transport-footer .footer-actions, +.transport-footer .footer-actions { + grid-column: 3; + justify-self: end; + display: flex; + align-items: center; + gap: 10px; +} + +@media (max-width: 1100px) { + .app:not(.is-import) .transport-footer, + .transport-footer { + flex-basis: 88px; + height: 88px; + min-height: 88px; + padding: 14px 18px !important; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + } + + .app:not(.is-import) .transport-footer .footer-actions, + .transport-footer .footer-actions { + display: none; + } + + .app:not(.is-import) .transport-footer .np-transport, + .transport-footer .np-transport { + margin-left: 0; + } +} + +/* ─── 11. Responsive: compact layout below 960px ─── */ +@media (max-width: 960px) { + .app { + grid-template-columns: var(--cat-w, 240px) 1fr !important; + } + .topbar .stem-choice span { display: none; } + .widget .lane-header.mx-row { + /* drop val col at narrow width: icon(24) name(55) fader(1fr) VU(70) M S dl */ + grid-template-columns: 24px 55px 1fr 70px 26px 26px 26px !important; + } + .mx-val { display: none; } +} + +@media (max-width: 720px) { + .app { + grid-template-columns: 52px 1fr !important; + --cat-w: 52px; + } +} diff --git a/static/img/friends/dlima-guitars-ig.jpg b/static/img/friends/dlima-guitars-ig.jpg new file mode 100644 index 0000000..920c7b8 Binary files /dev/null and b/static/img/friends/dlima-guitars-ig.jpg differ diff --git a/static/img/friends/joao-gaspar.jpg b/static/img/friends/joao-gaspar.jpg new file mode 100644 index 0000000..4d5884e Binary files /dev/null and b/static/img/friends/joao-gaspar.jpg differ diff --git a/static/img/friends/kris-luthier.jpg b/static/img/friends/kris-luthier.jpg new file mode 100644 index 0000000..0f677fe Binary files /dev/null and b/static/img/friends/kris-luthier.jpg differ diff --git a/static/img/friends/lisbon-guitar-works.webp b/static/img/friends/lisbon-guitar-works.webp new file mode 100644 index 0000000..d8279ff Binary files /dev/null and b/static/img/friends/lisbon-guitar-works.webp differ diff --git a/static/imgs/stemdeck-logo-horizontal.svg b/static/imgs/stemdeck-logo-horizontal.svg new file mode 100644 index 0000000..b999fac --- /dev/null +++ b/static/imgs/stemdeck-logo-horizontal.svg @@ -0,0 +1,25 @@ + + StemDeck Horizontal Logo + StemDeck logo with golden waveform symbol and wordmark. + + + + + + + + + + + + + + + + + + + Stem + Deck + POWERED STEM SEPARATION + diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..836d74f --- /dev/null +++ b/static/index.html @@ -0,0 +1,738 @@ + + + + + + StemDeck — split any track into stems + + + + + + + + + +
+ + +
+ +
+ + StemDeck + +
+ + + +
+ + +
+ + +
+ + + + + + +
+ + +
+ + +
+ Extract +
+ + + + + + + +
+
+ + + +
+ + +
+ + +
+ + + +
+ + +
+ + + + + +
+
+ + + + + +
+ + +
+ +
+ KEY +
+ + +
+ + +
+ +
+ BPM +
+ +
+
+ +
+ LUFS +
+ +
+ +
+ +
+ DURATION +
+ +
+
+ +
+ SCALE +
+ +
+
+ +
+ DYNAMIC RANGE +
+ +
+ +
+ +
+ TEMPO STABILITY +
+ +
+ +
+
+ + +
+
+ VOCAL PRESENCE + +
+
+ DRUM INTENSITY + +
+
+ BASS DEPTH + +
+
+ GUITAR PRESENCE + +
+
+ PIANO PRESENCE + +
+
+ OTHER + +
+
+ + + +
+ + +
+
+ Sections + + +
+
+
+ + +
+
+ Mixer + Drag fader · M/S +
+
+
+
+ +
+
+
+
+ + +
+ + +
+ + + + + +
+ + + + + Original + + M + S + + + + + + + + Vocals + + M + S + + + + + + + + Drums + + M + S + + + + + + + + Bass + + M + S + + + + + + + + Guitar + + M + S + + + + + + + + Piano + + M + S + + + + + + + + Others + + M + S + + + +
+ + +
+
+ + +
+ +
+
+
+ +
+ +
+ +
+
+
+ +
+ + +
+ + +
+ + + + + + + +
+ +
+
+ +
+ + + + + + + + + + + diff --git a/static/js/audio.js b/static/js/audio.js new file mode 100644 index 0000000..4005608 --- /dev/null +++ b/static/js/audio.js @@ -0,0 +1,149 @@ +import { TRACK_NAMES } from "./constants.js"; +import { + multitrack, mixerEl, audioContext, trackAnalysers, + vuRafId, trackIndex, + setAudioContext, setVuRafId, +} from "./state.js"; + +export function attachAnalysers() { + if (!multitrack) return; + if (trackAnalysers.length) return; + const wsArr = multitrack.wavesurfers || multitrack._wavesurfers; + const audios = multitrack.audios; + if (!wsArr?.length || !audios?.length) return; + + const ctx = multitrack.audioContext; + if (!ctx) return; + if (ctx.state === "suspended") ctx.resume().catch(() => {}); + setAudioContext(ctx); + + for (const stemName of TRACK_NAMES) { + const idx = trackIndex[stemName]; + if (idx === undefined) continue; + const audioEl = audios[idx]; + if (!audioEl) continue; + + const isHtmlMedia = audioEl instanceof HTMLMediaElement; + const isWebAudio = typeof audioEl.getGainNode === "function"; + if (!isHtmlMedia && !isWebAudio) continue; + if (isHtmlMedia && !audioEl.src) continue; + + let analyser; + try { + analyser = ctx.createAnalyser(); + analyser.fftSize = 256; + analyser.smoothingTimeConstant = 0.5; + if (isWebAudio) { + // wavesurfer's audio wrapper exposes its internal gain node; + // analyser taps the post-gain signal. + audioEl.getGainNode().connect(analyser); + } else { + // Bare HTMLAudioElement: route through Web Audio so we can tap + // the post-gain signal. applyMix() may have already created the + // MediaElementSource + GainNode chain; re-use it if so (the spec + // only allows createMediaElementSource once per element). + if (!audioEl._stMediaSource) { + audioEl._stMediaSource = ctx.createMediaElementSource(audioEl); + audioEl._stGainNode = ctx.createGain(); + audioEl._stMediaSource.connect(audioEl._stGainNode); + audioEl._stGainNode.connect(ctx.destination); + audioEl.volume = 1; + } else if (!audioEl._stGainNode) { + audioEl._stGainNode = ctx.createGain(); + audioEl._stMediaSource.connect(audioEl._stGainNode); + audioEl._stGainNode.connect(ctx.destination); + audioEl.volume = 1; + } + audioEl._stGainNode.connect(analyser); + } + } catch (err) { + console.warn("VU analyser hookup failed for stem", stemName, err); + continue; + } + // Time-domain buffer must be sized to fftSize (not frequencyBinCount, + // which is fftSize/2). With a too-small buffer, getByteTimeDomainData + // only writes the first N samples and trailing entries keep stale + // values, biasing RMS toward whatever was last left there. + const data = new Uint8Array(analyser.fftSize); + const vuEl = mixerEl.querySelector(`.lane-vu[data-stem="${stemName}"]`); + const miniMeterEl = document.querySelector(`.stem-list .${stemName} .mini-meter`); + trackAnalysers.push({ analyser, data, vuEl, miniMeterEl, peak: 0 }); + } + + const tick = () => { + for (const t of trackAnalysers) { + t.analyser.getByteTimeDomainData(t.data); + let sum = 0; + for (let i = 0; i < t.data.length; i++) { + const v = (t.data[i] - 128) / 128; + sum += v * v; + } + const rms = Math.sqrt(sum / t.data.length); + // Linear RMS with 2.5x gain. Compensates for the 0.5 master + // attenuation the analyser sees post-gain via createMediaElementSource, + // so typical -20 dBFS music lands in the 25-50% bar range, drum + // hits push toward 80-100%, silence falls to 0. Linear (not dB) + // gives the meter punch on transients that dB scaling smooths out. + const level = Math.min(1, rms * 2.5); + // Peak hold: bar follows level on the way up, falls slowly on + // the way down. That's what makes a VU look alive -- it lifts + // sharply on each drum hit / vocal phrase and drifts down in + // between rather than tracking instantaneous RMS jitter. + const prevPeak = t.peak || 0; + const nextPeak = level > prevPeak ? level : Math.max(0, prevPeak - 0.012); + t.peak = nextPeak; + // Separate peak-hold marker that holds the recent max for + // ~600 ms before falling, so a thin tick visually marks + // "loudest moment in the last second" above the bar. + const prevHold = t.peakHold || 0; + let nextHold = prevHold; + let holdFrames = t.holdFrames || 0; + if (level > prevHold) { + nextHold = level; + holdFrames = 36; + } else if (holdFrames > 0) { + holdFrames -= 1; + } else { + nextHold = Math.max(0, prevHold - 0.02); + } + t.peakHold = nextHold; + t.holdFrames = holdFrames; + const lvlPct = Math.round(level * 100); + const peakPct = Math.round(nextPeak * 100); + const holdPct = Math.round(nextHold * 100); + if (t.vuEl) { + if (lvlPct !== t.lastLevelPct) { + t.vuEl.style.setProperty("--vu-level", `${lvlPct}%`); + } + if (holdPct !== t.lastHoldPct) { + t.vuEl.style.setProperty("--vu-peak", `${holdPct}%`); + } + } + if (t.miniMeterEl) { + if (peakPct !== t.lastPeakPct) { + t.miniMeterEl.style.setProperty("--vu-scale", nextPeak.toFixed(3)); + } + if (holdPct !== t.lastHoldPct) { + t.miniMeterEl.style.setProperty("--vu-peak-pct", String(holdPct)); + t.miniMeterEl.style.setProperty( + "--vu-peak-opacity", + nextHold > 0.04 ? "1" : "0", + ); + } + } + t.lastLevelPct = lvlPct; + t.lastPeakPct = peakPct; + t.lastHoldPct = holdPct; + } + + setVuRafId(requestAnimationFrame(tick)); + }; + setVuRafId(requestAnimationFrame(tick)); +} + +export function stopVuLoop() { + if (vuRafId) { + cancelAnimationFrame(vuRafId); + setVuRafId(null); + } +} \ No newline at end of file diff --git a/static/js/audioEngine.js b/static/js/audioEngine.js new file mode 100644 index 0000000..deebd91 --- /dev/null +++ b/static/js/audioEngine.js @@ -0,0 +1,218 @@ +// Web Audio decode-and-mix playback engine. +// +// Safari/WKWebView goes choppy when playing N streaming