diff --git a/catalog.toml b/catalog.toml index bf61869..4f2caf2 100644 --- a/catalog.toml +++ b/catalog.toml @@ -199,3 +199,16 @@ plugin_api = 25 version = "1.0.0" updated_at = 1786549052 rev = "4933b1c459459af3a0f9d14898b76ce0564d43b3" + +[[plugin]] +id = "noctalia/wallpaper_depth_large" +name = "Wallpaper Depth Large" +version = "1.0.2" +updated_at = 1786565714 +added_at = 1786549052 +author = "noctalia" +license = "MIT" +icon = "layers-subtract" +description = "Generate depth masks that place desktop widgets behind wallpaper foregrounds. Uses the larger model for better results." +plugin_api = 26 +tags = ["wallpaper", "desktop", "ai"] diff --git a/wallpaper_depth/depth_helper.py b/wallpaper_depth/depth_helper.py index 85098d8..511c261 100755 --- a/wallpaper_depth/depth_helper.py +++ b/wallpaper_depth/depth_helper.py @@ -55,6 +55,95 @@ def atomic_json(path: Path, value: dict[str, object]) -> None: os.replace(temporary, path) +def is_nixos() -> bool: + """NixOS has no FHS-style /lib, /usr/lib search path, so pip-installed + manylinux wheels (numpy/onnxruntime/Pillow) can fail to find shared + libraries such as libstdc++.so.6 at import time even though `pip install` + itself succeeds.""" + if Path("/etc/NIXOS").exists(): + return True + try: + os_release = Path("/etc/os-release").read_text(encoding="utf-8") + except OSError: + return False + return any(line.strip() in ('ID=nixos', 'ID="nixos"') for line in os_release.splitlines()) + + +# stdenv.cc.cc.lib provides libstdc++.so.6 and libgcc_s.so.1 (onnxruntime) +# and libgomp.so.1 (numpy's OpenBLAS threading); zlib covers Pillow/onnxruntime +# fallbacks that aren't always vendored inside the wheel itself. +NIX_LIBRARY_PACKAGES = ("stdenv.cc.cc.lib", "zlib") + + +def nix_library_cache_path(data_dir: Path) -> Path: + return data_dir / "runtime" / "nix-library-path.json" + + +def resolve_nix_library_path(data_dir: Path) -> str: + cache_path = nix_library_cache_path(data_dir) + try: + cached = json.loads(cache_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + cached = None + if ( + isinstance(cached, dict) + and cached.get("packages") == list(NIX_LIBRARY_PACKAGES) + and isinstance(cached.get("libDirs"), list) + ): + lib_dirs = [Path(entry) for entry in cached["libDirs"]] + if lib_dirs and all(path.is_dir() for path in lib_dirs): + return os.pathsep.join(str(path) for path in lib_dirs) + + store_paths: list[str] = [] + for attribute in NIX_LIBRARY_PACKAGES: + try: + result = subprocess.run( + ["nix-build", "", "-A", attribute, "--no-out-link"], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + except FileNotFoundError as error: + raise RuntimeError( + "nix-build was not found; cannot resolve NixOS runtime libraries " + "needed by numpy/onnxruntime/Pillow" + ) from error + if result.returncode != 0 or not result.stdout.strip(): + raise RuntimeError( + f"nix-build could not resolve nixpkgs#{attribute}: " + f"{result.stderr.strip() or 'no output'}" + ) + store_paths.append(result.stdout.strip().splitlines()[-1]) + + lib_dirs = [str(Path(path) / "lib") for path in store_paths] + atomic_json( + cache_path, + {"packages": list(NIX_LIBRARY_PACKAGES), "libDirs": lib_dirs, "resolvedAt": int(time.time())}, + ) + return os.pathsep.join(lib_dirs) + + +def ensure_nixos_dynamic_linking(data_dir: Path) -> None: + """On NixOS, re-exec this interpreter with LD_LIBRARY_PATH patched so + pip-installed manylinux wheels can find libstdc++/libgomp/zlib. Every + subprocess spawned afterwards (venv creation, pip install, the runtime + readiness check, and the venv python that runs `generate`) inherits this + process's environment, so patching it once here is enough for the whole + plugin -- no changes to service.luau are needed.""" + if os.environ.get("_WALLPAPER_DEPTH_NIXOS_PATCHED") == "1": + return + if not is_nixos(): + return + lib_path = resolve_nix_library_path(data_dir) + existing = os.environ.get("LD_LIBRARY_PATH", "") + new_env = dict(os.environ) + new_env["LD_LIBRARY_PATH"] = f"{lib_path}{os.pathsep}{existing}" if existing else lib_path + new_env["_WALLPAPER_DEPTH_NIXOS_PATCHED"] = "1" + os.execve(sys.executable, [sys.executable] + sys.argv, new_env) + + def runtime_python(data_dir: Path) -> Path: return data_dir / "runtime" / ".venv" / "bin" / "python" @@ -393,6 +482,7 @@ def main() -> int: args = parse_args() data_dir = args.data_dir.expanduser().resolve() try: + ensure_nixos_dynamic_linking(data_dir) if args.command == "setup": setup(data_dir) elif args.command == "status": diff --git a/wallpaper_depth_large/README.md b/wallpaper_depth_large/README.md new file mode 100644 index 0000000..77ec5d6 --- /dev/null +++ b/wallpaper_depth_large/README.md @@ -0,0 +1,71 @@ +# Wallpaper Depth + +Wallpaper Depth generates a foreground mask for each image wallpaper, allowing +Noctalia desktop widgets to pass behind nearby scenery. + +Depth estimation runs locally with +[Depth Anything V2 Small](https://huggingface.co/onnx-community/depth-anything-v2-small). +Wallpapers are never uploaded. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `noctalia/wallpaper_depth_large` | +| Entries | Service: `service`; panel: `manager` | + +## Requirements + +Install `python3` version 3.11โ€“3.14 with `venv` and `pip` support. Initial setup +requires network access, and shell offline mode must be disabled. + +From the plugin panel, setup creates an isolated Python environment in the +plugin data directory and downloads the 99 MB Apache-2.0 Depth Anything V2 +Small ONNX model from Hugging Face. The runtime contains pinned versions of +NumPy, ONNX Runtime, and Pillow; it does not modify the system Python +environment. + +## Usage + +1. Open the **Wallpaper Depth** panel, or toggle it from a terminal: + + ```sh + noctalia msg panel-toggle noctalia/wallpaper_depth_large:manager + ``` + +2. Select **Install model** and wait for setup to finish. +3. Apply an image wallpaper to each desired output. +4. Select **Generate masks**, or leave **Generate automatically** enabled. +5. Adjust **Foreground threshold** and **Edge feather** in the plugin settings + when the default mask does not match the scene. + +The panel reports generation state for every connected output. **Clear cache** +removes saved depth maps and masks; the plugin regenerates them when needed. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `auto_generate` | `bool` | `true` | Regenerates masks when a wallpaper or mask parameter changes. | +| `threshold` | `int` | `50` | Normalized depth cutoff from 0โ€“100. Lower values place more of the scene in front of desktop widgets. | +| `feather` | `int` | `8` | Soft-transition width around the depth cutoff, from 0โ€“50. | + +## How it works + +The service processes each output independently. It preserves the wallpaper's +aspect ratio during inference, normalizes the relative depth prediction, and +refines it against the source image before applying the configured threshold +and feather. The resulting mask is restored to the wallpaper's original +resolution and registered with Noctalia for that output. + +Depth predictions are cached separately from masks, so changing threshold or +feather can reuse the expensive model result. Cache entries are keyed by the +wallpaper contents, model revision, and processing version, and old entries are +pruned automatically. + +## Licensing and privacy + +The plugin is MIT-licensed. Depth Anything V2 Small is downloaded at setup time +under the Apache-2.0 license. Model inference, depth caches, and generated masks +remain in the local plugin data directory; only the model download contacts +Hugging Face. diff --git a/wallpaper_depth_large/depth_helper.py b/wallpaper_depth_large/depth_helper.py new file mode 100755 index 0000000..055f0c3 --- /dev/null +++ b/wallpaper_depth_large/depth_helper.py @@ -0,0 +1,506 @@ +#!/usr/bin/env python3 +"""Bootstrap Depth Anything V2 Large and generate source-aligned wallpaper masks.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import time +import urllib.request + +MODEL_REVISION = "0306443f3d6bd5eab8723897d5201f2c8aac95de" +MODEL_URL = ( + "https://huggingface.co/onnx-community/depth-anything-v2-large/resolve/" + f"{MODEL_REVISION}/onnx/model.onnx?download=true" +) +MODEL_SHA256 = "a93aa89b5e92e30e0afbe0f7c3ec692b35cfca791ae9004a190fb0ca2010e905" +MODEL_SIZE = 1_336_922_232 +INPUT_SIZE = 518 +MODEL_PATCH_SIZE = 14 +DEPTH_PIPELINE_VERSION = 2 +MASK_PIPELINE_VERSION = 3 +REFINEMENT_MAX_DIMENSION = 1920 +GUIDED_FILTER_RADIUS = 8 +GUIDED_FILTER_EPSILON = 0.001 +PYTHON_MIN = (3, 11) +PYTHON_MAX = (3, 14) +PACKAGES = ( + "numpy==2.4.2", + "onnxruntime==1.28.0", + "Pillow==12.3.0", +) + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def atomic_json(path: Path, value: dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile("w", dir=path.parent, delete=False, encoding="utf-8") as stream: + json.dump(value, stream, separators=(",", ":"), sort_keys=True) + stream.write("\n") + temporary = Path(stream.name) + os.replace(temporary, path) + + +def is_nixos() -> bool: + """NixOS has no FHS-style /lib, /usr/lib search path, so pip-installed + manylinux wheels (numpy/onnxruntime/Pillow) can fail to find shared + libraries such as libstdc++.so.6 at import time even though `pip install` + itself succeeds.""" + if Path("/etc/NIXOS").exists(): + return True + try: + os_release = Path("/etc/os-release").read_text(encoding="utf-8") + except OSError: + return False + return any(line.strip() in ('ID=nixos', 'ID="nixos"') for line in os_release.splitlines()) + + +# stdenv.cc.cc.lib provides libstdc++.so.6 and libgcc_s.so.1 (onnxruntime) +# and libgomp.so.1 (numpy's OpenBLAS threading); zlib covers Pillow/onnxruntime +# fallbacks that aren't always vendored inside the wheel itself. +NIX_LIBRARY_PACKAGES = ("stdenv.cc.cc.lib", "zlib") + + +def nix_library_cache_path(data_dir: Path) -> Path: + return data_dir / "runtime" / "nix-library-path.json" + + +def resolve_nix_library_path(data_dir: Path) -> str: + cache_path = nix_library_cache_path(data_dir) + try: + cached = json.loads(cache_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + cached = None + if ( + isinstance(cached, dict) + and cached.get("packages") == list(NIX_LIBRARY_PACKAGES) + and isinstance(cached.get("libDirs"), list) + ): + lib_dirs = [Path(entry) for entry in cached["libDirs"]] + if lib_dirs and all(path.is_dir() for path in lib_dirs): + return os.pathsep.join(str(path) for path in lib_dirs) + + store_paths: list[str] = [] + for attribute in NIX_LIBRARY_PACKAGES: + try: + result = subprocess.run( + ["nix-build", "", "-A", attribute, "--no-out-link"], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + except FileNotFoundError as error: + raise RuntimeError( + "nix-build was not found; cannot resolve NixOS runtime libraries " + "needed by numpy/onnxruntime/Pillow" + ) from error + if result.returncode != 0 or not result.stdout.strip(): + raise RuntimeError( + f"nix-build could not resolve nixpkgs#{attribute}: " + f"{result.stderr.strip() or 'no output'}" + ) + store_paths.append(result.stdout.strip().splitlines()[-1]) + + lib_dirs = [str(Path(path) / "lib") for path in store_paths] + atomic_json( + cache_path, + {"packages": list(NIX_LIBRARY_PACKAGES), "libDirs": lib_dirs, "resolvedAt": int(time.time())}, + ) + return os.pathsep.join(lib_dirs) + + +def ensure_nixos_dynamic_linking(data_dir: Path) -> None: + """On NixOS, re-exec this interpreter with LD_LIBRARY_PATH patched so + pip-installed manylinux wheels can find libstdc++/libgomp/zlib. Every + subprocess spawned afterwards (venv creation, pip install, the runtime + readiness check, and the venv python that runs `generate`) inherits this + process's environment, so patching it once here is enough for the whole + plugin -- no changes to service.luau are needed.""" + if os.environ.get("_WALLPAPER_DEPTH_NIXOS_PATCHED") == "1": + return + if not is_nixos(): + return + lib_path = resolve_nix_library_path(data_dir) + existing = os.environ.get("LD_LIBRARY_PATH", "") + new_env = dict(os.environ) + new_env["LD_LIBRARY_PATH"] = f"{lib_path}{os.pathsep}{existing}" if existing else lib_path + new_env["_WALLPAPER_DEPTH_NIXOS_PATCHED"] = "1" + os.execve(sys.executable, [sys.executable] + sys.argv, new_env) + + +def runtime_python(data_dir: Path) -> Path: + return data_dir / "runtime" / ".venv" / "bin" / "python" + + +def model_path(data_dir: Path) -> Path: + return data_dir / "models" / "depth-anything-v2-large" / "model.onnx" + + +def verify_model(path: Path) -> bool: + return path.is_file() and path.stat().st_size == MODEL_SIZE and sha256_file(path) == MODEL_SHA256 + + +def runtime_ready(data_dir: Path) -> bool: + python = runtime_python(data_dir) + if not python.is_file(): + return False + result = subprocess.run( + [str(python), "-c", "import numpy, onnxruntime, PIL"], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + return result.returncode == 0 + + +def emit(value: dict[str, object]) -> None: + print(json.dumps(value, separators=(",", ":"), sort_keys=True), flush=True) + + +def download_model(destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = destination.with_suffix(".onnx.part") + temporary.unlink(missing_ok=True) + request = urllib.request.Request(MODEL_URL, headers={"User-Agent": "Noctalia-Wallpaper-Depth/1.0"}) + try: + with urllib.request.urlopen(request, timeout=60) as response, temporary.open("wb") as stream: + while chunk := response.read(1024 * 1024): + stream.write(chunk) + if temporary.stat().st_size != MODEL_SIZE: + raise RuntimeError( + f"model size mismatch: expected {MODEL_SIZE} bytes, received {temporary.stat().st_size}" + ) + checksum = sha256_file(temporary) + if checksum != MODEL_SHA256: + raise RuntimeError(f"model checksum mismatch: expected {MODEL_SHA256}, received {checksum}") + os.replace(temporary, destination) + finally: + temporary.unlink(missing_ok=True) + + +def setup(data_dir: Path) -> None: + operation_path = data_dir / "setup-operation.json" + atomic_json( + operation_path, + {"state": "running", "startedAt": int(time.time()), "modelSize": MODEL_SIZE}, + ) + python_version = sys.version_info[:2] + if not PYTHON_MIN <= python_version <= PYTHON_MAX: + raise RuntimeError("Python 3.11 through 3.14 is required") + runtime_dir = data_dir / "runtime" + venv = runtime_dir / ".venv" + runtime_dir.mkdir(parents=True, exist_ok=True) + if not runtime_ready(data_dir): + subprocess.run( + [sys.executable, "-m", "venv", "--clear", str(venv)], + stdin=subprocess.DEVNULL, + check=True, + ) + subprocess.run( + [ + str(runtime_python(data_dir)), + "-m", + "pip", + "install", + "--disable-pip-version-check", + "--no-input", + "--only-binary=:all:", + *PACKAGES, + ], + stdin=subprocess.DEVNULL, + check=True, + ) + if not verify_model(model_path(data_dir)): + download_model(model_path(data_dir)) + if not runtime_ready(data_dir) or not verify_model(model_path(data_dir)): + raise RuntimeError("setup validation failed") + status = { + "ready": True, + "modelRevision": MODEL_REVISION, + "modelSha256": MODEL_SHA256, + "modelSize": MODEL_SIZE, + "packages": list(PACKAGES), + } + atomic_json(data_dir / "setup.json", status) + atomic_json( + operation_path, + { + "state": "ready", + "finishedAt": int(time.time()), + "modelRevision": MODEL_REVISION, + "modelSize": MODEL_SIZE, + }, + ) + emit(status) + + +def status(data_dir: Path) -> None: + model = model_path(data_dir) + emit( + { + "ready": runtime_ready(data_dir) and verify_model(model), + "runtimeReady": runtime_ready(data_dir), + "modelReady": verify_model(model), + "modelRevision": MODEL_REVISION, + "modelSize": MODEL_SIZE, + } + ) + + +def inference_size(source_width: int, source_height: int) -> tuple[int, int]: + scale = max(INPUT_SIZE / source_width, INPUT_SIZE / source_height) + width = max( + INPUT_SIZE, + round(source_width * scale / MODEL_PATCH_SIZE) * MODEL_PATCH_SIZE, + ) + height = max( + INPUT_SIZE, + round(source_height * scale / MODEL_PATCH_SIZE) * MODEL_PATCH_SIZE, + ) + return width, height + + +def smoothstep(values, low: float, high: float): + import numpy as np + + if high <= low: + return (values >= high).astype(np.float32) + scaled = np.clip((values - low) / (high - low), 0.0, 1.0) + return scaled * scaled * (3.0 - 2.0 * scaled) + + +def box_mean(values, radius: int): + import numpy as np + + padded = np.pad(values, ((radius, radius), (radius, radius)), mode="edge") + integral = np.pad(padded, ((1, 0), (1, 0)), mode="constant") + integral = np.cumsum(integral, axis=0, dtype=np.float64) + integral = np.cumsum(integral, axis=1, dtype=np.float64) + diameter = radius * 2 + 1 + total = ( + integral[diameter:, diameter:] + - integral[:-diameter, diameter:] + - integral[diameter:, :-diameter] + + integral[:-diameter, :-diameter] + ) + total *= 1.0 / (diameter * diameter) + return total.astype(np.float32) + + +def refine_depth(source_rgb, depth): + import numpy as np + from PIL import Image + + source_width, source_height = source_rgb.size + scale = min(1.0, REFINEMENT_MAX_DIMENSION / max(source_width, source_height)) + refinement_size = ( + max(1, round(source_width * scale)), + max(1, round(source_height * scale)), + ) + guide_image = source_rgb.convert("L") + if guide_image.size != refinement_size: + guide_image = guide_image.resize(refinement_size, Image.Resampling.LANCZOS) + guide = np.asarray(guide_image, dtype=np.float32) / np.float32(255.0) + coarse = np.asarray( + Image.fromarray(depth).resize(refinement_size, Image.Resampling.BICUBIC), + dtype=np.float32, + ) + + mean_guide = box_mean(guide, GUIDED_FILTER_RADIUS) + mean_depth = box_mean(coarse, GUIDED_FILTER_RADIUS) + correlation_guide = box_mean(guide * guide, GUIDED_FILTER_RADIUS) + correlation_cross = box_mean(guide * coarse, GUIDED_FILTER_RADIUS) + variance_guide = correlation_guide - mean_guide * mean_guide + covariance = correlation_cross - mean_guide * mean_depth + coefficient_a = covariance / (variance_guide + np.float32(GUIDED_FILTER_EPSILON)) + coefficient_b = mean_depth - coefficient_a * mean_guide + refined = np.clip( + box_mean(coefficient_a, GUIDED_FILTER_RADIUS) * guide + + box_mean(coefficient_b, GUIDED_FILTER_RADIUS), + 0.0, + 1.0, + ).astype(np.float32) + + if refinement_size != (source_width, source_height): + refined = np.asarray( + Image.fromarray(refined).resize((source_width, source_height), Image.Resampling.BICUBIC), + dtype=np.float32, + ) + return refined + + +def prune(directory: Path, maximum: int) -> None: + if not directory.is_dir(): + return + files = sorted( + (path for path in directory.iterdir() if path.is_file()), + key=lambda path: path.stat().st_mtime_ns, + reverse=True, + ) + for path in files[maximum:]: + path.unlink(missing_ok=True) + + +def generate(data_dir: Path, wallpaper: Path, threshold: float, feather: float) -> None: + import fcntl + import numpy as np + import onnxruntime as ort + from PIL import Image + + if not wallpaper.is_file(): + raise RuntimeError("wallpaper is missing or unreadable") + if not verify_model(model_path(data_dir)): + raise RuntimeError("model is missing or failed checksum validation; run setup again") + threshold = min(1.0, max(0.0, threshold)) + feather = min(0.5, max(0.0, feather)) + started = time.monotonic() + wallpaper_hash = sha256_file(wallpaper) + cache_key = ( + f"{wallpaper_hash}-{MODEL_SHA256[:16]}-d{DEPTH_PIPELINE_VERSION}-i{INPUT_SIZE}" + ) + depth_dir = data_dir / "cache" / "depth" + mask_dir = data_dir / "cache" / "masks" + depth_dir.mkdir(parents=True, exist_ok=True) + mask_dir.mkdir(parents=True, exist_ok=True) + depth_path = depth_dir / f"{cache_key}.npy" + mask_key = f"{cache_key}-v{MASK_PIPELINE_VERSION}-t{threshold:.4f}-f{feather:.4f}" + mask_path = mask_dir / f"{mask_key}.png" + lock_path = data_dir / "runtime" / "generate.lock" + lock_path.parent.mkdir(parents=True, exist_ok=True) + + with lock_path.open("w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + cache_hit = depth_path.is_file() + with Image.open(wallpaper) as source: + source_rgb = source.convert("RGB") + source_width, source_height = source_rgb.size + if source_width <= 0 or source_height <= 0: + raise RuntimeError("wallpaper has invalid dimensions") + model_width, model_height = inference_size(source_width, source_height) + if cache_hit: + depth = np.load(depth_path, allow_pickle=False) + if depth.shape != (model_height, model_width): + depth_path.unlink(missing_ok=True) + cache_hit = False + if not cache_hit: + resized = source_rgb.resize((model_width, model_height), Image.Resampling.BICUBIC) + pixels = np.asarray(resized, dtype=np.float32) / np.float32(255.0) + pixels = (pixels - np.array([0.485, 0.456, 0.406], dtype=np.float32)) / np.array( + [0.229, 0.224, 0.225], dtype=np.float32 + ) + tensor = np.transpose(pixels, (2, 0, 1))[None, ...] + options = ort.SessionOptions() + options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL + session = ort.InferenceSession( + str(model_path(data_dir)), sess_options=options, providers=["CPUExecutionProvider"] + ) + input_name = session.get_inputs()[0].name + prediction = session.run(None, {input_name: tensor})[0] + depth = np.asarray(prediction, dtype=np.float32).squeeze() + if depth.shape != (model_height, model_width): + raise RuntimeError( + f"unexpected model output shape: {depth.shape}; " + f"expected {(model_height, model_width)}" + ) + minimum = float(np.min(depth)) + maximum = float(np.max(depth)) + if not np.isfinite(minimum) or not np.isfinite(maximum) or maximum <= minimum: + raise RuntimeError("model returned an invalid depth map") + depth = (depth - minimum) / (maximum - minimum) + with tempfile.NamedTemporaryFile("wb", dir=depth_dir, delete=False) as stream: + np.save(stream, depth, allow_pickle=False) + temporary_depth = Path(stream.name) + os.replace(temporary_depth, depth_path) + + if mask_path.is_file(): + with Image.open(mask_path) as cached_mask: + if cached_mask.size != (source_width, source_height): + mask_path.unlink(missing_ok=True) + if not mask_path.is_file(): + full_size_depth = refine_depth(source_rgb, depth) + half_feather = feather * 0.5 + alpha = smoothstep(full_size_depth, threshold - half_feather, threshold + half_feather) + alpha_image = Image.fromarray(np.rint(alpha * 255.0).astype(np.uint8)) + with tempfile.NamedTemporaryFile("wb", dir=mask_dir, delete=False) as stream: + alpha_image.save(stream, format="PNG", optimize=True) + temporary_mask = Path(stream.name) + os.replace(temporary_mask, mask_path) + + prune(depth_dir, 8) + prune(mask_dir, 32) + + emit( + { + "cacheHit": cache_hit, + "elapsedMs": round((time.monotonic() - started) * 1000), + "height": source_height, + "maskPath": str(mask_path), + "modelRevision": MODEL_REVISION, + "wallpaperPath": str(wallpaper), + "width": source_width, + } + ) + + +def clear_cache(data_dir: Path) -> None: + shutil.rmtree(data_dir / "cache", ignore_errors=True) + emit({"cleared": True}) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--data-dir", required=True, type=Path) + subparsers = parser.add_subparsers(dest="command", required=True) + subparsers.add_parser("setup") + subparsers.add_parser("status") + generate_parser = subparsers.add_parser("generate") + generate_parser.add_argument("--wallpaper", required=True, type=Path) + generate_parser.add_argument("--threshold", required=True, type=float) + generate_parser.add_argument("--feather", required=True, type=float) + subparsers.add_parser("clear-cache") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + data_dir = args.data_dir.expanduser().resolve() + try: + ensure_nixos_dynamic_linking(data_dir) + if args.command == "setup": + setup(data_dir) + elif args.command == "status": + status(data_dir) + elif args.command == "generate": + generate(data_dir, args.wallpaper.expanduser().resolve(), args.threshold, args.feather) + elif args.command == "clear-cache": + clear_cache(data_dir) + return 0 + except Exception as error: # Keep the Luau-facing failure one line and actionable. + if args.command == "setup": + atomic_json( + data_dir / "setup-operation.json", + {"state": "error", "finishedAt": int(time.time()), "message": str(error)}, + ) + print(str(error), file=sys.stderr, flush=True) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/wallpaper_depth_large/panel.luau b/wallpaper_depth_large/panel.luau new file mode 100644 index 0000000..b9ca080 --- /dev/null +++ b/wallpaper_depth_large/panel.luau @@ -0,0 +1,232 @@ +--!nonstrict + +local status = { + setupState = "checking", + setupMessage = "", + busy = true, + outputs = {}, +} + +local function tr(key, params) + return noctalia.tr(key, params) +end + +local function outputState(row) + if row.state == "ready" then + local detail = tr("panel.output.ready") + if type(row.elapsedMs) == "number" then + detail = tr("panel.output.ready_time", { seconds = string.format("%.1f", row.elapsedMs / 1000) }) + end + if row.cacheHit == true then + detail = detail .. " ยท " .. tr("panel.output.cached") + end + return "check", "primary", detail + elseif row.state == "processing" then + return "loader-2", "primary", tr("panel.output.processing") + elseif row.state == "waiting" then + return "hourglass", "on_surface_variant", tr("panel.output.waiting") + elseif row.state == "error" then + return "alert-triangle", "error", row.message or tr("panel.output.error") + end + return "photo-off", "on_surface_variant", tr("panel.output.no_wallpaper") +end + +local function outputRows() + local rows = {} + for _, row in ipairs(status.outputs or {}) do + local glyph, color, detail = outputState(row) + table.insert(rows, ui.row({ + gap = 10, + align = "center", + padding = 10, + radius = 8, + fill = "surface_variant/0.35", + }, { + ui.glyph({ name = glyph, size = 18, color = color }), + ui.column({ gap = 2, flexGrow = 1 }, { + ui.label({ text = row.name or "", fontWeight = "bold", fontSize = 13 }), + ui.label({ text = detail, color = color, fontSize = 11, wrap = true }), + }), + })) + end + if #rows == 0 then + table.insert(rows, ui.label({ + text = tr("panel.no_outputs"), + color = "on_surface_variant", + fontSize = 12, + })) + end + return rows +end + +local function setupCard() + local state = status.setupState or "checking" + local glyph = "loader-2" + local color = "on_surface_variant" + local title = tr("panel.setup.checking") + if state == "ready" then + glyph = "circle-check" + color = "primary" + title = tr("panel.setup.ready") + elseif state == "running" then + glyph = "download" + color = "primary" + title = tr("panel.setup.running") + elseif state == "missing" then + glyph = "package" + title = tr("panel.setup.missing") + elseif state == "error" then + glyph = "alert-triangle" + color = "error" + title = tr("panel.setup.error") + end + + local children = { + ui.row({ gap = 10, align = "center" }, { + ui.glyph({ name = glyph, size = 20, color = color }), + ui.column({ gap = 2, flexGrow = 1 }, { + ui.label({ text = title, fontWeight = "bold", fontSize = 13 }), + ui.label({ + text = status.setupMessage or "", + color = color, + fontSize = 11, + wrap = true, + visible = type(status.setupMessage) == "string" and status.setupMessage ~= "", + }), + }), + }), + } + + if state ~= "ready" then + local prerequisites = {} + if status.pythonAvailable ~= true then + table.insert(prerequisites, "python3") + end + if #prerequisites > 0 then + table.insert(children, ui.label({ + text = tr("panel.setup.prerequisites", { commands = table.concat(prerequisites, ", ") }), + color = "error", + fontSize = 11, + wrap = true, + })) + end + if status.offline == true then + table.insert(children, ui.label({ + text = tr("panel.setup.offline"), + color = "error", + fontSize = 11, + wrap = true, + })) + end + table.insert(children, ui.label({ + text = tr("panel.setup.disclosure"), + color = "on_surface_variant", + fontSize = 11, + wrap = true, + })) + table.insert(children, ui.button({ + text = state == "error" and tr("panel.setup.retry") or tr("panel.setup.install"), + glyph = "download", + variant = "primary", + enabled = state ~= "running" + and status.offline ~= true + and status.pythonAvailable == true + and status.busy ~= true, + onClick = "onSetup", + })) + end + + return ui.column({ gap = 8, padding = 10, radius = 8, fill = "surface_variant/0.35" }, children) +end + +local function render() + local ready = status.setupState == "ready" + local threshold = math.floor((status.threshold or 0) * 100 + 0.5) + local feather = math.floor((status.feather or 0) * 100 + 0.5) + panel.render(ui.column({ flexGrow = 1, gap = 10, padding = 10 }, { + ui.row({ align = "center", gap = 8 }, { + ui.glyph({ name = "layers-subtract", size = 20, color = "primary" }), + ui.label({ text = tr("title"), fontSize = 16, fontWeight = "bold", flexGrow = 1 }), + ui.button({ glyph = "close", variant = "ghost", onClick = function() + panel.close() + end }), + }), + ui.label({ + text = tr("panel.description"), + color = "on_surface_variant", + fontSize = 12, + wrap = true, + }), + setupCard(), + ui.row({ align = "center", justify = "space_between" }, { + ui.label({ text = tr("panel.parameters"), fontWeight = "bold", fontSize = 13 }), + ui.button({ text = tr("panel.settings"), glyph = "settings", variant = "ghost", onClick = "onSettings" }), + }), + ui.row({ gap = 12, paddingH = 4 }, { + ui.column({ gap = 1, flexGrow = 1 }, { + ui.label({ text = tr("panel.threshold"), color = "on_surface_variant", fontSize = 11 }), + ui.label({ text = tostring(threshold) .. "%", fontWeight = "bold", fontSize = 13 }), + }), + ui.column({ gap = 1, flexGrow = 1 }, { + ui.label({ text = tr("panel.feather"), color = "on_surface_variant", fontSize = 11 }), + ui.label({ text = tostring(feather) .. "%", fontWeight = "bold", fontSize = 13 }), + }), + ui.column({ gap = 1, flexGrow = 1 }, { + ui.label({ text = tr("panel.automatic"), color = "on_surface_variant", fontSize = 11 }), + ui.label({ + text = status.autoGenerate == true and tr("panel.on") or tr("panel.off"), + fontWeight = "bold", + fontSize = 13, + }), + }), + }), + ui.separator({}), + ui.label({ text = tr("panel.outputs"), fontWeight = "bold", fontSize = 13 }), + ui.scroll({ flexGrow = 1, gap = 6 }, outputRows()), + ui.row({ gap = 8 }, { + ui.button({ + text = tr("panel.generate"), + glyph = "sparkles", + variant = "primary", + flexGrow = 1, + enabled = ready and status.busy ~= true, + onClick = "onGenerate", + }), + ui.button({ + text = tr("panel.clear_cache"), + glyph = "trash", + variant = "outline", + enabled = status.busy ~= true, + onClick = "onClearCache", + }), + }), + })) +end + +function onSetup() + noctalia.state.set("wallpaper_depth.command", { action = "setup" }) +end + +function onGenerate() + noctalia.state.set("wallpaper_depth.command", { action = "generate" }) +end + +function onClearCache() + noctalia.state.set("wallpaper_depth.command", { action = "clear_cache" }) +end + +function onSettings() + noctalia.openSettings() +end + +function onOpen(_context) + status = noctalia.state.get("wallpaper_depth.status") or status + render() +end + +noctalia.state.watch("wallpaper_depth.status", function(value) + if type(value) == "table" then + status = value + render() + end +end) diff --git a/wallpaper_depth_large/plugin.toml b/wallpaper_depth_large/plugin.toml new file mode 100644 index 0000000..448acdf --- /dev/null +++ b/wallpaper_depth_large/plugin.toml @@ -0,0 +1,49 @@ +id = "noctalia/wallpaper_depth_large" +name = "Wallpaper Depth Large" +version = "1.0.2" +plugin_api = 26 +author = "noctalia" +license = "MIT" +dependencies = ["python3"] +tags = ["wallpaper", "desktop", "ai"] +icon = "layers-subtract" +description = "Generate depth masks that place desktop widgets behind wallpaper foregrounds. Uses the large model for better results." + +[[setting]] +key = "auto_generate" +type = "bool" +label_key = "settings.auto_generate.label" +description_key = "settings.auto_generate.description" +default = true + +[[setting]] +key = "threshold" +type = "int" +label_key = "settings.threshold.label" +description_key = "settings.threshold.description" +default = 50 +min = 0 +max = 100 +step = 1 + +[[setting]] +key = "feather" +type = "int" +label_key = "settings.feather.label" +description_key = "settings.feather.description" +default = 8 +min = 0 +max = 50 +step = 1 + +[[service]] +id = "service" +entry = "service.luau" + +[[panel]] +id = "manager" +entry = "panel.luau" +width = 500 +height = 660 +placement = "attached" +position = "auto" diff --git a/wallpaper_depth_large/service.luau b/wallpaper_depth_large/service.luau new file mode 100644 index 0000000..879cf0a --- /dev/null +++ b/wallpaper_depth_large/service.luau @@ -0,0 +1,491 @@ +--!nonstrict + +noctalia.setUpdateInterval(1000) + +local MODEL_SIZE = 1336922232 +local helperPath = noctalia.pluginDir() .. "/depth_helper.py" +local dataDir = noctalia.pluginDataDir() +if dataDir == nil then + error("Wallpaper Depth requires a plugin data directory") +end +local operationPath = dataDir .. "/setup-operation.json" +local runtimePython = dataDir .. "/runtime/.venv/bin/python" + +local setupState = "checking" +local setupMessage = "" +local activeJob = nil +local cacheOperation = false +local pending = {} +local queued = {} +local outputs = {} +local lastParametersKey = nil +local lastAutoGenerate = nil +local pythonAvailable = noctalia.commandExists("python3") + +local function decodeObject(raw) + if type(raw) ~= "string" or raw == "" then + return nil + end + local value = noctalia.json.decode(raw) + if type(value) ~= "table" then + return nil + end + return value +end + +local function shortError(result, fallback) + local message = "" + if type(result) == "table" then + message = noctalia.string.trim(result.stderr or "") + if message == "" then + message = noctalia.string.trim(result.stdout or "") + end + end + if message == "" then + message = fallback + end + if #message > 300 then + message = string.sub(message, 1, 297) .. "..." + end + return message +end + +local function parameters() + local threshold = noctalia.getConfig("threshold") + local feather = noctalia.getConfig("feather") + local autoGenerate = noctalia.getConfig("auto_generate") + if type(threshold) ~= "number" or type(feather) ~= "number" or type(autoGenerate) ~= "boolean" then + return nil, noctalia.tr("service.invalid_settings") + end + threshold = math.max(0, math.min(100, threshold)) / 100 + feather = math.max(0, math.min(50, feather)) / 100 + return { + threshold = threshold, + feather = feather, + autoGenerate = autoGenerate, + key = string.format("%.4f:%.4f", threshold, feather), + }, nil +end + +local function publish() + local rows = {} + for _, row in pairs(outputs) do + table.insert(rows, { + name = row.name, + wallpaperPath = row.wallpaperPath, + state = row.state, + message = row.message, + cacheHit = row.cacheHit, + elapsedMs = row.elapsedMs, + }) + end + table.sort(rows, function(left, right) + return left.name < right.name + end) + local params, parameterError = parameters() + noctalia.state.set("wallpaper_depth.status", { + setupState = setupState, + setupMessage = setupMessage ~= "" and setupMessage or parameterError, + busy = setupState == "running" or activeJob ~= nil or cacheOperation, + offline = noctalia.getSetting("shell.offline_mode"), + pythonAvailable = pythonAvailable, + modelSize = MODEL_SIZE, + threshold = params ~= nil and params.threshold or 0, + feather = params ~= nil and params.feather or 0, + autoGenerate = params ~= nil and params.autoGenerate or false, + outputs = rows, + }) +end + +local function setSetupState(state, message) + setupState = state + setupMessage = message or "" + publish() +end + +local function readOperation() + return decodeObject(noctalia.readFile(operationPath)) +end + +local function enqueue(outputName, wallpaperPath, params) + if setupState ~= "ready" or type(wallpaperPath) ~= "string" or wallpaperPath == "" then + return + end + local key = wallpaperPath .. "\n" .. params.key + if queued[outputName] == key then + return + end + if activeJob ~= nil and activeJob.outputName == outputName and activeJob.key == key then + return + end + queued[outputName] = key + table.insert(pending, { + outputName = outputName, + wallpaperPath = wallpaperPath, + threshold = params.threshold, + feather = params.feather, + parametersKey = params.key, + key = key, + }) +end + +local runNext + +runNext = function() + if activeJob ~= nil or cacheOperation or setupState ~= "ready" then + return + end + local params = parameters() + if params == nil then + return + end + + local job = nil + while #pending > 0 do + local candidate = table.remove(pending, 1) + queued[candidate.outputName] = nil + local currentPath = noctalia.wallpaperPath(candidate.outputName) + if currentPath == candidate.wallpaperPath and candidate.parametersKey == params.key then + job = candidate + break + end + end + if job == nil then + publish() + return + end + + activeJob = job + local row = outputs[job.outputName] + if row ~= nil then + row.state = "processing" + row.message = "" + row.cacheHit = nil + row.elapsedMs = nil + end + publish() + + local accepted = noctalia.runAsync({ + runtimePython, + helperPath, + "--data-dir", + dataDir, + "generate", + "--wallpaper", + job.wallpaperPath, + "--threshold", + tostring(job.threshold), + "--feather", + tostring(job.feather), + }, function(result) + local completedJob = activeJob + activeJob = nil + if completedJob == nil then + runNext() + return + end + + local currentParameters = parameters() + local currentPath = noctalia.wallpaperPath(completedJob.outputName) + local currentRow = outputs[completedJob.outputName] + local stale = currentParameters == nil + or currentParameters.key ~= completedJob.parametersKey + or currentPath ~= completedJob.wallpaperPath + if stale then + if currentRow ~= nil then + currentRow.state = currentPath ~= nil and "waiting" or "no_wallpaper" + end + if currentParameters ~= nil and currentPath ~= nil then + enqueue(completedJob.outputName, currentPath, currentParameters) + end + publish() + runNext() + return + end + + if result.exitCode ~= 0 or result.timedOut then + noctalia.setWallpaperMask(completedJob.outputName, nil) + if currentRow ~= nil then + currentRow.state = "error" + currentRow.message = shortError(result, noctalia.tr("service.generation_failed")) + end + else + local generated = decodeObject(result.stdout) + if generated == nil + or type(generated.maskPath) ~= "string" + or generated.maskPath == "" + or generated.wallpaperPath ~= completedJob.wallpaperPath + then + noctalia.setWallpaperMask(completedJob.outputName, nil) + if currentRow ~= nil then + currentRow.state = "error" + currentRow.message = noctalia.tr("service.invalid_result") + end + else + noctalia.setWallpaperMask(completedJob.outputName, { + path = generated.maskPath, + wallpaperPath = completedJob.wallpaperPath, + }) + if currentRow ~= nil then + currentRow.state = "ready" + currentRow.message = "" + currentRow.cacheHit = generated.cacheHit == true + currentRow.elapsedMs = tonumber(generated.elapsedMs) + end + end + end + publish() + runNext() + end, 60000) + + if not accepted then + activeJob = nil + noctalia.setWallpaperMask(job.outputName, nil) + if row ~= nil then + row.state = "error" + row.message = noctalia.tr("service.start_helper_failed") + end + publish() + runNext() + end +end + +local function enqueueAll(force) + local params, parameterError = parameters() + if params == nil then + setSetupState(setupState, parameterError) + return + end + for outputName, row in pairs(outputs) do + if type(row.wallpaperPath) == "string" and row.wallpaperPath ~= "" then + if force then + noctalia.setWallpaperMask(outputName, nil) + row.state = "waiting" + row.message = "" + end + enqueue(outputName, row.wallpaperPath, params) + end + end + publish() + runNext() +end + +local function verifySetup() + if not pythonAvailable then + setSetupState("missing", noctalia.tr("service.python_missing")) + return + end + setSetupState("checking", "") + local accepted = noctalia.runAsync({ + "python3", + helperPath, + "--data-dir", + dataDir, + "status", + }, function(result) + if result.exitCode ~= 0 or result.timedOut then + setSetupState("missing", shortError(result, noctalia.tr("service.validation_failed"))) + return + end + local value = decodeObject(result.stdout) + if value ~= nil and value.ready == true then + setSetupState("ready", "") + enqueueAll(false) + else + setSetupState("missing", noctalia.tr("service.setup_missing")) + end + end, 60000) + if not accepted then + setSetupState("error", noctalia.tr("service.start_validation_failed")) + end +end + +local function startSetup() + if noctalia.getSetting("shell.offline_mode") then + setSetupState("error", noctalia.tr("service.offline")) + return + end + if not pythonAvailable then + setSetupState("error", noctalia.tr("service.python_missing")) + return + end + if activeJob ~= nil or cacheOperation then + setSetupState("error", noctalia.tr("service.busy")) + return + end + + noctalia.writeFile(operationPath, '{"state":"starting"}\n') + setSetupState("running", noctalia.tr("service.setup_running")) + local accepted = noctalia.runAsync({ + "python3", + helperPath, + "--data-dir", + dataDir, + "setup", + }) + if not accepted then + setSetupState("error", noctalia.tr("service.start_setup_failed")) + end +end + +local function pollSetup() + if setupState ~= "running" then + return + end + local operation = readOperation() + if operation == nil or operation.state == "starting" or operation.state == "running" then + return + end + if operation.state == "ready" then + verifySetup() + elseif operation.state == "error" then + setSetupState( + "error", type(operation.message) == "string" and operation.message or noctalia.tr("service.setup_failed") + ) + end +end + +local function clearCache() + if activeJob ~= nil or cacheOperation or setupState == "running" then + return + end + cacheOperation = true + pending = {} + queued = {} + for outputName, row in pairs(outputs) do + noctalia.setWallpaperMask(outputName, nil) + row.state = row.wallpaperPath ~= nil and "waiting" or "no_wallpaper" + row.message = "" + end + publish() + local accepted = noctalia.runAsync({ + "python3", + helperPath, + "--data-dir", + dataDir, + "clear-cache", + }, function(result) + cacheOperation = false + if result.exitCode ~= 0 or result.timedOut then + setupMessage = shortError(result, noctalia.tr("service.clear_cache_failed")) + else + setupMessage = "" + end + publish() + local params = parameters() + if params ~= nil and params.autoGenerate then + enqueueAll(false) + end + end, 60000) + if not accepted then + cacheOperation = false + setupMessage = noctalia.tr("service.start_clear_cache_failed") + publish() + end +end + +local function syncOutputs() + local params, parameterError = parameters() + if params == nil then + setupMessage = parameterError + publish() + return + end + local current = {} + for _, output in ipairs(noctalia.outputs()) do + current[output.name] = true + local wallpaperPath = noctalia.wallpaperPath(output.name) + if wallpaperPath ~= nil and not noctalia.fileExists(wallpaperPath) then + wallpaperPath = nil + end + local row = outputs[output.name] + if row == nil then + row = { name = output.name, state = "no_wallpaper", message = "" } + outputs[output.name] = row + end + if row.wallpaperPath ~= wallpaperPath then + noctalia.setWallpaperMask(output.name, nil) + row.wallpaperPath = wallpaperPath + row.state = wallpaperPath ~= nil and "waiting" or "no_wallpaper" + row.message = "" + row.cacheHit = nil + row.elapsedMs = nil + queued[output.name] = nil + if params.autoGenerate and wallpaperPath ~= nil then + enqueue(output.name, wallpaperPath, params) + end + end + end + for outputName, _ in pairs(outputs) do + if current[outputName] ~= true then + outputs[outputName] = nil + queued[outputName] = nil + end + end + + if lastParametersKey ~= nil and lastParametersKey ~= params.key then + for outputName, row in pairs(outputs) do + noctalia.setWallpaperMask(outputName, nil) + row.state = row.wallpaperPath ~= nil and "waiting" or "no_wallpaper" + row.message = "" + end + pending = {} + queued = {} + if params.autoGenerate then + enqueueAll(false) + end + end + if lastAutoGenerate == false and params.autoGenerate then + for outputName, row in pairs(outputs) do + if row.state == "waiting" and row.wallpaperPath ~= nil then + enqueue(outputName, row.wallpaperPath, params) + end + end + end + lastParametersKey = params.key + lastAutoGenerate = params.autoGenerate + publish() + runNext() +end + +noctalia.state.watch("wallpaper_depth.command", function(command) + if type(command) ~= "table" then + return + end + noctalia.state.set("wallpaper_depth.command", nil) + if command.action == "setup" then + startSetup() + elseif command.action == "generate" then + enqueueAll(true) + elseif command.action == "clear_cache" then + clearCache() + end +end) + +function onIpc(event, _payload) + if event == "setup" then + startSetup() + elseif event == "generate" then + enqueueAll(true) + elseif event == "clear-cache" then + clearCache() + end +end + +function onConfigChanged() + syncOutputs() +end + +function update() + pollSetup() + syncOutputs() +end + +local operation = readOperation() +if operation ~= nil and (operation.state == "running" or operation.state == "starting") then + setupState = "running" + setupMessage = noctalia.tr("service.setup_running") +else + verifySetup() +end +syncOutputs() diff --git a/wallpaper_depth_large/thumbnail.webp b/wallpaper_depth_large/thumbnail.webp new file mode 100644 index 0000000..b0db42e Binary files /dev/null and b/wallpaper_depth_large/thumbnail.webp differ diff --git a/wallpaper_depth_large/translations/en.json b/wallpaper_depth_large/translations/en.json new file mode 100644 index 0000000..185b3da --- /dev/null +++ b/wallpaper_depth_large/translations/en.json @@ -0,0 +1,69 @@ +{ + "title": "Wallpaper Depth", + "panel": { + "description": "Generate a foreground depth mask for each wallpaper so desktop widgets can pass behind nearby scenery.", + "setup": { + "checking": "Checking local model", + "ready": "Model ready", + "running": "Setting up depth model", + "missing": "Setup required", + "error": "Setup failed", + "prerequisites": "Install the required commands first: {commands}.", + "offline": "Shell offline mode blocks setup downloads.", + "disclosure": "Setup creates a private Python environment and downloads the 99 MB Apache-2.0 Depth Anything V2 Small model from Hugging Face. No wallpaper is uploaded.", + "install": "Install model", + "retry": "Retry setup" + }, + "parameters": "Mask parameters", + "settings": "Settings", + "threshold": "Foreground threshold", + "feather": "Edge feather", + "automatic": "Automatic", + "on": "On", + "off": "Off", + "outputs": "Outputs", + "no_outputs": "No connected outputs.", + "output": { + "ready": "Mask applied", + "ready_time": "Mask applied in {seconds} s", + "cached": "cached depth", + "processing": "Estimating depth", + "waiting": "Waiting to generate", + "error": "Generation failed", + "no_wallpaper": "No image wallpaper" + }, + "generate": "Generate masks", + "clear_cache": "Clear cache" + }, + "service": { + "invalid_settings": "Plugin settings are invalid.", + "generation_failed": "Depth generation failed.", + "invalid_result": "The depth helper returned an invalid result.", + "start_helper_failed": "Could not start the depth helper.", + "python_missing": "python3 is not installed.", + "validation_failed": "Setup validation failed.", + "setup_missing": "The model and runtime are not installed.", + "start_validation_failed": "Could not start setup validation.", + "offline": "Disable shell offline mode before setup.", + "busy": "Wait for the current operation to finish.", + "setup_running": "Installing the runtime and downloading the 99 MB model.", + "start_setup_failed": "Could not start setup.", + "setup_failed": "Setup failed.", + "clear_cache_failed": "Could not clear the cache.", + "start_clear_cache_failed": "Could not start cache cleanup." + }, + "settings": { + "auto_generate": { + "label": "Generate automatically", + "description": "Regenerate the output mask when its wallpaper or mask parameters change." + }, + "threshold": { + "label": "Foreground threshold", + "description": "Normalized depth above which the wallpaper erases desktop widgets. Lower values place more of the scene in front." + }, + "feather": { + "label": "Edge feather", + "description": "Width of the soft transition around the depth threshold." + } + } +}