diff --git a/.gitattributes b/.gitattributes index 24f4cb7..475fb24 100644 --- a/.gitattributes +++ b/.gitattributes @@ -26,3 +26,6 @@ *.pth binary *.ckpt binary *.safetensors binary + +# Preserve pinned vendor bytes, including upstream license formatting. +ui/vendor/** -text -whitespace diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40d0aea..438d6ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: - name: Check Python correctness run: python -m ruff check . - name: Compile application and tests without importing models - run: python -m compileall -q remwm.py remwmgui.py utils.py lama_inpaint tests + run: python -m compileall -q remwm.py remwmgui.py desktop_main.py desktop_runtime.py desktop_models.py model_assets.py utils.py lama_inpaint scripts tests - name: Run CPU tests run: python -m pytest - name: Check Bash syntax @@ -99,7 +99,30 @@ jobs: sudo apt-get install -y libegl1 libgl1 libxkbcommon-x11-0 libxcb-cursor0 libxcb-icccm4 libxcb-keysyms1 libxcb-shape0 libxcb-xinerama0 libxcb-randr0 libnss3 libasound2t64 - name: Check installed desktop backend (Linux) if: runner.os == 'Linux' - run: xvfb-run -a python scripts/check_webview.py + run: xvfb-run -a python scripts/check_desktop.py - name: Check installed desktop backend (Windows/macOS) if: runner.os != 'Linux' - run: python scripts/check_webview.py + run: python scripts/check_desktop.py + + windows-portable: + name: Windows portable executable + runs-on: windows-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: Build independent embedded runtime and executable + run: python scripts/build_windows.py --output "${{ runner.temp }}/portable" --cache "${{ runner.temp }}/downloads" --backend cpu --skip-archive + - name: Exercise the executable and real offline desktop page + shell: pwsh + run: | + $version = (Get-Content VERSION -Raw).Trim() + $bundle = Join-Path '${{ runner.temp }}/portable' "WatermarkRemover-AI-$version-windows-x64-cpu" + $process = Start-Process -FilePath (Join-Path $bundle 'WatermarkRemover-AI.exe') -ArgumentList '--check' -WindowStyle Hidden -PassThru + $handle = $process.Handle + if (-not $process.WaitForExit(120000)) { $process.Kill(); throw 'Portable check timed out' } + if ($process.ExitCode -ne 0) { throw "Portable check failed: $($process.ExitCode)" } diff --git a/.github/workflows/windows-portable.yml b/.github/workflows/windows-portable.yml new file mode 100644 index 0000000..c7341d5 --- /dev/null +++ b/.github/workflows/windows-portable.yml @@ -0,0 +1,55 @@ +name: Windows portable packages + +on: + workflow_dispatch: + push: + tags: ["v*"] + +permissions: + contents: read + +jobs: + build: + name: Portable (${{ matrix.backend }}) + runs-on: windows-latest + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + backend: [cpu, cu126] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: Validate release tag + shell: pwsh + run: | + $version = (Get-Content VERSION -Raw).Trim() + if ($env:GITHUB_REF_TYPE -eq 'tag' -and $env:GITHUB_REF_NAME -ne "v$version") { throw 'Tag must match VERSION' } + - name: Build folder and archive + run: python scripts/build_windows.py --output dist --cache "${{ runner.temp }}/downloads" --backend ${{ matrix.backend }} + - name: Verify executable and local GUI + shell: pwsh + run: | + $version = (Get-Content VERSION -Raw).Trim() + $name = "WatermarkRemover-AI-$version-windows-x64-${{ matrix.backend }}" + $destination = Join-Path $env:RUNNER_TEMP 'extracted portable' + python -m zipfile -e "dist/$name.zip" "$destination" + if ($LASTEXITCODE -ne 0) { throw 'Archive extraction failed' } + $bundle = Join-Path $destination $name + $process = Start-Process -FilePath (Join-Path $bundle 'WatermarkRemover-AI.exe') -ArgumentList '--check' -WindowStyle Hidden -PassThru + $handle = $process.Handle + if (-not $process.WaitForExit(120000)) { $process.Kill(); throw 'Portable check timed out' } + if ($process.ExitCode -ne 0) { throw "Portable check failed: $($process.ExitCode)" } + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-${{ matrix.backend }} + path: | + dist/*.zip + dist/*.sha256 + compression-level: 0 + if-no-files-found: error + retention-days: 14 diff --git a/.gitignore b/.gitignore index 749d8c1..cd51a6e 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,6 @@ huggingface/ .venv-test/ .pytest_cache/ .ruff_cache/ + +node_modules/ +build-downloads/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 24a0b65..6aa04b0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,7 +22,7 @@ CI runs these checks on Windows, Linux, and macOS. It also parses the PowerShell These lightweight checks use CPU fixtures. They do not install the full application, launch its GUI, download model weights, or prove CUDA/MPS compatibility. The test dependencies are separate from the application dependencies. A successful `pip check` in the test environment only validates that environment. -A separate application CI job installs `requirements.txt` on all three platforms with the normal resolver, runs `pip check` in that environment, verifies application imports and CLI help, and tests the adapter using a generated TorchScript fixture. It also checks image and short-video routing without downloading detection models. This job does not prove a GUI interaction or real-model/GPU inference. See [LaMA runtime validation](docs/lama-runtime.md#validation). +A separate application CI job installs `requirements.txt` on all three platforms with the normal resolver, runs `pip check` in that environment, verifies application imports and CLI help, and tests the adapter using a generated TorchScript fixture. It also checks image and short-video routing without downloading detection models. This job also opens the real local desktop page and exercises its JavaScript/Python bridge. It does not prove real-model/GPU inference. See [LaMA runtime validation](docs/lama-runtime.md#validation). ## Evidence required by change type @@ -47,7 +47,7 @@ GPU/model integration tests require a separate trusted environment and must not Automated review provides suggestions; maintainers verify findings against the code and test evidence. A bot's approval does not replace application testing. Resolve applicable review findings or explain why they do not apply. Maintainers decide when to merge. -Once the new CI jobs have passed reliably, maintainers can require their status checks through repository rules. Adding this workflow alone does not enable branch protection. Full-manifest installation checks run in the application job; installer-script execution and GUI interaction still require separate validation on affected platforms. +Once the new CI jobs have passed reliably, maintainers can require their status checks through repository rules. Adding this workflow alone does not enable branch protection. Full-manifest installation checks run in the application job; installer-script execution and GUI interactions beyond the automated desktop check still require separate validation on affected platforms. ## Dependency updates diff --git a/README.md b/README.md index e843ad5..e1650f0 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,12 @@ https://github.com/user-attachments/assets/505be2a8-8eda-4def-90b6-5a4ceefee456 --- +## Windows portable release + +The versioned Windows package includes an `.exe` launcher, Python and the desktop +runtime. See [portable setup, model downloads and troubleshooting](docs/windows-portable.md). +The first launch prepares verified models with visible progress and a retry button. + ## Installation ### Windows diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index ff9c9d9..e7a5fe4 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -26,3 +26,22 @@ asset digest. The runtime uses the pinned SHA-256, not MD5 or an environment variable supplied checksum. This is an integrity pin, not an upstream signature. For model provenance and terms, see [LaMA](https://github.com/advimman/lama) and the artifact publisher. Code licensing does not replace model/data terms. + +## Offline desktop assets + +- Alpine.js 3.13.3: MIT, https://github.com/alpinejs/alpine/tree/v3.13.3 ; license in `ui/vendor/ALPINE-LICENSE.md`. +- Tailwind CSS 3.4.17: MIT, https://github.com/tailwindlabs/tailwindcss/tree/v3.4.17 ; generated CSS and license in `ui/vendor`. Rebuild with `npm ci --ignore-scripts` and `npm run build:ui`. +- Space Mono, Rubik Glitch, Inter, Cinzel, Dancing Script, Oswald, Playfair Display and Mali: SIL Open Font License 1.1. Font files were obtained from Google Fonts; the corresponding OFL files are included in `ui/vendor`. See https://github.com/google/fonts . +- `ui/vendor/manifest.json` records the bundled asset SHA-256 digests. + +The Windows portable package includes CPython under the PSF license and PySide6/Qt +under their distributed licenses, including LGPL components. Their license files +remain in the Python distribution and installed package metadata. Libraries remain +as separate replaceable files. See https://doc.qt.io/qtforpython-6/licenses.html +for licensing and corresponding source references. The package also preserves +PyTorch and other dependencies' license files in their installed distributions. + +Florence-2 assets are pinned to revision `4271c66b88cdbc05735372ec13b2360108de5317` +of `florence-community/Florence-2-large`. `models/florence.json` records sizes and +SHA-256 digests checked against the repository's Git/LFS metadata. Model files are +downloaded separately, with byte verification before use. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..4a46fb5 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.68.0 diff --git a/desktop_main.py b/desktop_main.py new file mode 100644 index 0000000..8f8724a --- /dev/null +++ b/desktop_main.py @@ -0,0 +1,8 @@ +"""Windowed entry point for source installs and the portable executable.""" +from desktop_runtime import configure_runtime + +configure_runtime() + +if __name__ == "__main__": + from remwmgui import main + main() diff --git a/desktop_models.py b/desktop_models.py new file mode 100644 index 0000000..6fcd573 --- /dev/null +++ b/desktop_models.py @@ -0,0 +1,83 @@ +"""Keep model verification/downloads off the desktop bridge and UI threads.""" + +import json +import subprocess +import threading + +from desktop_runtime import APP_ROOT, python_executable, worker_options + + +class ModelPreparation: + """One preparation worker per window, with pollable progress and explicit retry.""" + + def __init__(self): + """Start idle; callers decide when to check or download model assets.""" + self._lock = threading.Lock() + self._process = None + self._running = False + self._closed = False + self._state = dict(status="unchecked", message="Checking model files...") + + def status(self): + """Return a copy so bridge serialization never races with worker updates.""" + with self._lock: + return dict(self._state, busy=self._running) + + def start(self, *, check=False): + """Start checking or downloading; completed/failed jobs can be retried.""" + with self._lock: + if self._closed: + return {"error": "Window is closing"} + if self._running: + return {"error": "Model preparation is already running"} + self._running = True + self._state = dict(status="checking", message="Checking model files...") + threading.Thread(target=self._run, args=(check,), daemon=True).start() + return {"status": "started"} + + def _run(self, check): + """Consume structured progress while keeping errors visible and retryable.""" + try: + cmd = [python_executable(), str(APP_ROOT / "model_assets.py")] + if check: + cmd.append("--check") + with self._lock: + if self._closed: + return + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + **worker_options()) + self._process = process + for line in process.stdout: + try: + event = json.loads(line) + except ValueError: + continue + if isinstance(event, dict) and "status" in event: + with self._lock: + self._state = event + process.stdout.close() + code = process.wait() + with self._lock: + if code or self._state.get("status") != "ready": + if self._state.get("status") not in {"missing", "error"}: + self._state = dict(status="error", message=f"Model preparation stopped (exit {code}). Please retry.") + except Exception as exc: + with self._lock: + self._state = dict(status="error", message=str(exc)) + finally: + with self._lock: + self._process = None + self._running = False + + def close(self): + """Terminate only this window's worker; downloads remain safe to retry.""" + with self._lock: + self._closed = True + process = self._process + if process and process.poll() is None: + process.terminate() + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=3) diff --git a/desktop_runtime.py b/desktop_runtime.py new file mode 100644 index 0000000..7a396c1 --- /dev/null +++ b/desktop_runtime.py @@ -0,0 +1,53 @@ +"""Paths and subprocess settings shared by source and portable desktop launches.""" + +import logging +import os +from pathlib import Path +import subprocess +import sys + +APP_ROOT = Path(__file__).resolve().parent + + +def data_dir(): + """Keep writable user state outside application files, or beside a portable build.""" + override = os.environ.get("WMR_DATA_DIR") + if override: + return Path(override).expanduser().resolve() + if (APP_ROOT / "portable.flag").exists(): + return APP_ROOT / "data" + if sys.platform == "win32": + return Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) / "WatermarkRemover-AI" + return Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "WatermarkRemover-AI" + + +def configure_runtime(): + """Prepare logs and portable caches before importing the graphical backend.""" + root = data_dir() + root.mkdir(parents=True, exist_ok=True) + if (APP_ROOT / "portable.flag").exists(): + os.environ.setdefault("TORCH_HOME", str(root / "models/torch")) + os.environ.setdefault("HF_HOME", str(root / "models/huggingface")) + for name in ("stdout", "stderr"): + if getattr(sys, name) is None: + setattr(sys, name, (root / "desktop.log").open("a", encoding="utf-8", buffering=1)) + logging.basicConfig(filename=root / "desktop.log", level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", encoding="utf-8") + + +def python_executable(): + """Workers need python.exe with real pipes even when the GUI uses pythonw.exe.""" + executable = Path(sys.executable) + if executable.name.lower() == "pythonw.exe": + return str(executable.with_name("python.exe")) + return str(executable) + + +def worker_options(*, offline=False): + """Launch bounded local workers without console windows or shell interpretation.""" + env = os.environ.copy() + env.update(PYTHONUNBUFFERED="1", PYTHONIOENCODING="utf-8") + if offline: + env.update(HF_HUB_OFFLINE="1", TRANSFORMERS_OFFLINE="1") + return dict(cwd=str(APP_ROOT), env=env, text=True, encoding="utf-8", errors="replace", + creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0) diff --git a/docs/windows-portable.md b/docs/windows-portable.md new file mode 100644 index 0000000..7dc52e8 --- /dev/null +++ b/docs/windows-portable.md @@ -0,0 +1,102 @@ +# Windows portable application + +The portable package contains a versioned `WatermarkRemover-AI.exe`, CPython +3.13.15, the application dependencies, Qt WebEngine, and the complete offline +interface. No system Python, Git, administrator access or WebView2 installation +is needed. Extract the whole archive to a writable directory before opening the +EXE; do not launch it from inside the archive. + +## First launch + +1. Choose the CPU package for broad hardware compatibility, or the CUDA 12.6 + package for a compatible NVIDIA GPU/driver. See [device guidance](dependency-migration.md). +2. Extract the package and open `WatermarkRemover-AI.exe`. +3. Open **Models**, then choose **Download / Retry**. The application checks its + pinned LaMA and Florence-2 files, downloads missing/damaged files and shows + progress. Model data is about 1.8 GB; allow additional disk space for the + application and temporary transfers. +4. After **ready** appears, choose input and output paths and process a file. + Detection Preview uses the same prepared models. + +A connection failure is shown in the Models panel. Fix the connection or free disk +space and use **Download / Retry** again. Completed verified files are reused. +Partial Florence-2 transfers resume when the server supports byte ranges; LaMA +restarts an interrupted transfer. No downloaded file is accepted before its pinned +size and SHA-256 match. Neither application updates nor arbitrary scripts are +fetched through this model downloader. + +The UI, translations, themes and fonts work without internet. Processing uses +prepared local models. First model preparation requires access to GitHub and +Hugging Face, or a preseeded cache. See [offline cache instructions](lama-runtime.md). +The Florence-2 manifest and pinned snapshot revision are in `models/florence.json`. +Source installers remember the selected Florence mirror for later desktop retries. +An explicit `HF_ENDPOINT` takes precedence over the saved `model-endpoint.json` in +the application data directory; only HTTPS base URLs without credentials, query +strings or fragments are accepted. A mirror never changes the pinned checksums. +LaMA still uses its separate GitHub source. To persist a different Florence endpoint: + +```sh +python -m model_assets --florence-only --endpoint https://huggingface.co +``` + +FFmpeg is still optional and must be available on PATH to preserve video audio. +The UI reports its availability. The package does not silently install a system +codec or driver. CUDA packages do not certify every NVIDIA card or older driver; +MPS is not a Windows feature and is outside the current LaMA device contract. + +## Portable state and upgrades + +`data/` beside the executable stores settings, logs and default model caches. +Explicit `TORCH_HOME`, `HF_HOME` or Hugging Face cache variables still take +precedence. Keep the `data` directory when moving the portable application. +For an upgrade, extract into a new directory, close the old application, then copy +`data/` into the new directory. Keep the previous version until the new one works. +Do not mix dependency folders from different versions or CPU/CUDA packages. + +Source installations use the user's application data directory for new settings +and logs, and read an existing project `ui.yml` when no new settings file exists. +Existing theme and language choices are retained. + +## Diagnostics + +- `data/desktop.log` records windowed-launch errors. Inspect it before sharing; + logs may contain local paths and processing details. +- `build-info.json` identifies version, backend, Python artifact and source commit. +- `installed-packages.txt` records the exact package versions included in the build. +- Launch `WatermarkRemover-AI.exe --check` to exercise the real local page and + JavaScript/Python bridge without downloading production models. This is a UI + installation check, not proof of GPU or model inference. +- For CLI usage, run `python/python.exe remwm.py INPUT OUTPUT` from the extracted + directory. Keep cache variables consistent with your GUI cache; in a shell set + `TORCH_HOME` to `data/models/torch` and `HF_HOME` to `data/models/huggingface`. + +## Building and release checks + +On Windows, from a clean checkout with a working build Python/pip: + +```powershell +python scripts/build_windows.py --output dist --cache build-downloads --backend cpu +python scripts/build_windows.py --output dist --cache build-downloads --backend cu126 +``` + +The builder verifies the embedded Python archive, uses the explicit Torch index, +checks dependency resolution and imports, compiles the native launcher and emits +versioned ZIP/SHA-256 files. Runtime DLLs and dependencies' license files remain +separate in the package. Python entry-point wrappers containing build-machine +paths are excluded; use `python.exe -m pip` for intentional package maintenance. +Never run installation commands into a release candidate after recording its +validation evidence. Build diagnostics are not a promise of byte-for-byte +reproducible archives: preserve the emitted package inventory and checksum. + +For application-only rebuilds, `--refresh-app` requires matching Python, backend, +Torch, torchvision, and pip versions and dependency manifests from the previous build. It replaces +application directories so removed files cannot remain. Runtime changes or old +build metadata require a fresh output directory. User data and the embedded runtime +are preserved during an application-only refresh. + +CI builds the CPU executable and exercises the actual offline desktop page. +The Windows portable workflow builds both CPU/CUDA artifacts on a version tag or +manual dispatch, without automatically publishing a release. Before publishing, +validate the extracted archive from another directory, first model download, +connection failure/retry, preview, processing, cancellation and error reporting. +Real model/GPU runs stay in a trusted environment outside pull-request CI. diff --git a/lama_inpaint/model_store.py b/lama_inpaint/model_store.py index f691b64..84239c9 100644 --- a/lama_inpaint/model_store.py +++ b/lama_inpaint/model_store.py @@ -56,7 +56,7 @@ def _is_valid(path): return False -def ensure_model(cache_dir=None, *, download=True): +def ensure_model(cache_dir=None, *, download=True, progress=None): """Return verified weights, downloading atomically when allowed. Downloads use a unique temporary file and a per-artifact cross-process lock. @@ -65,6 +65,8 @@ def ensure_model(cache_dir=None, *, download=True): """ cache = Path(cache_dir) if cache_dir is not None else default_cache_dir() target = cache / MODEL_NAME + if progress: + progress(dict(status="checking", model="LaMA", current=0, total=MODEL_SIZE)) if not download: if not _is_valid(target): raise ModelError( @@ -76,13 +78,13 @@ def ensure_model(cache_dir=None, *, download=True): with FileLock(str(target) + ".lock", timeout=600): if _is_valid(target): return target - _download(target) + _download(target, progress=progress) except Timeout as exc: raise ModelError("Timed out waiting for another LaMA model download.") from exc return target -def _download(target): +def _download(target, progress=None): """Publish only a complete verified transfer; retain the old file on failure.""" temporary = None try: @@ -100,6 +102,8 @@ def _download(target): "LaMA download exceeded its size or time limit." ) output.write(chunk) + if progress: + progress(dict(status="downloading", model="LaMA", current=size, total=MODEL_SIZE)) output.flush() os.fsync(output.fileno()) with temporary.open("rb") as stream: diff --git a/model_assets.py b/model_assets.py new file mode 100644 index 0000000..c552f0c --- /dev/null +++ b/model_assets.py @@ -0,0 +1,195 @@ +"""Pinned Florence assets with verified cache reuse and resumable downloads.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import time +import tempfile +from urllib.parse import urlsplit +from urllib.request import Request, urlopen + +from filelock import FileLock + +from desktop_runtime import data_dir + +MANIFEST = json.loads((Path(__file__).parent / "models/florence.json").read_text()) +FLORENCE_REPO = MANIFEST["repo"] +FLORENCE_REVISION = MANIFEST["revision"] + + +def validate_endpoint(value): + """Accept HTTPS base URLs without credentials, query strings or fragments.""" + parsed = urlsplit(value) + if (parsed.scheme != "https" or not parsed.hostname or parsed.username is not None + or parsed.password is not None or parsed.query or parsed.fragment + or any(character.isspace() for character in value) or "\\" in value): + raise ValueError("The model endpoint must be an HTTPS base URL without credentials, query or fragment.") + # Validate explicit port syntax/range even if no download is needed yet. + parsed.port + return value.rstrip("/") + + +def save_endpoint(value): + """Persist the installer's selection before downloading, including failed attempts.""" + endpoint = validate_endpoint(value) + root = data_dir() + root.mkdir(parents=True, exist_ok=True) + temporary = None + try: + with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=root, + prefix="model-endpoint-", delete=False) as output: + temporary = Path(output.name) + json.dump({"endpoint": endpoint}, output) + output.flush() + os.fsync(output.fileno()) + os.replace(temporary, root / "model-endpoint.json") + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + return endpoint + + +def model_endpoint(): + """Use an explicit environment override, then the saved selection, then origin.""" + endpoint = os.environ.get("HF_ENDPOINT") + if not endpoint: + settings = data_dir() / "model-endpoint.json" + endpoint = (json.loads(settings.read_text(encoding="utf-8"))["endpoint"] + if settings.exists() else "https://huggingface.co") + return validate_endpoint(endpoint) + + +def florence_snapshot(): + """Reuse the standard Hugging Face snapshot directory for the pinned revision.""" + hf_home = Path(os.environ.get("HF_HOME", str(Path(os.environ.get("XDG_CACHE_HOME", "~/.cache")).expanduser() / "huggingface"))).expanduser() + hub = Path(os.environ.get("HF_HUB_CACHE", os.environ.get("HUGGINGFACE_HUB_CACHE", str(hf_home / "hub")))).expanduser() + return hub / ("models--" + FLORENCE_REPO.replace("/", "--")) / "snapshots" / FLORENCE_REVISION + + +def valid_file(path, artifact): + """Validate bytes before publishing or loading an artifact, including cached files.""" + try: + if path.stat().st_size != artifact["size"]: + return False + digest = hashlib.sha256() + with path.open("rb") as source: + while chunk := source.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() == artifact["sha256"] + except FileNotFoundError: + return False + + +def download_file(url, target, artifact, report): + """Resume only a matching byte range; publish verified bytes atomically. + + Network interruptions retain partial bytes for retry. A checksum failure + removes the partial file so retry cannot repeatedly reuse corrupt bytes. + Callers hold the per-model file lock throughout validation and download. + """ + partial = target.with_name(target.name + ".part") + offset = partial.stat().st_size if partial.exists() else 0 + if offset >= artifact["size"]: + if valid_file(partial, artifact): + os.replace(partial, target) + return + partial.unlink() + offset = 0 + headers = {"Range": f"bytes={offset}-", "Accept-Encoding": "identity"} if offset else {"Accept-Encoding": "identity"} + deadline = time.monotonic() + 3600 + with urlopen(Request(url, headers=headers), timeout=30) as response: + if response.status == 206: + content_range = response.headers.get("Content-Range", "") + expected = f"bytes {offset}-{artifact['size'] - 1}/{artifact['size']}" + if content_range != expected: + raise RuntimeError("Server returned an unexpected download range; retry the download.") + elif response.status == 200: + offset = 0 # Server ignored Range: restart instead of appending duplicate bytes. + else: + raise RuntimeError(f"Unexpected download response: {response.status}") + report(offset, artifact["size"]) + with partial.open("ab" if offset else "wb") as output: + while chunk := response.read(1024 * 1024): + offset += len(chunk) + if offset > artifact["size"] or time.monotonic() > deadline: + raise RuntimeError("Model download exceeded its expected size or time limit.") + output.write(chunk) + report(offset, artifact["size"]) + output.flush() + os.fsync(output.fileno()) + if partial.stat().st_size < artifact["size"]: + raise RuntimeError("Model transfer ended early. Please retry to resume the download.") + if not valid_file(partial, artifact): + partial.unlink(missing_ok=True) + raise RuntimeError("Model download failed its SHA-256 check. Please retry.") + os.replace(partial, target) + + +def ensure_florence(*, download=True, progress=None): + """Return a complete verified pinned snapshot; offline checks never open the network.""" + download = download and os.environ.get("HF_HUB_OFFLINE", "").upper() not in {"1", "TRUE", "YES"} + root = florence_snapshot() + if download: + root.mkdir(parents=True, exist_ok=True) + def report(status, filename="", current=0, total=0): + """Forward plain progress data to the caller without coupling to a GUI.""" + if progress: + progress(dict(status=status, model="Florence-2", file=filename, current=current, total=total)) + def prepare(): + """Verify every required file and fetch only missing or damaged artifacts.""" + for artifact in MANIFEST["files"]: + name = artifact["name"] + target = root / name + report("checking", name) + if valid_file(target, artifact): + continue + if not download: + raise RuntimeError(f"Florence-2 needs a download or repair: {name}") + report("downloading", name, 0, artifact["size"]) + url = f"{model_endpoint()}/{FLORENCE_REPO}/resolve/{FLORENCE_REVISION}/{name}" + download_file(url, target, artifact, + lambda current, total: report("downloading", name, current, total)) + if download: + report("waiting") + with FileLock(str(root / ".prepare.lock"), timeout=600): + prepare() + else: + prepare() + return root + + +def prepare_models(*, download=True, progress=None): + """Verify and prepare both models before allowing desktop processing.""" + from lama_inpaint.model_store import ensure_model + ensure_model(download=download, progress=progress) + ensure_florence(download=download, progress=progress) + + +def main(): + """Emit progress as JSON lines for the isolated desktop preparation worker.""" + parser = argparse.ArgumentParser() + parser.add_argument("--check", action="store_true") + parser.add_argument("--florence-only", action="store_true") + parser.add_argument("--endpoint", help="Persist an HTTPS Florence endpoint for future desktop retries") + args = parser.parse_args() + def emit(event): + """Flush each event so the GUI can report progress while the worker runs.""" + print(json.dumps(event), flush=True) + try: + if args.endpoint is not None: + save_endpoint(args.endpoint) + os.environ["HF_ENDPOINT"] = args.endpoint + prepare = ensure_florence if args.florence_only else prepare_models + prepare(download=not args.check, progress=emit) + emit(dict(status="ready", message="Models are verified and ready.")) + except Exception as exc: + emit(dict(status="missing" if args.check else "error", + message="Model files are missing or need repair. Choose Download / Retry." if args.check else str(exc), + detail=str(exc))) + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/models/florence.json b/models/florence.json new file mode 100644 index 0000000..c53784d --- /dev/null +++ b/models/florence.json @@ -0,0 +1,61 @@ +{ + "repo": "florence-community/Florence-2-large", + "revision": "4271c66b88cdbc05735372ec13b2360108de5317", + "files": [ + { + "name": "added_tokens.json", + "size": 22430, + "sha256": "1d75deda84dfa81fb6c09301f3fed00f9695059568bbb1403a6bf299cd84fc37" + }, + { + "name": "config.json", + "size": 2396, + "sha256": "8412483f687f2f71587328a38c6fa70a68d9488f28e90607be3c182641f60f2c" + }, + { + "name": "generation_config.json", + "size": 292, + "sha256": "0251459c49cc358ac033b5d4b8569e61ac22bde5d76be404b97a44cfb33fb12e" + }, + { + "name": "merges.txt", + "size": 456318, + "sha256": "1ce1664773c50f3e0cc8842619a93edc4624525b728b188a9e0be33b7726adc5" + }, + { + "name": "model.safetensors", + "size": 1553541016, + "sha256": "7715423d6549bf1e71188bdd84f4ac960cc0597886af24a5ef7b66f128660685" + }, + { + "name": "preprocessor_config.json", + "size": 603, + "sha256": "1396ec5a0a7adfe1c04fb777b09e8ba753be6dbb5868212ab3c3ef39d91fe031" + }, + { + "name": "processor_config.json", + "size": 2264, + "sha256": "cd0e3bf41a39b1276503fbd273bc03b9afc70d7a15ea681a92a1b4b77f858ee6" + }, + { + "name": "special_tokens_map.json", + "size": 146627, + "sha256": "72ff172dc769bc1551b1b4211628ce3271643bc60379e4da45d85a9be9332c39" + }, + { + "name": "tokenizer.json", + "size": 3748144, + "sha256": "3ad7001f773409abe6bba33eac92662611a73d72f459bda2f00d2a221dd31ce4" + }, + { + "name": "tokenizer_config.json", + "size": 197922, + "sha256": "cb5f80bd9afa767bb1bb798ee5f0a79eae45239b8e72506166b4218175a16723" + }, + { + "name": "vocab.json", + "size": 798293, + "sha256": "ed19656ea1707df69134c4af35c8ceda2cc9860bf2c3495026153a133670ab5e" + } + ] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..49e0a6f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1021 @@ +{ + "name": "WatermarkRemover-AI-desktop", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "tailwindcss": "3.4.17" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.3.0.tgz", + "integrity": "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.2.tgz", + "integrity": "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1abea85 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "private": true, + "scripts": { + "build:ui": "tailwindcss -i ui/tailwind-input.css -o ui/vendor/tailwind.css --minify" + }, + "devDependencies": { + "tailwindcss": "3.4.17" + } +} diff --git a/packaging/Launcher.cs b/packaging/Launcher.cs new file mode 100644 index 0000000..9c5098e --- /dev/null +++ b/packaging/Launcher.cs @@ -0,0 +1,48 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Windows.Forms; + +[assembly: AssemblyTitle("WatermarkRemover-AI")] +[assembly: AssemblyProduct("WatermarkRemover-AI")] +[assembly: AssemblyCompany("D-Ogi")] +[assembly: AssemblyVersion("__VERSION__.0")] +[assembly: AssemblyFileVersion("__VERSION__.0")] + +internal static class Launcher +{ + [STAThread] + private static int Main(string[] args) + { + bool check = args.Length == 1 && args[0] == "--check"; + string root = AppDomain.CurrentDomain.BaseDirectory; + string interpreter = Path.Combine(root, "python", check ? "python.exe" : "pythonw.exe"); + string script = Path.Combine(root, check ? "scripts/check_desktop.py" : "desktop_main.py"); + try + { + if (args.Length > 0 && !check) throw new ArgumentException("Supported argument: --check"); + if (!File.Exists(interpreter) || !File.Exists(script)) + throw new FileNotFoundException("Extract the complete portable archive before launching. Keep the EXE beside its python and ui folders."); + ProcessStartInfo start = new ProcessStartInfo(interpreter, "\"" + script + "\""); + start.WorkingDirectory = root; + start.UseShellExecute = false; + start.CreateNoWindow = true; + start.EnvironmentVariables["PYTHONIOENCODING"] = "utf-8"; + start.EnvironmentVariables.Remove("PYTHONHOME"); + start.EnvironmentVariables.Remove("PYTHONPATH"); + using (Process child = Process.Start(start)) + { + child.WaitForExit(); + if (child.ExitCode != 0 && !check) + MessageBox.Show("The application could not start. Check data\\desktop.log in the extracted folder. Make sure the folder is writable.", "WatermarkRemover-AI", MessageBoxButtons.OK, MessageBoxIcon.Error); + return child.ExitCode; + } + } + catch (Exception error) + { + if (!check) MessageBox.Show(error.Message, "WatermarkRemover-AI", MessageBoxButtons.OK, MessageBoxIcon.Error); + return 1; + } + } +} diff --git a/packaging/python-runtime.json b/packaging/python-runtime.json new file mode 100644 index 0000000..5d7e532 --- /dev/null +++ b/packaging/python-runtime.json @@ -0,0 +1,5 @@ +{ + "version": "3.13.15", + "url": "https://www.python.org/ftp/python/3.13.15/python-3.13.15-embed-amd64.zip", + "sha256": "d1f04d990aee1253d8569e8e5104e30fa9f5fa830899f14843448872d936a2cf" +} diff --git a/remwm.py b/remwm.py index 513a0ff..0858d47 100644 --- a/remwm.py +++ b/remwm.py @@ -1,3 +1,4 @@ +from model_assets import ensure_florence import sys import click from pathlib import Path @@ -550,10 +551,11 @@ def main(input_path: str, output_path: str, preview: bool, overwrite: bool, tran # Apply float32 for CPU (compatibility) model_dtype = torch.float32 if device == "cpu" else None + florence_path = str(ensure_florence()) florence_model = Florence2ForConditionalGeneration.from_pretrained( - "florence-community/Florence-2-large", + florence_path, torch_dtype=model_dtype).to(device).eval() - florence_processor = AutoProcessor.from_pretrained("florence-community/Florence-2-large") + florence_processor = AutoProcessor.from_pretrained(florence_path) # Get sample image from input if input_path.is_dir(): @@ -628,10 +630,11 @@ def main(input_path: str, output_path: str, preview: bool, overwrite: bool, tran # Apply float32 for CPU (compatibility) model_dtype = torch.float32 if device == "cpu" else None + florence_path = str(ensure_florence()) florence_model = Florence2ForConditionalGeneration.from_pretrained( - "florence-community/Florence-2-large", + florence_path, torch_dtype=model_dtype).to(device).eval() - florence_processor = AutoProcessor.from_pretrained("florence-community/Florence-2-large") + florence_processor = AutoProcessor.from_pretrained(florence_path) logger.info("Florence-2 Model loaded") if not transparent: diff --git a/remwmgui.py b/remwmgui.py index 1a15725..4461c62 100644 --- a/remwmgui.py +++ b/remwmgui.py @@ -5,19 +5,6 @@ PyWebview frontend with brainrot HTML UI import logging -# Suppress noisy pywebview WebView2 COM warnings (thread safety noise, doesn't affect functionality) -class PyWebviewFilter(logging.Filter): - def filter(self, record): - msg = record.getMessage() - # Filter out WebView2 COM interface errors that spam the console - if 'Error while processing window.native' in msg: - return False - if 'CoreWebView2 members can only be accessed' in msg: - return False - return True - -logging.getLogger('pywebview').addFilter(PyWebviewFilter()) - import webview import threading import subprocess @@ -27,6 +14,8 @@ import json import yaml import base64 from pathlib import Path +from desktop_models import ModelPreparation +from desktop_runtime import APP_ROOT, configure_runtime, data_dir, python_executable, worker_options # Only psutil for system info (lightweight) try: @@ -36,7 +25,7 @@ except ImportError: PSUTIL_AVAILABLE = False -CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui.yml") +CONFIG_FILE = str(data_dir() / "ui.yml") class Api: @@ -45,14 +34,32 @@ class Api: def __init__(self): self.window = None self.process = None + self._preview_process = None + self._preview_running = False + self._closed = False self.is_running = False - print(f"[DEBUG] CONFIG_FILE path: {CONFIG_FILE}") - print(f"[DEBUG] File exists: {os.path.exists(CONFIG_FILE)}") - if os.path.exists(CONFIG_FILE): - with open(CONFIG_FILE, 'r') as f: - print(f"[DEBUG] Raw file contents:\n{f.read()}") + self._process_lock = threading.Lock() + self._stop_requested = threading.Event() self.config = self._load_config() - print(f"[DEBUG] Config loaded at startup: {self.config}") + self._models = ModelPreparation() + self._models.start(check=True) + + def get_model_status(self): + """Report verified readiness, byte progress, or an actionable download error.""" + return self._models.status() + + def download_models(self): + """Download missing assets or retry a failed transfer using verified caches.""" + if self.is_running or self._preview_running: + return {"error": "Wait until processing or preview finishes"} + return self._models.start() + + def _close(self): + """Reap only child processes created by this application window.""" + with self._process_lock: + self._closed = True + self.stop_processing() + self._models.close() def set_window(self, window): """Set the webview window reference""" @@ -60,10 +67,14 @@ class Api: def _load_config(self): """Load saved configuration from YAML file""" - if os.path.exists(CONFIG_FILE): + source = Path(CONFIG_FILE) + if not source.exists(): + source = APP_ROOT / "ui.yml" + if source.exists(): try: - with open(CONFIG_FILE, 'r', encoding='utf-8') as f: - return yaml.safe_load(f) or {} + with source.open('r', encoding='utf-8') as f: + config = yaml.safe_load(f) + return config if isinstance(config, dict) else {} except Exception: pass return {} @@ -71,6 +82,7 @@ class Api: def _save_config(self, config): """Save configuration to YAML file""" try: + Path(CONFIG_FILE).parent.mkdir(parents=True, exist_ok=True) with open(CONFIG_FILE, 'w', encoding='utf-8') as f: yaml.dump(config, f, default_flow_style=False) except Exception as e: @@ -82,7 +94,6 @@ class Api: def get_config(self): """Return saved configuration to frontend""" - print(f"[DEBUG] get_config called, returning: {self.config}") return self.config def save_config(self, config): @@ -190,8 +201,8 @@ class Api: # Check CUDA via subprocess (avoid importing torch in GUI) try: result = subprocess.run( - [sys.executable, '-c', 'import torch; print("CUDA:" + str(torch.cuda.is_available()) + ":" + (torch.cuda.get_device_name(0) if torch.cuda.is_available() else ""))'], - capture_output=True, text=True, timeout=10, creationflags=creationflags + [python_executable(), '-c', 'import torch; print("CUDA:" + str(torch.cuda.is_available()) + ":" + (torch.cuda.get_device_name(0) if torch.cuda.is_available() else ""))'], + capture_output=True, text=True, timeout=30, creationflags=creationflags ) if result.returncode == 0 and 'CUDA:' in result.stdout: parts = result.stdout.strip().split(':') @@ -203,7 +214,7 @@ class Api: # Check FFmpeg try: - subprocess.run(['ffmpeg', '-version'], capture_output=True, check=True, creationflags=creationflags) + subprocess.run(['ffmpeg', '-version'], capture_output=True, check=True, timeout=5, creationflags=creationflags) info['ffmpeg'] = True except (subprocess.SubprocessError, FileNotFoundError): info['ffmpeg'] = False @@ -231,6 +242,8 @@ class Api: if self.is_running: return {'error': 'Already running'} + if self._models.status().get('status') != 'ready': + return {'error': 'Prepare the AI models before processing. Open Models and choose Download / Retry.'} input_path = settings.get('input', '') output_path = settings.get('output', '') @@ -283,7 +296,7 @@ class Api: }) # Build command - cmd = [sys.executable, 'remwm.py', input_path, output_path] + cmd = [python_executable(), str(APP_ROOT / 'remwm.py'), input_path, output_path] if settings.get('overwrite'): cmd.append('--overwrite') @@ -311,7 +324,13 @@ class Api: cmd.append(f'--fade-out={float(fade_out)}') # Start processing in background thread - self.is_running = True + with self._process_lock: + if self._closed: + return {"error": "Window is closing"} + if self.is_running or self._preview_running: + return {"error": "Processing or preview is already running"} + self.is_running = True + self._stop_requested.clear() threading.Thread(target=self._run_process, args=(cmd,), daemon=True).start() return {'status': 'started'} @@ -324,30 +343,30 @@ class Api: cli_display = cli_display.replace(' --', ' \\\n --') self._call_js(f'addLog("$ {json.dumps(cli_display)[1:-1]}", "text-neon-cyan")') - env = os.environ.copy() - env['PYTHONUNBUFFERED'] = '1' working_dir = os.path.dirname(os.path.abspath(__file__)) script_path = os.path.join(working_dir, 'remwm.py') # Verify script exists if not os.path.exists(script_path): - self._call_js(f'addLog("ERROR: remwm.py not found at {json.dumps(script_path)}", "text-error")') - self._call_js('processingComplete()') + self._call_js(f'addLog({json.dumps("ERROR: remwm.py not found at " + script_path)}, "text-error")') + self._call_js('processingComplete({success: false})') return - self.process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, - env=env, - cwd=working_dir - ) + with self._process_lock: + if self._stop_requested.is_set(): + self._call_js('processingComplete({success: false, cancelled: true})') + return + self.process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=1, + **worker_options(offline=True) + ) for line in iter(self.process.stdout.readline, ''): - if not self.is_running: + if self._stop_requested.is_set(): break line = line.strip() @@ -377,8 +396,8 @@ class Api: self._call_js(f'addLog({escaped}, "{color}")') - self.process.wait() - self._call_js('processingComplete()') + code = self.process.wait() + self._call_js(f'processingComplete({json.dumps({"success": code == 0 and not self._stop_requested.is_set(), "cancelled": self._stop_requested.is_set(), "exit_code": code})})') except Exception as e: import traceback @@ -387,11 +406,14 @@ class Api: # Log full traceback for debugging tb = json.dumps(traceback.format_exc()) self._call_js(f'addLog({tb}, "text-gray-500")') - self._call_js('processingComplete()') + self._call_js('processingComplete({success: false})') finally: - self.is_running = False - self.process = None + with self._process_lock: + if self.process and self.process.stdout: + self.process.stdout.close() + self.is_running = False + self.process = None def _call_js(self, js_code): """Safely call JavaScript in the frontend""" @@ -403,69 +425,73 @@ class Api: def stop_processing(self): """Stop the current processing""" - self.is_running = False + self._stop_requested.set() + with self._process_lock: + processes = (self.process, self._preview_process) - if self.process: + for process in processes: + if process is None: + continue try: - self.process.terminate() + process.terminate() try: - self.process.wait(timeout=0.5) + process.wait(timeout=0.5) except subprocess.TimeoutExpired: - self.process.kill() + process.kill() + process.wait(timeout=3) except Exception: pass return {'status': 'stopped'} def preview_detection(self, settings): - """ - Preview watermark detection via CLI subprocess. - Returns image with bounding boxes drawn as base64. - """ + """Run one owned preview worker and return detections or a visible error.""" + if self._models.status().get('status') != 'ready': + return {'error': 'Prepare the AI models before processing. Open Models and choose Download / Retry.'} input_path = settings.get('input', '') - detection_prompt = settings.get('detection_prompt', 'watermark') - max_bbox = settings.get('max_bbox', 15) - if not input_path: return {'error': 'No input path specified'} - + with self._process_lock: + if self._closed: + return {'error': 'Window is closing'} + if self.is_running or self._preview_running: + return {'error': 'Processing or preview is already running'} + self._preview_running = True + process = None try: - # Call CLI with --preview flag - cmd = [ - sys.executable, 'remwm.py', - input_path, '--preview', - '--max-bbox-percent', str(int(max_bbox)), - '--detection-prompt', detection_prompt - ] - - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=120, - cwd=os.path.dirname(os.path.abspath(__file__)) - ) - - if result.returncode != 0: - return {'error': result.stderr or 'Preview failed'} - - # Parse JSON output from CLI - output = result.stdout.strip() - # Find JSON in output (may have log lines before it) - for line in output.split('\n'): + cmd = [python_executable(), str(APP_ROOT / 'remwm.py'), input_path, '--preview', + '--max-bbox-percent', str(int(settings.get('max_bbox', 15))), + '--detection-prompt', settings.get('detection_prompt', 'watermark')] + with self._process_lock: + if self._closed: + return {'error': 'Window is closing'} + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + **worker_options(offline=True)) + self._preview_process = process + output, errors = process.communicate(timeout=120) + if process.returncode != 0: + return {'error': errors or 'Preview failed or was cancelled'} + for line in output.strip().splitlines(): if line.startswith('{'): return json.loads(line) - return {'error': 'No preview data returned'} - except subprocess.TimeoutExpired: return {'error': 'Preview timed out'} - except Exception as e: - return {'error': str(e)} + except Exception as error: + return {'error': str(error)} + finally: + if process is not None: + if process.poll() is None: + process.kill() + process.communicate() + with self._process_lock: + self._preview_process = None + self._preview_running = False -def main(): +def main(startup=None, *, hidden=False): """Main entry point""" + configure_runtime() api = Api() script_dir = os.path.dirname(os.path.abspath(__file__)) @@ -478,11 +504,14 @@ def main(): width=950, height=860, min_size=(800, 600), - background_color='#050505' + background_color='#050505', + hidden=hidden ) api.set_window(window) - webview.start() + window.events.closed += api._close + webview.start(startup, args=(window, api) if startup else None, + http_server=True, gui="qt" if sys.platform == "win32" else None) if __name__ == '__main__': diff --git a/requirements.txt b/requirements.txt index 76d4351..82f9a2a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,8 @@ -r requirements-core.txt # Desktop UI. Linux needs a concrete backend; Qt wheels avoid a GTK source build. -pywebview>=6.2.1; sys_platform != "linux" +pywebview>=6.2.1; sys_platform == "darwin" +pywebview[pyside6]>=6.2.1; sys_platform == "win32" pywebview[qt]>=6.2.1; sys_platform == "linux" psutil pyyaml diff --git a/run.bat b/run.bat index 3c091a1..880e9b5 100644 --- a/run.bat +++ b/run.bat @@ -1,3 +1,3 @@ @echo off cd /d "%~dp0" -python\pythonw.exe remwmgui.py +start "" "%~dp0python\pythonw.exe" "%~dp0desktop_main.py" diff --git a/scripts/build_windows.py b/scripts/build_windows.py new file mode 100644 index 0000000..08788a3 --- /dev/null +++ b/scripts/build_windows.py @@ -0,0 +1,149 @@ +"""Build a versioned portable Windows folder and ZIP with an executable launcher.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +from urllib.request import urlopen +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +APP_FILES = ["VERSION", "LICENSE", "THIRD_PARTY_NOTICES.md", "README.md", "remwm.py", + "remwmgui.py", "utils.py", "desktop_main.py", "desktop_runtime.py", + "desktop_models.py", "model_assets.py", "requirements.txt", "requirements-core.txt"] +APP_DIRS = ["lama_inpaint", "ui", "models", "licenses", "docs"] +PIP_VERSION = "26.2.1" +TORCH_VERSION = "2.14.0" +VISION_VERSION = "0.29.0" + + +def run(*args): + """Stop packaging at the first failed command rather than publishing a broken build.""" + subprocess.run([str(arg) for arg in args], check=True, cwd=ROOT) + + +def sha256(path): + """Hash large archives without holding them in memory.""" + digest = hashlib.sha256() + with path.open("rb") as source: + while block := source.read(1024*1024): + digest.update(block) + return digest.hexdigest() + + +def runtime_inputs(backend, runtime): + """Identify every declared input used to install the embedded runtime.""" + return dict(backend=backend, python=runtime, pip=PIP_VERSION, + torch=TORCH_VERSION, torchvision=VISION_VERSION, + requirements={name: hashlib.sha256((ROOT/name).read_text(encoding="utf-8").encode("utf-8")).hexdigest() + for name in ("requirements.txt", "requirements-core.txt")}) + + +def validate_refresh(target, expected): + """Reject stale or legacy runtime metadata before changing any packaged file.""" + marker = target / "build-info.json" + existing = json.loads(marker.read_text(encoding="utf-8")) if marker.exists() else {} + if (existing.get("runtime_inputs") != expected + or existing.get("backend") != expected["backend"] + or existing.get("python") != expected["python"]): + raise SystemExit("Runtime inputs changed or are missing; create a fresh build instead of --refresh-app") + + +def copy_app(target): + """Copy an explicit public file list, excluding caches, private settings and models.""" + target = target.resolve() + directories = [target/name for name in [*APP_DIRS, "scripts"]] + # Check every absolute deletion target before replacing any application directory. + for destination in directories: + resolved = destination.resolve() + if destination.is_symlink() or resolved == target or not resolved.is_relative_to(target): + raise ValueError(f"Application directory escapes the package: {destination.name}") + for destination in directories: + if destination.exists(): + shutil.rmtree(destination) + for name in APP_FILES: + shutil.copy2(ROOT/name, target/name) + for name in APP_DIRS: + shutil.copytree(ROOT/name, target/name, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) + (target/"scripts").mkdir() + shutil.copy2(ROOT/"scripts/check_desktop.py", target/"scripts/check_desktop.py") + (target/"portable.flag").write_text("Store settings, logs and model caches in data/.\n") + + +def main(): + """Install a private runtime, compile a small native launcher and archive tested files.""" + parser=argparse.ArgumentParser() + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--cache", type=Path, required=True) + parser.add_argument("--backend", choices=["cpu", "cu126"], default="cpu") + parser.add_argument("--refresh-app", action="store_true") + parser.add_argument("--skip-archive", action="store_true") + args=parser.parse_args() + if sys.platform != "win32": + raise SystemExit("Windows packaging must run on Windows") + version=(ROOT/"VERSION").read_text().strip() + if json.loads((ROOT/"ui/config.json").read_text(encoding="utf-8"))["version"] != version: + raise SystemExit("VERSION and ui/config.json must agree") + name=f"WatermarkRemover-AI-{version}-windows-x64-{args.backend}" + target=args.output.resolve()/name + runtime=json.loads((ROOT/"packaging/python-runtime.json").read_text()) + args.cache.mkdir(parents=True,exist_ok=True) + if args.refresh_app: + validate_refresh(target, runtime_inputs(args.backend, runtime)) + else: + target.mkdir(parents=True,exist_ok=False) + archive=args.cache/Path(runtime["url"]).name + if not archive.exists(): + with urlopen(runtime["url"],timeout=30) as source, archive.open("wb") as output: + shutil.copyfileobj(source,output) + if sha256(archive) != runtime["sha256"]: + raise SystemExit("Embedded Python checksum mismatch; remove the incomplete cached archive and retry") + python_dir=target/"python";python_dir.mkdir() + with zipfile.ZipFile(archive) as source: + for member in source.namelist(): + if not (python_dir/member).resolve().is_relative_to(python_dir): + raise SystemExit("Unsafe archive member") + source.extractall(python_dir) + major_minor="".join(runtime["version"].split(".")[:2]) + (python_dir/f"python{major_minor}._pth").write_text(f"python{major_minor}.zip\n.\n..\nLib/site-packages\nimport site\n") + (python_dir/"Lib/site-packages").mkdir(parents=True) + executable=python_dir/"python.exe" + run(sys.executable,"-m","pip","--python",executable,"install",f"pip=={PIP_VERSION}") + run(executable,"-m","pip","--isolated","install",f"torch=={TORCH_VERSION}",f"torchvision=={VISION_VERSION}", + "--index-url",f"https://download.pytorch.org/whl/{args.backend}") + run(executable,"-m","pip","--isolated","install","-r",ROOT/"requirements.txt") + copy_app(target) + executable=target/"python/python.exe" + run(executable,"-m","pip","check") + run(executable,"-c","import remwm, webview; from PySide6 import QtWebEngineWidgets") + source=target/"Launcher.cs" + source.write_text((ROOT/"packaging/Launcher.cs").read_text().replace("__VERSION__",version)) + compiler=Path(os.environ["WINDIR"])/"Microsoft.NET/Framework64/v4.0.30319/csc.exe" + run(compiler,"/nologo","/target:winexe","/platform:x64","/optimize+","/debug-", + "/reference:System.Windows.Forms.dll",f"/out:{target/'WatermarkRemover-AI.exe'}",source) + source.unlink() + freeze=subprocess.check_output([str(executable),"-m","pip","freeze","--all"],text=True) + (target/"installed-packages.txt").write_text(freeze,encoding="utf-8") + commit=subprocess.check_output(["git","rev-parse","HEAD"],cwd=ROOT,text=True).strip() + (target/"build-info.json").write_text(json.dumps(dict(version=version,backend=args.backend, + python=runtime,runtime_inputs=runtime_inputs(args.backend, runtime),source_commit=commit, + source_dirty=bool(subprocess.check_output(["git","diff","--ignore-space-at-eol","HEAD"],cwd=ROOT))),indent=2)+"\n") + if not args.skip_archive: + bundle=args.output.resolve()/(name+".zip") + with zipfile.ZipFile(bundle,"w",compression=zipfile.ZIP_DEFLATED,compresslevel=6) as output: + for path in sorted(target.rglob("*")): + relative=path.relative_to(target) + if path.is_file() and relative.parts[0]!="data" and "__pycache__" not in relative.parts and relative.parts[:2] != ("python", "Scripts"): + output.write(path,Path(name)/relative) + digest=sha256(bundle) + bundle.with_suffix(".zip.sha256").write_text(f"{digest} {bundle.name}\n") + print("Built",bundle,"SHA256",digest,flush=True) + print("Portable folder",target,flush=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/check_desktop.py b/scripts/check_desktop.py new file mode 100644 index 0000000..04d1954 --- /dev/null +++ b/scripts/check_desktop.py @@ -0,0 +1,79 @@ +"""Exercise the actual local desktop page, assets, configuration and API bridge.""" +import json +import logging +import os +from pathlib import Path +import sys +import threading +import time + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from desktop_runtime import configure_runtime +configure_runtime() +logging.getLogger().addHandler(logging.StreamHandler()) +import remwmgui + +finished = threading.Event() +failures = [] + + +def exercise(window, api): + """Read real Alpine state and execute actions through JavaScript API promises.""" + original_config = dict(api.get_config()) + try: + assert window.events.loaded.wait(60), "Desktop page did not finish loading" + deadline = time.monotonic() + 20 + state = None + while time.monotonic() < deadline: + state = window.evaluate_js("""JSON.stringify({ + ready: !!window.appInstance && !window.appInstance.isLoading, + api: !!window.pywebview?.api, + version: window.appInstance?.appVersion, + translations: Object.keys(window.appInstance?.t || {}).length, + ram: window.appInstance?.systemInfo?.ram, + models: window.appInstance?.models?.status, + background: getComputedStyle(document.body).backgroundColor, + origin: location.protocol, + resources: performance.getEntriesByType('resource').map(x => x.name) + })""") + state = json.loads(state) if isinstance(state, str) else state + if state and state.get('ready') and state.get('translations') and state.get('ram', 0) > 0: + break + time.sleep(.2) + assert state and state['ready'] and state['api'], state + assert state['origin'] == 'http:', state + assert state['translations'] >= 10 and state['version'] != '0.0', state + assert all(url.startswith(('http://127.0.0.1:', 'http://localhost:')) for url in state['resources']), state + window.evaluate_js("document.querySelector('#models-button').click()") + assert window.evaluate_js('window.appInstance.modelsOpen') + window.evaluate_js("document.querySelector('#close-models').click()") + assert not window.evaluate_js('window.appInstance.modelsOpen') + window.evaluate_js("window.savedProbe = null; window.pywebview.api.save_config({theme:'korpo',lang:'en'}).then(() => window.pywebview.api.get_config()).then(x => window.savedProbe = x)") + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + if window.evaluate_js("window.savedProbe?.theme") == 'korpo': + break + time.sleep(.1) + assert window.evaluate_js("window.savedProbe?.lang") == 'en' + print('DESKTOP PASS', json.dumps(state), flush=True) + except BaseException as exc: + failures.append(repr(exc)) + finally: + api.save_config(original_config) + window.destroy() + + +def watchdog(): + """Bound native window startup and shutdown as well as the page checks.""" + if not finished.wait(90): + print('Desktop check timed out', flush=True) + os._exit(1) + + +threading.Thread(target=watchdog, daemon=True).start() +# WebKit can defer network page loading for an entirely hidden native window. +# Exercise its normal visible startup on the macOS CI desktop. +remwmgui.main(exercise, hidden=sys.platform != "darwin") +finished.set() +if failures: + raise SystemExit('; '.join(failures)) diff --git a/setup.bat b/setup.bat index 607bc50..7ac900e 100644 --- a/setup.bat +++ b/setup.bat @@ -17,7 +17,7 @@ set PYTHON_EXE=%PYTHON_DIR%\python.exe set CHINA_MODE=0 set PIP_MIRROR= set PIP_TRUSTED_HOST= -set HF_ENDPOINT= +set HF_ENDPOINT=https://huggingface.co :: Check if user is in China (for mirror selection) echo [?] Are you in China? (y/n) @@ -143,13 +143,11 @@ echo. echo [*] Downloading Florence-2 model (~1.5GB)... if "%CHINA_MODE%"=="1" ( echo Using HF-Mirror for faster download in China - "%PYTHON_EXE%" -c "import os; os.environ['HF_ENDPOINT']='%HF_ENDPOINT%'; from huggingface_hub import snapshot_download; snapshot_download('florence-community/Florence-2-large', local_dir_use_symlinks=False)" -) else ( - "%PYTHON_EXE%" -c "from huggingface_hub import snapshot_download; snapshot_download('florence-community/Florence-2-large', local_dir_use_symlinks=False)" ) +"%PYTHON_EXE%" -m model_assets --florence-only --endpoint "%HF_ENDPOINT%" if errorlevel 1 ( echo [!] Warning: Could not download Florence-2 model - echo It will be downloaded on first use + echo Open Models in the application and choose Download / Retry ) else ( echo [OK] Florence-2 model ready ) diff --git a/setup.ps1 b/setup.ps1 index 80c5982..20cbd06 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -11,7 +11,7 @@ $PYTHON_EXE = "$PYTHON_DIR\python.exe" $CHINA_MODE = $false $PIP_INDEX_URL = "" $PIP_EXTRA_ARGS = @() -$HF_ENDPOINT = "" +$HF_ENDPOINT = "https://huggingface.co" # Fun facts and tips to show during installation $tips = @( @@ -194,25 +194,9 @@ Write-Host " Did you know?" -ForegroundColor DarkGray Write-Host "" if ($CHINA_MODE) { - # Set HF_ENDPOINT environment variable for China mirror - $env:HF_ENDPOINT = $HF_ENDPOINT Write-Host " Using HF-Mirror for faster download in China" -ForegroundColor DarkGray - $florenceScript = @" -import os -os.environ['HF_ENDPOINT'] = '$HF_ENDPOINT' -from huggingface_hub import snapshot_download -snapshot_download('florence-community/Florence-2-large', local_dir_use_symlinks=False) -print('FLORENCE_OK') -"@ -} else { - $florenceScript = @" -from huggingface_hub import snapshot_download -snapshot_download('florence-community/Florence-2-large', local_dir_use_symlinks=False) -print('FLORENCE_OK') -"@ } - -$florenceProcess = Start-Process -FilePath $PYTHON_EXE -ArgumentList "-c", "`"$florenceScript`"" -NoNewWindow -PassThru +$florenceProcess = Start-Process -FilePath $PYTHON_EXE -ArgumentList "-m", "model_assets", "--florence-only", "--endpoint", $HF_ENDPOINT -NoNewWindow -PassThru $florenceHandle = $florenceProcess.Handle $lastTipTime = Get-Date @@ -235,7 +219,7 @@ Write-Host "`r $florenceProcess.WaitForExit() if ($florenceProcess.ExitCode -ne 0) { Write-Host " [!] Warning: Could not download Florence-2 model" -ForegroundColor Yellow - Write-Host " It will be downloaded on first use" -ForegroundColor Yellow + Write-Host " Open Models in the application and choose Download / Retry" -ForegroundColor Yellow } else { Write-Host " [OK] Florence-2 model ready" -ForegroundColor Green diff --git a/setup.sh b/setup.sh index 48bcbc2..9333dca 100755 --- a/setup.sh +++ b/setup.sh @@ -11,7 +11,7 @@ echo "" # China mirror configuration CHINA_MODE=0 PIP_MIRROR="" -HF_ENDPOINT="" +HF_ENDPOINT="https://huggingface.co" # Check if user is in China (for mirror selection) echo " [?] Are you in China? (y/n)" @@ -120,10 +120,8 @@ python -m lama_inpaint download echo " [*] Downloading Florence-2 model (~1.5GB)..." if [ "$CHINA_MODE" == "1" ]; then echo " Using HF-Mirror for faster download in China" - HF_ENDPOINT="$HF_ENDPOINT" python -c "import os; os.environ['HF_ENDPOINT']='$HF_ENDPOINT'; from huggingface_hub import snapshot_download; snapshot_download('florence-community/Florence-2-large', local_dir_use_symlinks=False)" || echo " [!] Florence-2 download failed, will retry on first use" -else - python -c "from huggingface_hub import snapshot_download; snapshot_download('florence-community/Florence-2-large', local_dir_use_symlinks=False)" || echo " [!] Florence-2 download failed, will retry on first use" fi +python -m model_assets --florence-only --endpoint "$HF_ENDPOINT" || echo " [!] Florence-2 download failed, open Models in the application and choose Download / Retry" echo "" echo " =============================================" diff --git a/tailwind.config.cjs b/tailwind.config.cjs new file mode 100644 index 0000000..90b0618 --- /dev/null +++ b/tailwind.config.cjs @@ -0,0 +1,36 @@ +module.exports = { + content: ['./ui/**/*.html', './ui/**/*.js', './remwmgui.py'], + theme: { + extend: { + fontFamily: { + mono: ['"Space Mono"', 'monospace'], + glitch: ['"Rubik Glitch"', 'cursive'], + sans: ['Inter', 'sans-serif'], + }, + colors: { + 'void': '#050505', + 'card': '#111116', + 'neon-green': '#00ff88', + 'neon-pink': '#ff006e', + 'neon-cyan': '#00f3ff', + 'error': '#ff3333', + }, + animation: { + 'pulse-fast': 'pulse 0.5s cubic-bezier(0.4, 0, 0.6, 1) infinite', + 'glitch': 'glitch 1s linear infinite', + 'marquee': 'marquee 10s linear infinite', + }, + keyframes: { + glitch: { + '2%, 64%': { transform: 'translate(2px,0) skew(0deg)' }, + '4%, 60%': { transform: 'translate(-2px,0) skew(0deg)' }, + '62%': { transform: 'translate(0,0) skew(5deg)' }, + }, + marquee: { + '0%': { transform: 'translateX(0)' }, + '100%': { transform: 'translateX(-50%)' }, + } + } + } + } + }; diff --git a/tests/test_desktop_models.py b/tests/test_desktop_models.py new file mode 100644 index 0000000..8d16a8b --- /dev/null +++ b/tests/test_desktop_models.py @@ -0,0 +1,32 @@ +"""Exercise actual preparation subprocess failures and retries without network/models.""" +import time + +from desktop_models import ModelPreparation +import desktop_models + + +def wait(controller): + """Bound worker completion so a broken process lifecycle fails the test.""" + deadline=time.monotonic()+10 + while controller.status()['busy'] and time.monotonic() \ No newline at end of file diff --git a/ui/assets/lace.svg b/ui/assets/lace.svg new file mode 100644 index 0000000..4085645 --- /dev/null +++ b/ui/assets/lace.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/assets/star-cursor.svg b/ui/assets/star-cursor.svg new file mode 100644 index 0000000..af9036a --- /dev/null +++ b/ui/assets/star-cursor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/config.json b/ui/config.json index 1204971..6b1171f 100644 --- a/ui/config.json +++ b/ui/config.json @@ -1,5 +1,5 @@ { - "version": "0.67", + "version": "0.68.0", "themes": [ { "id": "brainrot", "name": "Brainrot" }, { "id": "slay", "name": "Slay Queen" }, diff --git a/ui/index.html b/ui/index.html index d235777..302c181 100644 --- a/ui/index.html +++ b/ui/index.html @@ -6,51 +6,14 @@ WM REMOVER - OHIO EDITION - - - - - - + + + + -