diff --git a/.github/workflows/windows_release.yml b/.github/workflows/windows_release.yml index bcecc60..b169de3 100644 --- a/.github/workflows/windows_release.yml +++ b/.github/workflows/windows_release.yml @@ -1,7 +1,8 @@ name: Windows Release # Continuous Windows portable build on every push to main. -# Uses GitHub-hosted windows-latest (MSVC preinstalled). +# Uses GitHub-hosted windows-latest (MSVC) as a *private* builder. +# Public download: Forgejo release tag windows-continuous. # See docs/windows-runner.md on: @@ -31,6 +32,9 @@ jobs: with: python-version: "3.12" + - name: Setup Ninja + uses: seanmiddleditch/gha-setup-ninja@v5 + - name: Install Conan shell: pwsh run: | @@ -44,6 +48,8 @@ jobs: $msvcVer = ($verLine.Line -split '=')[1].Trim() if (-not $msvcVer) { throw "could not read compiler.version from default profile" } # Host profile matching the runner (repo win-x86_64 still pins 194 for local Tower). + # Force Ninja: recipes that tool_require cmake<4 cannot use "Visual Studio 18 2026" + # (needs CMake >= 4.2). Ninja works with older CMake + MSVC 195. $p = Join-Path $env:USERPROFILE ".conan2\profiles\win-ci" @" [settings] @@ -54,9 +60,13 @@ jobs: compiler.runtime=dynamic compiler.version=$msvcVer os=Windows + + [conf] + tools.cmake.cmaketoolchain:generator=Ninja "@.Trim() | Set-Content -Path $p -Encoding ascii - Write-Host "win-ci MSVC version=$msvcVer" + Write-Host "win-ci MSVC version=$msvcVer (generator=Ninja)" Get-Content $p + ninja --version - name: Resolve version id: ver @@ -81,8 +91,14 @@ jobs: if errorlevel 1 exit /b 1 conan install . -of=build-x86_64 --build=missing -pr:h=win-ci -pr:b=default if errorlevel 1 exit /b 1 - call windowsinstall\build_x86_64.bat + if not exist build-x86_64\build mkdir build-x86_64\build + cd build-x86_64\build + call .\generators\conanbuild.bat + cmake ..\.. -G Ninja -DCMAKE_TOOLCHAIN_FILE="generators\conan_toolchain.cmake" -DCMAKE_BUILD_TYPE=Release -DCONFIG_NEXT_TO_EXECUTABLE=OFF if errorlevel 1 exit /b 1 + cmake --build . --config Release + if errorlevel 1 exit /b 1 + call .\generators\deactivate_conanbuild.bat - name: Package portable zip id: pkg @@ -92,9 +108,9 @@ jobs: run: | $ErrorActionPreference = "Stop" $exe = Get-ChildItem -Path . -Recurse -Filter infinipaint.exe | - Where-Object { $_.FullName -match 'Release' } | + Where-Object { $_.FullName -match 'build-x86_64|Release' } | Select-Object -First 1 - if (-not $exe) { throw "infinipaint.exe not found under Release" } + if (-not $exe) { throw "infinipaint.exe not found under build-x86_64/Release" } $outDir = Join-Path (Get-Location) "dist\portable" Remove-Item -Recurse -Force $outDir -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $outDir | Out-Null @@ -106,6 +122,14 @@ jobs: $dataSrc = Join-Path (Get-Location) "assets\data" if (-not (Test-Path $dataSrc)) { $dataSrc = Join-Path (Get-Location) "data" } Copy-Item -Recurse $dataSrc (Join-Path $outDir "data") + $rembgSrc = Join-Path (Get-Location) "third_party\rembg" + if (Test-Path $rembgSrc) { + Copy-Item -Recurse $rembgSrc (Join-Path $outDir "rembg") + } + $modelsSrc = Join-Path (Get-Location) "third_party\rembg-home" + if (Test-Path $modelsSrc) { + Copy-Item -Recurse $modelsSrc (Join-Path $outDir "rembg-home") + } New-Item -ItemType Directory -Force -Path "dist" | Out-Null $zipPath = Join-Path (Get-Location) "dist\$env:ZIP_NAME" if (Test-Path $zipPath) { Remove-Item $zipPath } @@ -113,19 +137,110 @@ jobs: "zip_path=$zipPath" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 Get-Item $zipPath | Format-List FullName, Length - - name: Publish GitHub release + # Optional private artifact on GitHub (repo is private builder-only). + - name: Publish private GitHub release uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.ver.outputs.tag }} name: Windows continuous (${{ steps.ver.outputs.asset_ver }}) prerelease: true body: | - Continuous Windows build from `main`. + Continuous Windows build from `main` (private GitHub builder). + + **Public download:** Forgejo release `windows-continuous` + https://forgejo.fsociety00.cc/copilot/infinipaint/releases/tag/windows-continuous - Version: **${{ steps.ver.outputs.version }}** - Commit: `${{ github.sha }}` - Asset: `${{ steps.ver.outputs.zip_name }}` - - Updated on every push to `main`. Built on GitHub-hosted `windows-latest`. files: dist/${{ steps.ver.outputs.zip_name }} fail_on_unmatched_files: true + + - name: Publish Forgejo release (public) + shell: bash + env: + FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} + VERSION_TAG: ${{ steps.ver.outputs.tag }} + ASSET_VER: ${{ steps.ver.outputs.asset_ver }} + VERSION: ${{ steps.ver.outputs.version }} + ZIP_NAME: ${{ steps.ver.outputs.zip_name }} + FORGEJO_API: https://forgejo.fsociety00.cc/api/v1 + FORGEJO_REPO: copilot/infinipaint + run: | + set -euo pipefail + if [ -z "${FORGEJO_TOKEN:-}" ]; then + echo "FORGEJO_TOKEN secret missing — cannot publish public Forgejo release" >&2 + exit 1 + fi + zip_path="dist/${ZIP_NAME}" + test -f "$zip_path" + python3 - <<'PY' + import json, os, ssl, urllib.error, urllib.parse, urllib.request + + api = os.environ["FORGEJO_API"].rstrip("/") + repo = os.environ["FORGEJO_REPO"] + token = os.environ["FORGEJO_TOKEN"] + tag = os.environ["VERSION_TAG"] + name = f"Windows continuous ({os.environ['ASSET_VER']})" + zip_name = os.environ["ZIP_NAME"] + zip_path = f"dist/{zip_name}" + body = "\n".join([ + "Continuous Windows build from `main` (built on private GitHub Actions).", + "", + f"- Version: **{os.environ['VERSION']}**", + f"- Commit: `{os.environ['GITHUB_SHA']}`", + f"- Asset: `{zip_name}`", + "", + "Updated on every push to `main`.", + ]) + ctx = ssl.create_default_context() + if os.environ.get("FORGEJO_CURL_INSECURE", "").lower() in ("1", "true"): + ctx = ssl._create_unverified_context() + + def req(method, url, data=None, headers=None, raw=False, allow_404=False): + h = {"Authorization": f"token {token}"} + if headers: + h.update(headers) + body_data = data + if data is not None and not raw: + body_data = json.dumps(data).encode() + h.setdefault("Content-Type", "application/json") + r = urllib.request.Request(url, data=body_data, headers=h, method=method) + try: + with urllib.request.urlopen(r, context=ctx) as resp: + raw_body = resp.read() + return resp.status, (json.loads(raw_body) if raw_body else {}) + except urllib.error.HTTPError as e: + err = e.read().decode(errors="replace") + if allow_404 and e.code == 404: + return 404, {} + raise SystemExit(f"{method} {url} -> {e.code}: {err}") from e + + base = f"{api}/repos/{repo}/releases" + status, release = req("GET", f"{base}/tags/{urllib.parse.quote(tag)}", allow_404=True) + payload = { + "tag_name": tag, + "name": name, + "body": body, + "draft": False, + "prerelease": True, + } + if status == 404: + _, release = req("POST", base, payload) + else: + release_id = release["id"] + _, release = req("PATCH", f"{base}/{release_id}", payload) + for asset in release.get("assets") or []: + if asset.get("name") == zip_name: + req("DELETE", f"{base}/{release_id}/assets/{asset['id']}") + + release_id = release["id"] + with open(zip_path, "rb") as f: + data = f.read() + upload_url = f"{base}/{release_id}/assets?name={urllib.parse.quote(zip_name)}" + _, asset = req( + "POST", upload_url, data=data, raw=True, + headers={"Content-Type": "application/octet-stream"}, + ) + print(asset.get("browser_download_url") or asset.get("name") or asset) + PY diff --git a/CMakeLists.txt b/CMakeLists.txt index b204aa5..0a79194 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -105,6 +105,7 @@ set(sources "src/main.cpp" "src/DrawingProgram/Layers/DrawingProgramLayerListItem.cpp" "src/DrawingProgram/Layers/DrawingProgramLayerFolder.cpp" "src/DrawingProgram/Layers/DrawingProgramLayer.cpp" + "src/DrawingProgram/Layers/FogStripper.cpp" "src/DrawingProgram/Layers/SerializedBlendMode.cpp" "src/CanvasComponents/CanvasComponentContainer.cpp" "src/CanvasComponents/CanvasComponentAllocator.cpp" @@ -222,7 +223,7 @@ else() find_package(clipper2 REQUIRED) endif() -find_package(ICU 77.1 REQUIRED COMPONENTS uc) +find_package(ICU REQUIRED COMPONENTS uc) if(NOT EMSCRIPTEN) if((NOT ANDROID) AND (NOT LINUX)) @@ -423,7 +424,11 @@ else() else() target_link_libraries(main sdl-infinipaint::sdl-infinipaint) endif() - target_link_libraries(main icu-infinipaint::icu-infinipaint) + if(LINUX) + target_link_libraries(main ICU::uc) + else() + target_link_libraries(main icu-infinipaint::icu-infinipaint) + endif() endif() if(NOT EMSCRIPTEN) diff --git a/FORK.md b/FORK.md index 2e44529..efa2328 100644 --- a/FORK.md +++ b/FORK.md @@ -1,21 +1,20 @@ # Fork features (copilot/infinipaint) Upstream: [ErrorAtLine0/infinipaint](https://github.com/ErrorAtLine0/infinipaint) -Forgejo: https://forgejo.fsociety00.cc/copilot/infinipaint (public) -GitHub (CI / Windows zip): https://github.com/littlecodedragon/infinipaint +**Public forge:** https://forgejo.fsociety00.cc/copilot/infinipaint +GitHub (`littlecodedragon/infinipaint`) is **private builder only** — not a public fork mirror. Source of truth stays on Forgejo. -Save format: `INFPNT000007` / version `0.7.0-fork` (not network-compatible with stock 0.6.x peers). +Save format: `INFPNT000008` / version `0.8.0-fork` (not network-compatible with stock 0.6.x peers). ## CI | Workflow | Trigger | Runner | Output | |----------|---------|--------|--------| -| `.github/workflows/windows_release.yml` | every push to `main` | GitHub-hosted **`windows-latest`** | prerelease tag `windows-continuous` | +| `.github/workflows/windows_release.yml` | push `main` to private GitHub mirror | GitHub-hosted **`windows-latest`** | uploads public Forgejo tag `windows-continuous` | | `.forgejo/workflows/windows_release.yml` | every push to `main` | Forgejo self-hosted `windows-release` (optional) | same tag on Forgejo | -Primary path: GitHub Actions — see [docs/windows-runner.md](docs/windows-runner.md). -Download: https://github.com/littlecodedragon/infinipaint/releases/tag/windows-continuous - +**Friends (Windows):** https://forgejo.fsociety00.cc/copilot/infinipaint/releases/tag/windows-continuous — zip entpacken, `infinipaint.exe` starten. +Details: [docs/windows-runner.md](docs/windows-runner.md). ## Bring Everyone Here (host) In the player list, the lobby host gets **Bring Everyone Here**. Clients receive `CLIENT_FORCE_CAMERA_JUMP` and run the same `smooth_move_to` path as **Jump To**. @@ -26,8 +25,12 @@ With a multi-selection (images, brush strokes, text boxes, shapes, …), use **G ## Image compression -Settings → **Images**: compress on insert (WebP/PNG/Keep). Selection: **Compress Images**. +Settings → **Images**: compress on insert (WebP/PNG/Keep). **Compress all images on canvas** rewrites every already-placed raster (same format/quality; Keep uses WebP). Selection panel: **Compress Images** for a subset. ## Freistellen -Settings → **Images**: white-key and optional rembg (Tower `silueta` / `bria-rmbg`). Selection: **Freistellen**. +Not automatic on import. Choose **Paste Image + Freistellen** from the canvas right-click menu, or **Ctrl+Shift+V** then pick Freistellen vs as-is. Right-click an already placed image (or use the selection panel / image properties) to Freistellen later. Settings → **Images** only pick the method: white-key always; **Schnell (silueta)** / **Allgemein (bria)** when rembg CLI and that model exist (bundled `rembg/` + `rembg-home/models/`, Tower `~/.local/share/tower-rembg`, or PATH). Otherwise those radios stay greyed; white-key still works. Linux install runs `linuxinstall/bundle-rembg.sh`. + +## Fog of War / layer lock (host) + +In the layer panel (host / local file only): mark a leaf layer as **Fog of War layer** (must sit **above** the content it covers), then paint fog with brush/shapes on that layer. Players receive a **stripped** world — only objects **fully** covered by opaque fog (not merely overlapping) are omitted from join sync and from their save. Partially covered images stay. Moving or reshaping fog (or content under it) live-updates clients: newly covered objects are unloaded, revealed ones are sent again with their resources. The host file stays complete. Fog draws at the layer’s real alpha (1.0 = fully opaque for host and players). **Lock for players** blocks client edits on a normal layer; fog layers are always locked for players (move/edit/erase rejected on the host). diff --git a/README.md b/README.md index b65612f..c5be05d 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ InfiniPaint is a **collaborative, infinite** canvas note-taking/drawing app. Unlike many other infinite canvas apps, **there is no zoom-in or zoom-out limit** (at least up until the point your computer runs out of memory). This means that this app is very good at things such as drawing sketches of the solar system to scale, or just drawing any massive objects with tiny details. Of course, even though this is a feature, this app is also perfectly well suited for use as a normal canvas. -> **This repository is a fork** — Forgejo: [copilot/infinipaint](https://forgejo.fsociety00.cc/copilot/infinipaint), GitHub (Windows CI): [littlecodedragon/infinipaint](https://github.com/littlecodedragon/infinipaint). Extra features are documented in [FORK.md](FORK.md). +> **This repository is a fork** — public source: [copilot/infinipaint](https://forgejo.fsociety00.cc/copilot/infinipaint) on Forgejo. Extra features: [FORK.md](FORK.md). (A private GitHub mirror may exist only for Windows CI — not a public download site.) You can try the web version of this app at [infinipaint.com](https://infinipaint.com) (requires a WebGL2 capable browser). The web version was compiled from C++ to Javascript using Emscripten, and may contain some bugs, so if possible, please consider downloading the native version for a better experience. diff --git a/conan/conan-skia/recipes/skia/all/conanfile.py b/conan/conan-skia/recipes/skia/all/conanfile.py index 138460a..b66fa11 100644 --- a/conan/conan-skia/recipes/skia/all/conanfile.py +++ b/conan/conan-skia/recipes/skia/all/conanfile.py @@ -509,8 +509,58 @@ class ConanSkia(ConanFile): return [fw + ".framework" for fw in frameworks] def build(self): + # Optional: reuse a finished out/conan from a prior local build + # (avoids re-hitting googlesource + full ninja). Path = .../out/conan + reuse_out = os.environ.get("SKIA_REUSE_OUT") + if reuse_out and os.path.isdir(reuse_out): + import shutil + dst = join(self.source_folder, "out", "conan") + os.makedirs(join(self.source_folder, "out"), exist_ok=True) + if os.path.isdir(dst): + shutil.rmtree(dst) + shutil.copytree(reuse_out, dst) + return + # activate-emsdk fails on Windows for some reason. os.environ["GIT_SYNC_DEPS_SKIP_EMSDK"] = "1" + # Default git-sync-deps fans out one thread per dep and hits + # googlesource HTTP 429. Serialize + retry before syncing. + replace_in_file( + self, + join(self.source_folder, "tools", "git-sync-deps"), + """def multithread(function, list_of_arg_lists): + anything_failed = False + threads = [] + def hook(args): + nonlocal anything_failed + anything_failed = True + threading.excepthook = hook + for args in list_of_arg_lists: + thread = threading.Thread(None, function, None, args) + thread.start() + threads.append(thread) + for thread in threads: + thread.join() + if anything_failed: + raise Exception("Thread failure detected") +""", + """def multithread(function, list_of_arg_lists): + import time + for args in list_of_arg_lists: + last_err = None + for attempt in range(6): + try: + function(*args) + last_err = None + break + except Exception as e: + last_err = e + time.sleep(min(60, 2 ** attempt)) + if last_err is not None: + raise last_err +""", + strict=True, + ) self.run("python3 tools/git-sync-deps") del os.environ["GIT_SYNC_DEPS_SKIP_EMSDK"] @@ -585,9 +635,21 @@ class ConanSkia(ConanFile): buildenv = VirtualBuildEnv(self).vars() - if cc := buildenv.get("CC", default=None): + cc = buildenv.get("CC", default=None) + cxx = buildenv.get("CXX", default=None) + # Fall back to tools.build:compiler_executables when VirtualBuildEnv + # did not inject CC/CXX (common with only profile [conf]). + try: + executables = self.conf.get("tools.build:compiler_executables", default={}) or {} + except Exception: + executables = {} + if not cc: + cc = executables.get("c") + if not cxx: + cxx = executables.get("cpp") + if cc: args += f"cc=\"{cc}\"\n" - if cxx := buildenv.get("CXX", default=None): + if cxx: args += f"cxx=\"{cxx}\"\n" if ar := buildenv.get("AR", default=None): args += f"ar=\"{ar}\"\n" diff --git a/conan/icu/all/conanfile.py b/conan/icu/all/conanfile.py index ec38251..7a197d2 100644 --- a/conan/icu/all/conanfile.py +++ b/conan/icu/all/conanfile.py @@ -216,8 +216,34 @@ class ICUConan(ConanFile): if dat_package_file: shutil.copy(str(self.options.dat_package_file), dat_package_file[0]) + # GCC 16 ICEs compiling ICU 77 headers under -std=gnu++23 (enum in uchar.h). + # Keep the package usable from a C++23 consumer, but build ICU itself as C++17. + if self.settings.os == "Linux": + import os as _os + for var in ("CXXFLAGS", "CFLAGS"): + cur = _os.environ.get(var, "") + parts = [p for p in cur.split() if not p.startswith("-std=") and not p.startswith("-O")] + if var == "CXXFLAGS": + parts.extend(["-std=gnu++17", "-O1"]) + else: + parts.append("-O1") + _os.environ[var] = " ".join(parts) + autotools = Autotools(self) autotools.configure(build_script_folder=os.path.join(self.source_folder, "source")) + + if self.settings.os == "Linux": + for root, _, files in os.walk(self.build_folder): + for name in files: + if name in ("Makefile", "mh-linux") or name.endswith(".mk"): + path = os.path.join(root, name) + try: + replace_in_file(self, path, "-O3", "-O1", strict=False) + replace_in_file(self, path, "-std=gnu++23", "-std=gnu++17", strict=False) + replace_in_file(self, path, "-std=c++23", "-std=c++17", strict=False) + except Exception: + pass + autotools.make() @property diff --git a/conan/profiles/linux-x86_64-clang b/conan/profiles/linux-x86_64-clang new file mode 100644 index 0000000..c5d77b3 --- /dev/null +++ b/conan/profiles/linux-x86_64-clang @@ -0,0 +1,14 @@ +[settings] +arch=x86_64 +build_type=Release +compiler=clang +compiler.cppstd=gnu23 +compiler.libcxx=libstdc++11 +compiler.version=22 +os=Linux + +[tool_requires] +cmake/3.27.0 + +[conf] +tools.build:compiler_executables={'c':'clang','cpp':'clang++'} diff --git a/conan/profiles/linux-x86_64-gcc15 b/conan/profiles/linux-x86_64-gcc15 new file mode 100644 index 0000000..eefb129 --- /dev/null +++ b/conan/profiles/linux-x86_64-gcc15 @@ -0,0 +1,21 @@ +[settings] +arch=x86_64 +build_type=Release +compiler=gcc +compiler.cppstd=gnu23 +compiler.libcxx=libstdc++11 +compiler.version=15 +os=Linux + +[tool_requires] +cmake/3.27.0 + +[buildenv] +CC=/home/yuna/.local/share/gcc15-root/usr/bin/gcc-15 +CXX=/home/yuna/.local/share/gcc15-root/usr/bin/g++-15 + +[conf] +tools.build:compiler_executables={'c':'/home/yuna/.local/share/gcc15-root/usr/bin/gcc-15','cpp':'/home/yuna/.local/share/gcc15-root/usr/bin/g++-15'} +tools.build:jobs=2 +tools.build:cflags=["-O2"] +tools.build:cxxflags=["-O2"] diff --git a/conanfile.py b/conanfile.py index 31126cc..0d360a3 100644 --- a/conanfile.py +++ b/conanfile.py @@ -95,7 +95,8 @@ class CompressorRecipe(ConanFile): "use_system_harfbuzz": True, "use_conan_harfbuzz": True, "use_system_icu": True, - "use_conan_icu": True, + # Real distro ICU — Conan ICU 77 ICEs on GCC 16 / Clang 22. + "use_conan_icu": False, "use_system_libjpeg_turbo": False, "use_system_libpng": False, "use_system_libwebp": False, @@ -129,7 +130,9 @@ class CompressorRecipe(ConanFile): self.requires("clipper2/2.0.1") self.requires("zstd/1.5.7") - self.requires("icu-infinipaint/77.1") + # Skip Conan ICU on Linux (use system ICU via CMake find_package). + if self.settings.os != "Linux": + self.requires("icu-infinipaint/77.1") def build_requirements(self): self.tool_requires("cmake/3.27.0") diff --git a/docs/windows-runner.md b/docs/windows-runner.md index 3e6e551..d071a10 100644 --- a/docs/windows-runner.md +++ b/docs/windows-runner.md @@ -1,10 +1,16 @@ -# Windows continuous release (GitHub Actions) +# Windows continuous release (GitHub Actions as private builder) Native InfiniPaint Windows builds need **MSVC** (see `docs/BUILDING.md` + `windowsinstall/`). There is no Linux cross-build path. -The fork publishes a portable zip from a **GitHub** repository using the -GitHub-hosted runner `windows-latest` (Visual Studio / MSVC preinstalled). +Policy: **Forgejo is the public fork**; GitHub is only a **private** mirror used +to run `windows-latest` CI. Friends download the zip from Forgejo — not GitHub. + +## Public download (friends) + +https://forgejo.fsociety00.cc/copilot/infinipaint/releases/tag/windows-continuous + +Portable zip → extract → run `infinipaint.exe`. ## Workflow @@ -12,30 +18,30 @@ File: `.github/workflows/windows_release.yml` | | | |---|---| -| Trigger | every push to `main`, plus `workflow_dispatch` | -| Runner | `windows-latest` (GitHub-hosted, MSVC ~195) | -| Build | Conan profile `win-ci` (detected) + `windowsinstall/build_x86_64.bat` | -| Output | prerelease tag **`windows-continuous`** with a portable zip | +| Trigger | every push to `main` on the private GitHub mirror, plus `workflow_dispatch` | +| Runner | `windows-latest` (GitHub-hosted, MSVC ~195 / VS 18) | +| Build | Conan profile `win-ci` + **Ninja** (avoids CMake<4 vs `Visual Studio 18 2026`) | +| Output | public Forgejo prerelease **`windows-continuous`** (+ private GitHub copy) | + +Required GitHub Actions secret: `FORGEJO_TOKEN` (Forgejo PAT with release write on +`copilot/infinipaint`). Local Tower Windows can keep using `windowsinstall/conan_init_x86_64.bat` (`conan/profiles/win-x86_64`, MSVC 194). CI generates a `win-ci` profile for whatever MSVC `windows-latest` ships. -## GitHub remote - -Recommended remote name: `github` +## GitHub remote (private builder) ```bash git remote add github https://github.com/littlecodedragon/infinipaint.git git push -u github main ``` -Releases appear under: -https://github.com/littlecodedragon/infinipaint/releases/tag/windows-continuous +Keep the repo **private** (Settings → Change visibility, or +`gh repo edit littlecodedragon/infinipaint --visibility private`). ## Forgejo note -`.forgejo/workflows/windows_release.yml` still targets a Forgejo self-hosted -label `windows-release` (Tower dual-boot). Prefer the GitHub workflow above for -unattended continuous Windows zips; keep Forgejo for the public forge mirror if -desired. +`.forgejo/workflows/windows_release.yml` still targets a self-hosted +`windows-release` runner (optional). Primary public zips come from the GitHub +builder → Forgejo release upload above. diff --git a/linuxinstall/bundle-rembg.sh b/linuxinstall/bundle-rembg.sh new file mode 100755 index 0000000..8c44330 --- /dev/null +++ b/linuxinstall/bundle-rembg.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Place rembg CLI + models next to an InfiniPaint install so Schnell/Allgemein +# work without a global PATH dependency. Missing tools are skipped; the UI greys out. +set -euo pipefail +DEST="${1:?usage: bundle-rembg.sh DEST_DIR}" +mkdir -p "$DEST" + +TOWER="${TOWER_REMBG_VENV:-$HOME/.local/share/tower-rembg}" +MODELS_SRC="${REMBG_HOME:-$HOME/.rembg}" + +if [[ -x "$TOWER/bin/rembg" ]]; then + ln -sfn "$TOWER" "$DEST/rembg" + echo "Linked rembg CLI: $DEST/rembg -> $TOWER" +elif [[ -x "$TOWER/Scripts/rembg.exe" ]]; then + ln -sfn "$TOWER" "$DEST/rembg" + echo "Linked rembg CLI: $DEST/rembg -> $TOWER" +else + echo "No tower-rembg venv at $TOWER — Schnell/Allgemein will be unavailable unless rembg is on PATH." +fi + +if [[ -d "$MODELS_SRC/models" ]]; then + mkdir -p "$DEST/rembg-home" + ln -sfn "$MODELS_SRC/models" "$DEST/rembg-home/models" + echo "Linked rembg models: $DEST/rembg-home/models -> $MODELS_SRC/models" +else + echo "No rembg models at $MODELS_SRC/models — download with: rembg d silueta && rembg d bria-rmbg" +fi diff --git a/linuxinstall/install-local.sh b/linuxinstall/install-local.sh index 36f51ac..6b1b91c 100644 --- a/linuxinstall/install-local.sh +++ b/linuxinstall/install-local.sh @@ -14,6 +14,11 @@ rm -rf "${APPDIR}/data" cp -a "${ROOT}/assets/data" "${APPDIR}/data" ln -sfn "${APPDIR}/infinipaint" "${PREFIX}/bin/infinipaint" +# Optional rembg next to the app (silueta / bria). Missing = greyed-out modes in Settings. +if [[ -x "${ROOT}/linuxinstall/bundle-rembg.sh" ]]; then + bash "${ROOT}/linuxinstall/bundle-rembg.sh" "$APPDIR" || true +fi + cat > "${PREFIX}/share/applications/com.infinipaint.infinipaint-fork.desktop" < #include "CanvasComponent.hpp" @@ -45,6 +46,13 @@ void CanvasComponentAllocator::register_class(World& world) { a(*o.comp); }, .readUpdate = [](CanvasComponentAllocator& o, cereal::PortableBinaryInputArchive& a, const std::shared_ptr& c) { + // Host rejects client edits of fog/locked-layer components (still consume the payload). + if(c && o.comp && o.comp->compContainer && o.comp->compContainer->parentLayer + && o.comp->compContainer->parentLayer->is_player_edit_blocked()) { + auto junk = std::unique_ptr(CanvasComponent::allocate_comp(o.comp->get_type())); + a(*junk); + return; + } a(*o.comp); }, .allocateCopy = [](const CanvasComponentAllocator& o) { @@ -58,6 +66,8 @@ void CanvasComponentAllocator::register_class(World& world) { }, .postUpdateFunc = [&drawP = world.drawProg](CanvasComponentAllocator& o) { o.comp->compContainer->commit_update(drawP); + // Fog mesh reshape / content edit can change who is fully covered. + drawP.layerMan.mark_fog_client_projection_dirty(); } }); } diff --git a/src/CustomEvents.hpp b/src/CustomEvents.hpp index 1456ceb..e682c07 100644 --- a/src/CustomEvents.hpp +++ b/src/CustomEvents.hpp @@ -65,6 +65,7 @@ namespace CustomEvents { std::string data; std::optional richText; std::optional mousePos; + bool applyFreistellen = false; }; struct OpenInfiniPaintFileEvent { diff --git a/src/DrawingProgram/DrawingProgram.cpp b/src/DrawingProgram/DrawingProgram.cpp index 1850f92..97fa77b 100644 --- a/src/DrawingProgram/DrawingProgram.cpp +++ b/src/DrawingProgram/DrawingProgram.cpp @@ -51,6 +51,7 @@ #include "../ImagePreprocess.hpp" #include #include +#include #ifdef __ANDROID__ #include "../AndroidJNICalls.hpp" #endif @@ -112,6 +113,8 @@ void DrawingProgram::input_drop_text_callback(const InputManager::DropCallbackAr img.d.p2 = drop.pos + imDim; img.d.imageID = {0, 0}; auto newObjInfo = layerMan.add_component_to_layer_being_edited(newContainer); + if(!newObjInfo) + return; layerMan.add_undo_place_component(newObjInfo); droppedDownloadingFiles.emplace_back(newObjInfo, world.main.window.size.cast(), FileDownloader::download_data_from_url(drop.data)); } @@ -338,9 +341,29 @@ void DrawingProgram::init_server_callbacks() { for(auto& [netID, coord] : transforms) coord.scale_about(WorldVec{0, 0}, scaleUpAmount, true); } - - process_transform_message(transforms); - world.netServer->send_items_to_all_clients(RELIABLE_COMMAND_CHANNEL, CLIENT_TRANSFORM_MANY_COMPONENTS, transforms); + + std::vector> allowed; + std::vector> rejectedRestore; + allowed.reserve(transforms.size()); + for(auto& [netID, coord] : transforms) { + auto objPtr = world.netObjMan.get_obj_temporary_ref_from_id(netID); + if(!objPtr) + continue; + if(!layerMan.client_may_modify_component(objPtr.get())) { + rejectedRestore.emplace_back(netID, objPtr->coords); + continue; + } + allowed.emplace_back(netID, coord); + } + if(!allowed.empty()) { + process_transform_message(allowed); + world.netServer->send_items_to_all_clients(RELIABLE_COMMAND_CHANNEL, CLIENT_TRANSFORM_MANY_COMPONENTS, allowed); + // Moving content or fog changes coverage — unload/reload on clients. + layerMan.refresh_fog_client_projection(); + } + // Snap the offending client (and peers) back if they tried to move fog/locked objects. + if(!rejectedRestore.empty()) + world.netServer->send_items_to_all_clients(RELIABLE_COMMAND_CHANNEL, CLIENT_TRANSFORM_MANY_COMPONENTS, rejectedRestore); }); world.netServer->add_recv_callback(SERVER_FORCE_CAMERA_JUMP, [&](std::shared_ptr client, cereal::PortableBinaryInputArchive& message) { CoordSpaceHelper coords; @@ -355,8 +378,18 @@ void DrawingProgram::init_server_callbacks() { std::vector> groupIds; message(groupIds); (void)client; - process_group_id_message(groupIds); - world.netServer->send_items_to_all_clients(RELIABLE_COMMAND_CHANNEL, CLIENT_SET_COMPONENT_GROUP_IDS, groupIds); + std::vector> allowed; + allowed.reserve(groupIds.size()); + for(auto& [netID, groupId] : groupIds) { + auto objPtr = world.netObjMan.get_obj_temporary_ref_from_id(netID); + if(!objPtr || !layerMan.client_may_modify_component(objPtr.get())) + continue; + allowed.emplace_back(netID, groupId); + } + if(allowed.empty()) + return; + process_group_id_message(allowed); + world.netServer->send_items_to_all_clients(RELIABLE_COMMAND_CHANNEL, CLIENT_SET_COMPONENT_GROUP_IDS, allowed); }); } @@ -430,10 +463,20 @@ void DrawingProgram::process_transform_message(const std::vector& objsToSendTransformsFor) { if(world.netObjMan.is_connected()) { std::vector> transforms; - for(auto& obj : objsToSendTransformsFor) + for(auto& obj : objsToSendTransformsFor) { + if(!obj || !obj->obj) + continue; + if(!world.netObjMan.is_server() && !layerMan.client_may_modify_component(obj->obj.get())) + continue; transforms.emplace_back(obj->obj.get_net_id(), obj->obj->coords); - if(world.netObjMan.is_server()) + } + if(transforms.empty()) + return; + if(world.netObjMan.is_server()) { + // Fog coords first, then coverage erase/reveal (same reliable channel order). world.netServer->send_items_to_all_clients(RELIABLE_COMMAND_CHANNEL, CLIENT_TRANSFORM_MANY_COMPONENTS, transforms); + layerMan.refresh_fog_client_projection(); + } else world.netClient->send_items_to_server(RELIABLE_COMMAND_CHANNEL, SERVER_TRANSFORM_MANY_COMPONENTS, transforms); } @@ -497,8 +540,12 @@ void DrawingProgram::toolbar_gui(Toolbar& t) { } void DrawingProgram::right_click_popup_gui(Toolbar& t) { - if(rightClickPopupLocation.has_value()) - drawTool->right_click_popup_gui(t, rightClickPopupLocation.value()); + if(rightClickPopupLocation.has_value()) { + if(showingImagePasteChooser) + image_paste_choice_menu(rightClickPopupLocation.value()); + else + drawTool->right_click_popup_gui(t, rightClickPopupLocation.value()); + } } void DrawingProgram::set_right_click_popup_location(const Vector2f& newLoc) { @@ -509,8 +556,9 @@ void DrawingProgram::set_right_click_popup_location(const Vector2f& newLoc) { } void DrawingProgram::clear_right_click_popup() { - if(rightClickPopupLocation.has_value()) { + if(rightClickPopupLocation.has_value() || showingImagePasteChooser) { rightClickPopupLocation = std::nullopt; + showingImagePasteChooser = false; world.main.g.gui.set_to_layout(); } } @@ -550,11 +598,43 @@ void DrawingProgram::selection_action_menu(Vector2f popupPos) { }); popup_menu_action_button("Paste Image", "Paste Image", [&, popupPos] { selection.deselect_all(); - world.main.input.call_paste(CustomEvents::PasteEvent::DataType::IMAGE, { - .pastePosition = popupPos * world.main.g.final_gui_scale() - }); + request_clipboard_image_paste(popupPos * world.main.g.final_gui_scale(), false); }); + popup_menu_action_button("Paste Image Freistellen", "Paste Image + Freistellen", [&, popupPos] { + selection.deselect_all(); + request_clipboard_image_paste(popupPos * world.main.g.final_gui_scale(), true); + }); + CanvasComponentContainer::ObjInfo* imageUnderCursor = nullptr; + { + Vector2f screenPos = popupPos * world.main.g.final_gui_scale(); + SkPath clickPath = SkPath::Rect(SkRect::MakeLTRB(screenPos.x() - 0.5f, screenPos.y() - 0.5f, screenPos.x() + 0.5f, screenPos.y() + 0.5f)); + auto* hit = selection.get_front_object_colliding_with_in_editing_layer(clickPath); + if(hit && hit->obj && hit->obj->get_comp().get_type() == CanvasComponentType::IMAGE) + imageUnderCursor = hit; + } + if(imageUnderCursor && !selection.is_something_selected()) { + popup_menu_action_button("Freistellen", "Freistellen", [&, imageUnderCursor] { + process_images_on({imageUnderCursor}, true, true); + }); + } if(selection.is_something_selected()) { + bool hasImage = false; + for(auto* c : selection.get_selection_as_set()) { + if(c && c->obj && c->obj->get_comp().get_type() == CanvasComponentType::IMAGE) { + hasImage = true; + break; + } + } + if(hasImage) { + popup_menu_action_button("Freistellen", "Freistellen", [&] { + process_selected_images(true, true); + }); + } + else if(imageUnderCursor) { + popup_menu_action_button("Freistellen", "Freistellen", [&, imageUnderCursor] { + process_images_on({imageUnderCursor}, true, true); + }); + } popup_menu_action_button("Copy", "Copy", [&] { selection.selection_to_clipboard(); }); @@ -575,6 +655,31 @@ void DrawingProgram::selection_action_menu(Vector2f popupPos) { }); } +void DrawingProgram::request_clipboard_image_paste(Vector2f screenPos, bool applyFreistellen) { + world.main.input.call_paste(CustomEvents::PasteEvent::DataType::IMAGE, { + .pastePosition = screenPos, + .applyFreistellen = applyFreistellen + }); +} + +void DrawingProgram::image_paste_choice_menu(Vector2f popupPos) { + using namespace GUIStuff; + using namespace ElementHelpers; + + GUIStuff::GUIManager& gui = world.main.g.gui; + right_click_action_menu(popupPos, [&] { + text_label_light(gui, "Paste image"); + popup_menu_action_button("Paste Image as-is", "Paste as-is", [&, popupPos] { + selection.deselect_all(); + request_clipboard_image_paste(popupPos * world.main.g.final_gui_scale(), false); + }); + popup_menu_action_button("Paste Image Freistellen", "Freistellen then paste", [&, popupPos] { + selection.deselect_all(); + request_clipboard_image_paste(popupPos * world.main.g.final_gui_scale(), true); + }); + }); +} + void DrawingProgram::right_click_action_menu(Vector2f popupPos, const std::function& innerContent) { using namespace GUIStuff; using namespace ElementHelpers; @@ -642,6 +747,9 @@ void DrawingProgram::update() { update_downloading_dropped_files(); check_updateable_components(); + // After tools/transforms this frame: hide/reveal client content under moved fog. + layerMan.flush_fog_client_projection_if_dirty(); + if(drawCache.check_rebuild_needed_from_framerate() || drawCache.should_rebuild()) rebuild_cache(); } @@ -791,7 +899,7 @@ void DrawingProgram::input_add_file_to_canvas_callback(const CustomEvents::AddFi } -void DrawingProgram::add_file_to_canvas_by_path(const std::filesystem::path& filePath, Vector2f dropPos) { +void DrawingProgram::add_file_to_canvas_by_path(const std::filesystem::path& filePath, Vector2f dropPos, bool applyFreistellen) { if(layerMan.is_a_layer_being_edited()) { ResourceData resource; #ifdef __ANDROID__ @@ -808,7 +916,7 @@ void DrawingProgram::add_file_to_canvas_by_path(const std::filesystem::path& fil Logger::get().log(Logger::LogType::INFO, "[DrawingProgram::add_file_to_canvas_by_path] Could not open file " + filePath.string()); return; } - ImagePreprocess::preprocess_resource(resource.name, resource.data, ImagePreprocess::options_from_config(world.main.conf)); + ImagePreprocess::preprocess_resource(resource.name, resource.data, ImagePreprocess::options_for_place(world.main.conf, applyFreistellen)); NetworkingObjects::NetObjTemporaryPtr imageTempPtr = world.rMan.add_resource(resource); if(imageTempPtr) { NetworkingObjects::NetObjID imageID = imageTempPtr.get_net_id(); @@ -826,17 +934,18 @@ void DrawingProgram::add_file_to_canvas_by_path(const std::filesystem::path& fil img.d.p2 = dropPos + imDim; img.d.imageID = imageID; auto newObjInfo = layerMan.add_component_to_layer_being_edited(newContainer); - layerMan.add_undo_place_component(newObjInfo); + if(newObjInfo) + layerMan.add_undo_place_component(newObjInfo); } } } -CanvasComponentContainer::ObjInfo* DrawingProgram::add_file_to_canvas_by_data(const std::string& fileName, std::string_view fileBuffer, Vector2f dropPos) { +CanvasComponentContainer::ObjInfo* DrawingProgram::add_file_to_canvas_by_data(const std::string& fileName, std::string_view fileBuffer, Vector2f dropPos, bool applyFreistellen) { if(layerMan.is_a_layer_being_edited()) { ResourceData newResource; newResource.data = std::make_shared(fileBuffer); newResource.name = fileName; - ImagePreprocess::preprocess_resource(newResource.name, newResource.data, ImagePreprocess::options_from_config(world.main.conf)); + ImagePreprocess::preprocess_resource(newResource.name, newResource.data, ImagePreprocess::options_for_place(world.main.conf, applyFreistellen)); NetworkingObjects::NetObjID imageID = world.rMan.add_resource(newResource).get_net_id(); ResourceDisplay* display = world.rMan.get_display_data(imageID); Vector2f imTrueDim = display->get_dimensions(); @@ -850,6 +959,8 @@ CanvasComponentContainer::ObjInfo* DrawingProgram::add_file_to_canvas_by_data(co img.d.p2 = dropPos + imDim; img.d.imageID = imageID; CanvasComponentContainer::ObjInfo* newObjInfo = layerMan.add_component_to_layer_being_edited(newContainer); + if(!newObjInfo) + return nullptr; layerMan.add_undo_place_component(newObjInfo); return newObjInfo; } @@ -858,28 +969,183 @@ CanvasComponentContainer::ObjInfo* DrawingProgram::add_file_to_canvas_by_data(co void DrawingProgram::process_selected_images(bool freistellen, bool compress) { auto selected = selection.get_selection_as_set(); - ImagePreprocess::Options opts = ImagePreprocess::options_from_config(world.main.conf); - opts.freistellenOnInsert = freistellen; + std::vector objs(selected.begin(), selected.end()); + process_images_on(objs, freistellen, compress); +} + +void DrawingProgram::process_all_canvas_images(bool freistellen, bool compress) { + process_images_on(layerMan.get_flattened_component_list(), freistellen, compress); +} + +void DrawingProgram::process_images_on(const std::vector& objs, bool freistellen, bool compress) { + ImagePreprocess::Options opts = ImagePreprocess::options_for_place(world.main.conf, freistellen); opts.compressOnInsert = compress; + if(compress && opts.compressFormat == ImagePreprocess::CompressFormat::KEEP) + opts.compressFormat = ImagePreprocess::CompressFormat::WEBP; if(!freistellen && !compress) return; - for(auto* objInfo : selected) { + + std::unordered_map remapped; + int changedUnique = 0; + int changedInstances = 0; + + for(auto* objInfo : objs) { + if(!objInfo || !objInfo->obj) + continue; + if(objInfo->obj->get_comp().get_type() != CanvasComponentType::IMAGE) + continue; + auto& img = static_cast(objInfo->obj->get_comp()); + auto oldId = img.d.imageID; + auto remappedIt = remapped.find(oldId); + if(remappedIt != remapped.end()) { + if(remappedIt->second != oldId) { + img.d.imageID = remappedIt->second; + objInfo->obj->send_comp_update(*this, true); + objInfo->obj->commit_update(*this); + changedInstances++; + } + continue; + } + + auto resource = world.netObjMan.get_obj_temporary_ref_from_id(oldId); + if(!resource || !resource->data) { + remapped[oldId] = oldId; + continue; + } + ResourceData newRes; + newRes.name = resource->name; + newRes.data = std::make_shared(*resource->data); + if(!ImagePreprocess::preprocess_resource(newRes.name, newRes.data, opts)) { + remapped[oldId] = oldId; + continue; + } + auto newId = world.rMan.add_resource(newRes).get_net_id(); + remapped[oldId] = newId; + if(newId == oldId) + continue; + img.d.imageID = newId; + objInfo->obj->send_comp_update(*this, true); + objInfo->obj->commit_update(*this); + changedUnique++; + changedInstances++; + } + + if(changedUnique > 0) + world.rMan.clear_display_cache(); + Logger::get().log(Logger::LogType::INFO, + "[DrawingProgram] Image process: " + std::to_string(changedUnique) + " unique resources, " + + std::to_string(changedInstances) + " placed images"); +} + +DrawingProgram::ImageSizeSummaryLines DrawingProgram::canvas_image_size_summary() { + return image_size_summary_for(layerMan.get_flattened_component_list(), canvasImageSizeCache, + "No raster images on the canvas.", + "On canvas now", + "After Compress all"); +} + +DrawingProgram::ImageSizeSummaryLines DrawingProgram::selected_image_size_summary() { + auto selected = selection.get_selection_as_set(); + std::vector objs(selected.begin(), selected.end()); + return image_size_summary_for(objs, selectedImageSizeCache, + "No raster images in the selection.", + "Selected now", + "After Compress Images"); +} + +DrawingProgram::ImageSizeSummaryLines DrawingProgram::image_size_summary_for(const std::vector& objs, ImageSizeEstimateCache& cache, const char* emptyMsg, const char* nowPrefix, const char* afterPrefix) { + using namespace ImagePreprocess; + CompressFormat fmt = world.main.conf.imageCompressFormat; + int quality = world.main.conf.imageWebpQuality; + if(fmt == CompressFormat::KEEP) + fmt = CompressFormat::WEBP; + + struct UniqueRaster { + NetworkingObjects::NetObjID id; + std::string_view name; + const std::string* data = nullptr; + }; + std::vector unique; + unique.reserve(objs.size()); + std::unordered_set seen; + int placed = 0; + uint64_t fingerprint = 0; + uint64_t currentBytes = 0; + + for(auto* objInfo : objs) { + if(!objInfo || !objInfo->obj) + continue; if(objInfo->obj->get_comp().get_type() != CanvasComponentType::IMAGE) continue; auto& img = static_cast(objInfo->obj->get_comp()); auto resource = world.netObjMan.get_obj_temporary_ref_from_id(img.d.imageID); - if(!resource || !resource->data) + if(!resource || !resource->data || resource->data->empty()) continue; - ResourceData newRes; - newRes.name = resource->name; - newRes.data = std::make_shared(*resource->data); - if(!ImagePreprocess::preprocess_resource(newRes.name, newRes.data, opts)) + if(!looks_like_raster_image(resource->name, *resource->data)) continue; - img.d.imageID = world.rMan.add_resource(newRes).get_net_id(); - objInfo->obj->send_comp_update(*this, true); - objInfo->obj->commit_update(*this); + placed++; + if(!seen.insert(img.d.imageID).second) + continue; + UniqueRaster u; + u.id = img.d.imageID; + u.name = resource->name; + u.data = resource->data.get(); + unique.push_back(u); + currentBytes += static_cast(u.data->size()); + hash_combine(fingerprint, u.id.data[0], u.id.data[1], reinterpret_cast(u.data), u.data->size()); } - world.rMan.clear_display_cache(); + + uint8_t fmtByte = static_cast(fmt); + bool keyChanged = fingerprint != cache.fingerprint || fmtByte != cache.format || quality != cache.quality; + if(keyChanged) { + cache.fingerprint = fingerprint; + cache.format = fmtByte; + cache.quality = quality; + cache.placed = placed; + cache.unique = static_cast(unique.size()); + cache.currentBytes = currentBytes; + cache.estimatedBytes = currentBytes; + cache.estimateReady = unique.empty(); + cache.dirtySince = std::chrono::steady_clock::now(); + } + else { + cache.placed = placed; + cache.unique = static_cast(unique.size()); + cache.currentBytes = currentBytes; + } + + if(!cache.estimateReady && !unique.empty()) { + auto elapsed = std::chrono::steady_clock::now() - cache.dirtySince; + if(elapsed >= std::chrono::milliseconds(250)) { + uint64_t estimated = 0; + for(const auto& u : unique) { + auto est = estimate_compress_size(u.name, *u.data, fmt, quality); + if(est) + estimated += est->estimatedBytes; + else + estimated += static_cast(u.data->size()); + } + cache.estimatedBytes = estimated; + cache.estimateReady = true; + } + } + + ImageSizeSummaryLines out; + if(cache.unique == 0) { + out.nowLine = emptyMsg; + return out; + } + + std::string fmtName = fmt == CompressFormat::PNG ? "PNG" : ("WebP q" + std::to_string(quality)); + out.nowLine = std::string(nowPrefix) + ": " + std::to_string(cache.unique) + " unique raster" + + (cache.unique == 1 ? "" : "s") + " (" + std::to_string(cache.placed) + " placed) — " + + format_byte_size(cache.currentBytes); + out.afterLine = std::string(afterPrefix) + " (" + fmtName + "): "; + if(cache.estimateReady) + out.afterLine += format_byte_size(cache.estimatedBytes); + else + out.afterLine += "estimating…"; + return out; } float DrawingProgram::drag_point_radius() { diff --git a/src/DrawingProgram/DrawingProgram.hpp b/src/DrawingProgram/DrawingProgram.hpp index ba0d1eb..9138a4b 100644 --- a/src/DrawingProgram/DrawingProgram.hpp +++ b/src/DrawingProgram/DrawingProgram.hpp @@ -18,6 +18,7 @@ #pragma once #include "../DrawData.hpp" +#include #include "DrawingProgramCache.hpp" #include "ToolConfiguration.hpp" #include "Tools/GridModifyTool.hpp" @@ -54,8 +55,8 @@ class DrawingProgram { void read_components_client(cereal::PortableBinaryInputArchive& a); void init_server_callbacks(); void init_client_callbacks(); - void add_file_to_canvas_by_path(const std::filesystem::path& filePath, Vector2f dropPos); - CanvasComponentContainer::ObjInfo* add_file_to_canvas_by_data(const std::string& fileName, std::string_view fileBuffer, Vector2f dropPos); + void add_file_to_canvas_by_path(const std::filesystem::path& filePath, Vector2f dropPos, bool applyFreistellen = false); + CanvasComponentContainer::ObjInfo* add_file_to_canvas_by_data(const std::string& fileName, std::string_view fileBuffer, Vector2f dropPos, bool applyFreistellen = false); void get_used_resources(std::unordered_set& resourceSet); Vector4f* color_picker_color(Vector4f* oldColor); @@ -82,6 +83,13 @@ class DrawingProgram { void send_group_ids_for(const std::vector& objs); void force_clients_to_own_view(); void process_selected_images(bool freistellen, bool compress); + void process_all_canvas_images(bool freistellen, bool compress); + struct ImageSizeSummaryLines { + std::string nowLine; + std::string afterLine; + }; + ImageSizeSummaryLines canvas_image_size_summary(); + ImageSizeSummaryLines selected_image_size_summary(); void on_tab_out(); void input_add_file_to_canvas_callback(const CustomEvents::AddFileToCanvasEvent& addFile); @@ -110,12 +118,28 @@ class DrawingProgram { void process_transform_message(const std::vector>& transforms); void process_group_id_message(const std::vector>& groupIds); void process_force_camera_jump(const CoordSpaceHelper& coords, const Vector2f& windowSize); + void process_images_on(const std::vector& objs, bool freistellen, bool compress); + + struct ImageSizeEstimateCache { + uint64_t fingerprint = 0; + uint8_t format = 255; + int quality = -999; + int placed = 0; + int unique = 0; + uint64_t currentBytes = 0; + uint64_t estimatedBytes = 0; + bool estimateReady = false; + std::chrono::steady_clock::time_point dirtySince{}; + }; + ImageSizeSummaryLines image_size_summary_for(const std::vector& objs, ImageSizeEstimateCache& cache, const char* emptyMsg, const char* nowPrefix, const char* afterPrefix); void drag_drop_update(); void check_updateable_components(); void update_downloading_dropped_files(); void selection_action_menu(Vector2f popupPos); + void image_paste_choice_menu(Vector2f popupPos); + void request_clipboard_image_paste(Vector2f screenPos, bool applyFreistellen); void right_click_action_menu(Vector2f popupPos, const std::function& innerContent); void popup_menu_action_button(const char* id, const char* text, const std::function& onClick); void rebuild_cache(); @@ -127,6 +151,8 @@ class DrawingProgram { bool is_selection_allowing_tool(DrawingProgramToolType typeToCheck); DrawingProgramSelection selection; + ImageSizeEstimateCache canvasImageSizeCache; + ImageSizeEstimateCache selectedImageSizeCache; std::unique_ptr toolToSwitchToAfterUpdate; std::unordered_set updateableComponents; @@ -159,6 +185,7 @@ class DrawingProgram { std::vector droppedDownloadingFiles; std::optional rightClickPopupLocation; + bool showingImagePasteChooser = false; uint32_t nextID = 0; diff --git a/src/DrawingProgram/DrawingProgramCache.cpp b/src/DrawingProgram/DrawingProgramCache.cpp index 90b3bab..ab826a1 100644 --- a/src/DrawingProgram/DrawingProgramCache.cpp +++ b/src/DrawingProgram/DrawingProgramCache.cpp @@ -22,6 +22,7 @@ #include "../MainProgram.hpp" #include "Helpers/Parallel.hpp" #include "Layers/DrawingProgramLayerManager.hpp" +#include "Layers/SerializedBlendMode.hpp" #ifdef USE_SKIA_BACKEND_GRAPHITE #include diff --git a/src/DrawingProgram/DrawingProgramSelection.cpp b/src/DrawingProgram/DrawingProgramSelection.cpp index df1bcde..00d20c6 100644 --- a/src/DrawingProgram/DrawingProgramSelection.cpp +++ b/src/DrawingProgram/DrawingProgramSelection.cpp @@ -94,6 +94,12 @@ void DrawingProgramSelection::selection_gui(Toolbar& t) { .onClick = [&] { drawP.process_selected_images(true, true); } }); }); + { + auto sizes = drawP.selected_image_size_summary(); + mutable_text_label_light(gui, "selection image size now", sizes.nowLine); + if(!sizes.afterLine.empty()) + mutable_text_label_light(gui, "selection image size after", sizes.afterLine); + } } }); } @@ -356,7 +362,8 @@ void DrawingProgramSelection::expand_selection_with_groups() { return; std::unordered_set already(selectedSet.begin(), selectedSet.end()); for(auto* c : drawP.layerMan.get_flattened_component_list()) { - if(c->obj->groupId != 0 && groupIds.contains(c->obj->groupId) && !already.contains(c)) { + if(c->obj->groupId != 0 && groupIds.contains(c->obj->groupId) && !already.contains(c) + && drawP.layerMan.can_current_peer_edit_layer(c->obj->parentLayer)) { selectedSet.push_back(c); already.insert(c); } @@ -381,14 +388,23 @@ void DrawingProgramSelection::ungroup_selection() { } void DrawingProgramSelection::add_to_selection(const std::vector& newSelection) { - selectedSet.insert(selectedSet.end(), newSelection.begin(), newSelection.end()); + for(auto* c : newSelection) { + if(c->obj->parentLayer && !drawP.layerMan.can_current_peer_edit_layer(c->obj->parentLayer)) + continue; + selectedSet.emplace_back(c); + } expand_selection_with_groups(); sort_selection(); calculate_aabb(); } void DrawingProgramSelection::set_to_selection(const std::vector& newSelection) { - selectedSet = newSelection; + selectedSet.clear(); + for(auto* c : newSelection) { + if(c->obj->parentLayer && !drawP.layerMan.can_current_peer_edit_layer(c->obj->parentLayer)) + continue; + selectedSet.emplace_back(c); + } expand_selection_with_groups(); sort_selection(); calculate_aabb(); @@ -598,11 +614,8 @@ void DrawingProgramSelection::input_key_callback_display_selection(const InputMa } case InputManager::KEY_PASTE_IMAGE: { if(key.down && !key.repeat) { - deselect_all(); - drawP.world.main.input.call_paste(CustomEvents::PasteEvent::DataType::IMAGE, { - .pastePosition = drawP.world.main.input.mouse.pos - }); - drawP.world.main.g.gui.set_to_layout(); + drawP.showingImagePasteChooser = true; + drawP.set_right_click_popup_location(drawP.world.main.input.mouse.pos / drawP.world.main.g.final_gui_scale()); } break; } @@ -761,6 +774,28 @@ void DrawingProgramSelection::deselect_all() { } } +void DrawingProgramSelection::remove_uneditable_from_selection() { + if(selectedSet.empty()) + return; + bool removed = false; + std::erase_if(selectedSet, [&](CanvasComponentContainer::ObjInfo* c) { + if(!c || !c->obj || drawP.layerMan.can_current_peer_edit_layer(c->obj->parentLayer)) + return false; + drawP.drawCache.add_component(c); + removed = true; + return true; + }); + if(removed) { + if(selectedSet.empty()) + reset_all(); + else { + sort_selection(); + calculate_aabb(); + } + drawP.world.main.g.gui.set_to_layout(); + } +} + void DrawingProgramSelection::push_selection_to_front() { check_add_stroke_color_change_undo(); @@ -874,7 +909,7 @@ void DrawingProgramSelection::paste_image_process_event(const CustomEvents::Past drawP.switch_to_tool(DrawingProgramToolType::EDIT); std::vector compSetInserted; - compSetInserted.emplace_back(drawP.add_file_to_canvas_by_data("Image from clipboard", paste.data, paste.mousePos.value())); + compSetInserted.emplace_back(drawP.add_file_to_canvas_by_data("Image from clipboard", paste.data, paste.mousePos.value(), paste.applyFreistellen)); if(compSetInserted.back() != nullptr) { drawP.drawCache.erase_component(compSetInserted.back()); set_to_selection(compSetInserted); diff --git a/src/DrawingProgram/DrawingProgramSelection.hpp b/src/DrawingProgram/DrawingProgramSelection.hpp index ad51dd3..6d8ed2f 100644 --- a/src/DrawingProgram/DrawingProgramSelection.hpp +++ b/src/DrawingProgram/DrawingProgramSelection.hpp @@ -43,6 +43,7 @@ class DrawingProgramSelection { void update(); void sort_selection(); void deselect_all(); + void remove_uneditable_from_selection(); void paste_clipboard(Vector2f pasteScreenPos); void paste_image_process_event(const CustomEvents::PasteEvent& paste); std::unordered_set get_selection_as_set(); diff --git a/src/DrawingProgram/Layers/DrawingProgramLayer.cpp b/src/DrawingProgram/Layers/DrawingProgramLayer.cpp index 94e4848..994de1f 100644 --- a/src/DrawingProgram/Layers/DrawingProgramLayer.cpp +++ b/src/DrawingProgram/Layers/DrawingProgramLayer.cpp @@ -39,6 +39,15 @@ void DrawingProgramLayer::set_component_list_callbacks(DrawingProgramLayerListIt layerMan.drawP.drawCache.add_component(&(*c)); if(c->obj->get_comp().get_type() == CanvasComponentType::IMAGE) layerMan.drawP.updateableComponents.emplace(&(*c)); + + // Live fog / lock enforcement on host + if(layerMan.addToCacheOnComponentInsert && layerMan.drawP.world.netServer) { + if(layerMan.drawP.world.currentNetUpdateClient && layerListItem.is_player_edit_blocked()) { + layerMan.defer_reject_component(&layerListItem, c->obj.get_net_id()); + return; + } + layerMan.mark_fog_client_projection_dirty(); + } }; eraseCallback = [&](const CanvasComponentContainer::ObjInfoIterator& c) { layerMan.drawP.selection.erase_component(&(*c)); @@ -48,6 +57,8 @@ void DrawingProgramLayer::set_component_list_callbacks(DrawingProgramLayerListIt return downloadingFile.comp == &(*c); }); layerMan.drawP.updateableComponents.erase(&(*c)); + if(layerMan.addToCacheOnComponentInsert && layerMan.drawP.world.netServer) + layerMan.mark_fog_client_projection_dirty(); }; components->set_insert_callback(insertCallback); components->set_erase_callback(eraseCallback); diff --git a/src/DrawingProgram/Layers/DrawingProgramLayerListItem.cpp b/src/DrawingProgram/Layers/DrawingProgramLayerListItem.cpp index e62a702..2937aa6 100644 --- a/src/DrawingProgram/Layers/DrawingProgramLayerListItem.cpp +++ b/src/DrawingProgram/Layers/DrawingProgramLayerListItem.cpp @@ -21,6 +21,7 @@ #include #include "DrawingProgramLayer.hpp" #include "../../World.hpp" +#include "../../MainProgram.hpp" #include "SerializedBlendMode.hpp" using namespace NetworkingObjects; @@ -78,6 +79,8 @@ DrawingProgramLayerListItem::DrawingProgramLayerListItem(World& w, const Drawing displayData = w.netObjMan.make_obj(); displayData->alpha = initData.metaInfo.alpha; displayData->blendMode = initData.metaInfo.blendMode; + displayData->playerLocked = initData.metaInfo.playerLocked; + displayData->fogLayer = initData.metaInfo.fogLayer; } DrawingProgramLayerListItemUndoData DrawingProgramLayerListItem::get_undo_data(WorldUndoManager& u) const { @@ -85,6 +88,8 @@ DrawingProgramLayerListItemUndoData DrawingProgramLayerListItem::get_undo_data(W toRet.metaInfo.name = nameData->name; toRet.metaInfo.blendMode = displayData->blendMode; toRet.metaInfo.alpha = displayData->alpha; + toRet.metaInfo.playerLocked = displayData->playerLocked; + toRet.metaInfo.fogLayer = displayData->fogLayer; if(folderData) { toRet.containerUndoID = u.get_undoid_from_netid(folderData->folderList.get_net_id()); toRet.folderData = std::vector(); @@ -178,11 +183,24 @@ void DrawingProgramLayerListItem::load_file(cereal::PortableBinaryInputArchive& a(*nameData); displayData = layerMan.drawP.world.netObjMan.make_obj(); - a(*displayData); + if(version >= VersionNumber(0, 8, 0)) { + a(*displayData); + // Normalize cereal bools (non-0/1 bytes must not stick as "true" forever). + displayData->visible = displayData->visible ? true : false; + displayData->playerLocked = displayData->playerLocked ? true : false; + displayData->fogLayer = displayData->fogLayer ? true : false; + } + else { + a(displayData->alpha, displayData->visible, displayData->blendMode); + displayData->visible = displayData->visible ? true : false; + displayData->playerLocked = false; + displayData->fogLayer = false; + } bool isFolder; a(isFolder); if(isFolder) { + displayData->fogLayer = false; folderData = std::make_unique(); folderData->load_file(a, version, layerMan); } @@ -275,20 +293,84 @@ SerializedBlendMode DrawingProgramLayerListItem::get_blend_mode() const { return displayData->blendMode; } +void DrawingProgramLayerListItem::set_player_locked(DrawingProgramLayerManager& layerMan, bool locked) const { + if(!displayData) + return; + bool newLocked = locked || displayData->fogLayer; + if(displayData->playerLocked != newLocked) { + displayData->playerLocked = newLocked; + layerMan.drawP.drawCache.clear_own_cached_surfaces(); + layerMan.drawP.world.delayedUpdateObjectManager.send_update_to_all(displayData, false); + layerMan.refresh_fog_client_projection(); + } +} + +bool DrawingProgramLayerListItem::get_player_locked() const { + return displayData && displayData->playerLocked; +} + +void DrawingProgramLayerListItem::set_fog_layer(DrawingProgramLayerManager& layerMan, bool fog) const { + if(!displayData || displayData->fogLayer == fog || is_folder()) + return; + displayData->fogLayer = fog; + if(fog) + displayData->playerLocked = true; + layerMan.drawP.drawCache.clear_own_cached_surfaces(); + layerMan.drawP.world.delayedUpdateObjectManager.send_update_to_all(displayData, false); + layerMan.refresh_fog_client_projection(); +} + +bool DrawingProgramLayerListItem::is_fog_layer() const { + return displayData && displayData->fogLayer; +} + +bool DrawingProgramLayerListItem::is_player_edit_blocked() const { + return is_fog_layer() || get_player_locked(); +} + void DrawingProgramLayerListItem::set_metainfo(DrawingProgramLayerManager& layerMan, const DrawingProgramLayerListItemMetaInfo& metaInfo) { set_blend_mode(layerMan, metaInfo.blendMode); set_alpha(layerMan, metaInfo.alpha); set_name(layerMan.drawP.world.delayedUpdateObjectManager, metaInfo.name); + set_fog_layer(layerMan, metaInfo.fogLayer); + set_player_locked(layerMan, metaInfo.playerLocked || metaInfo.fogLayer); } DrawingProgramLayerListItemMetaInfo DrawingProgramLayerListItem::get_metainfo() const { return DrawingProgramLayerListItemMetaInfo{ .name = get_name(), .alpha = get_alpha(), - .blendMode = get_blend_mode() + .blendMode = get_blend_mode(), + .playerLocked = get_player_locked(), + .fogLayer = is_fog_layer() }; } +void DrawingProgramLayerListItem::write_stripped_constructor(cereal::PortableBinaryOutputArchive& a, const std::unordered_set& hiddenComponentIds) const { + a(static_cast(folderData)); + if(folderData) { + a(folderData->folderList.get_net_id()); + a(static_cast(folderData->folderList->size())); + for(auto& child : *folderData->folderList) { + a(child.obj.get_net_id()); + child.obj->write_stripped_constructor(a, hiddenComponentIds); + } + } + else { + std::vector> keep; + for(auto& c : *layerData->components) { + if(!hiddenComponentIds.contains(c.obj.get_net_id())) + keep.emplace_back(c.obj); + } + a(layerData->components.get_net_id()); + a(static_cast(keep.size())); + for(auto& c : keep) + c.write_create_message(a); + } + nameData.write_create_message(a); + displayData.write_create_message(a); +} + void DrawingProgramLayerListItem::erase_invalid_components() { if(folderData) folderData->erase_invalid_components(); @@ -312,7 +394,9 @@ void DrawingProgramLayerListItem::register_class(World& w) { }); w.delayedUpdateObjectManager.register_class(w.netObjMan, NetworkingObjects::DelayUpdateSerializedClassManager::CustomConstructors{ .postUpdateFunc = [&](DisplayData& o) { + (void)o; w.drawProg.drawCache.clear_own_cached_surfaces(); + w.drawProg.layerMan.ensure_editing_layer_is_editable(); w.set_to_layout_gui_if_focus(); } }); diff --git a/src/DrawingProgram/Layers/DrawingProgramLayerListItem.hpp b/src/DrawingProgram/Layers/DrawingProgramLayerListItem.hpp index 25b70bd..9f13dab 100644 --- a/src/DrawingProgram/Layers/DrawingProgramLayerListItem.hpp +++ b/src/DrawingProgram/Layers/DrawingProgramLayerListItem.hpp @@ -35,6 +35,8 @@ struct DrawingProgramLayerListItemMetaInfo { std::string name; float alpha = 1.0f; SerializedBlendMode blendMode = SerializedBlendMode::BLEND_SRC_OVER; + bool playerLocked = false; + bool fogLayer = false; bool operator==(const DrawingProgramLayerListItemMetaInfo&) const = default; }; @@ -80,12 +82,22 @@ class DrawingProgramLayerListItem { void set_blend_mode(DrawingProgramLayerManager& layerMan, SerializedBlendMode newBlendMode) const; SerializedBlendMode get_blend_mode() const; + void set_player_locked(DrawingProgramLayerManager& layerMan, bool locked) const; + bool get_player_locked() const; + void set_fog_layer(DrawingProgramLayerManager& layerMan, bool fog) const; + bool is_fog_layer() const; + // Fog layers are always treated as player-locked for edit checks. + bool is_player_edit_blocked() const; + void set_metainfo(DrawingProgramLayerManager& layerMan, const DrawingProgramLayerListItemMetaInfo& metaInfo); DrawingProgramLayerListItemMetaInfo get_metainfo() const; uint32_t get_component_count() const; void erase_invalid_components(); + + // Host→client join projection: same shape as write_constructor_func, omits hidden comps. + void write_stripped_constructor(cereal::PortableBinaryOutputArchive& a, const std::unordered_set& hiddenComponentIds) const; private: static void write_constructor_func(const NetworkingObjects::NetObjTemporaryPtr& o, cereal::PortableBinaryOutputArchive& a); static void read_constructor_func(const NetworkingObjects::NetObjTemporaryPtr& o, cereal::PortableBinaryInputArchive& a, const std::shared_ptr& c); @@ -101,8 +113,10 @@ class DrawingProgramLayerListItem { float alpha = 1.0f; bool visible = true; SerializedBlendMode blendMode = SerializedBlendMode::BLEND_SRC_OVER; + bool playerLocked = false; + bool fogLayer = false; template void serialize(Archive& a) { - a(alpha, visible, blendMode); + a(alpha, visible, blendMode, playerLocked, fogLayer); } }; NetworkingObjects::NetObjOwnerPtr nameData; diff --git a/src/DrawingProgram/Layers/DrawingProgramLayerManager.cpp b/src/DrawingProgram/Layers/DrawingProgramLayerManager.cpp index fd26b9d..d1a6947 100644 --- a/src/DrawingProgram/Layers/DrawingProgramLayerManager.cpp +++ b/src/DrawingProgram/Layers/DrawingProgramLayerManager.cpp @@ -17,6 +17,7 @@ */ #include "DrawingProgramLayerManager.hpp" +#include "FogStripper.hpp" #include "../../World.hpp" #include "Helpers/NetworkingObjects/NetObjID.hpp" #include "Helpers/NetworkingObjects/NetObjOrderedList.hpp" @@ -27,6 +28,7 @@ #include "../../CanvasComponents/CanvasComponentAllocator.hpp" #include "../../CanvasComponents/CanvasComponent.hpp" #include +#include DrawingProgramLayerManager::DrawingProgramLayerManager(DrawingProgram& drawProg): drawP(drawProg), // initialize drawP first for next objects to be initialized properly @@ -44,7 +46,10 @@ void DrawingProgramLayerManager::scale_up(const WorldScalar& scaleUpAmount) { } void DrawingProgramLayerManager::write_components_server(cereal::PortableBinaryOutputArchive& a) { - layerTreeRoot.write_create_message(a); + auto hidden = FogStripper::compute_hidden_component_ids(*this); + clientHiddenComponentIds = hidden; + a(layerTreeRoot.get_net_id()); + layerTreeRoot->write_stripped_constructor(a, hidden); } void DrawingProgramLayerManager::read_components_client(cereal::PortableBinaryInputArchive& a) { @@ -58,6 +63,7 @@ void DrawingProgramLayerManager::read_components_client(cereal::PortableBinaryIn }); drawP.rebuild_cache(); editingLayer = layerTreeRoot->get_folder().get_initial_editing_layer(); + ensure_editing_layer_is_editable(); } bool DrawingProgramLayerManager::is_a_layer_being_edited() { @@ -104,6 +110,10 @@ const DrawingProgramLayerListItem& DrawingProgramLayerManager::get_layer_root() } bool DrawingProgramLayerManager::component_passes_layer_selector(CanvasComponentContainer::ObjInfo* c, LayerSelector layerSelector) { + if(!c || !c->obj || !c->obj->parentLayer) + return false; + if(!can_current_peer_edit_layer(c->obj->parentLayer)) + return false; switch(layerSelector) { case LayerSelector::ALL_VISIBLE_LAYERS: return c->obj->parentLayer->get_visible(); @@ -243,6 +253,11 @@ std::vector DrawingProgramLayerManage auto editLayerPtr = editingLayer.lock(); if(!editLayerPtr) throw std::runtime_error("[DrawingProgramLayerManager::add_many_components_to_layer_being_edited] No layer selected!"); + if(!can_current_peer_edit_layer(editLayerPtr.get())) { + for(auto& [it, obj] : newObjs) + delete obj; + return {}; + } auto toRet = editLayerPtr->get_layer().components->insert_sorted_list_and_send_create(editLayerPtr->get_layer().components, newObjs); add_undo_place_components(editingLayer.lock().get(), toRet); return toRet; @@ -276,10 +291,90 @@ void DrawingProgramLayerManager::disable_add_to_cache_and_commit_update_block(co addToCacheOnComponentInsert = true; } +bool DrawingProgramLayerManager::can_current_peer_edit_layer(const DrawingProgramLayerListItem* layer) const { + if(!layer) + return false; + if(FogStripper::world_is_host(drawP.world)) + return true; + return !layer->is_player_edit_blocked(); +} + +bool DrawingProgramLayerManager::client_may_modify_component(const CanvasComponentContainer* comp) const { + // Host-side authority check for CLIENT messages — must NOT use can_current_peer_edit_layer + // (that always returns true on the host). + if(!comp || !comp->parentLayer) + return false; + return !comp->parentLayer->is_player_edit_blocked(); +} + +void DrawingProgramLayerManager::ensure_editing_layer_is_editable() { + auto cur = editingLayer.lock(); + if(cur && can_current_peer_edit_layer(cur.get())) + return; + if(!layerTreeRoot) + return; + std::function(const DrawingProgramLayerFolder&)> findEditable; + findEditable = [&](const DrawingProgramLayerFolder& folder) -> NetworkingObjects::NetObjWeakPtr { + for(auto& c : *folder.folderList) { + if(!c.obj->is_folder() && can_current_peer_edit_layer(c.obj.get())) + return c.obj; + } + for(auto& c : *folder.folderList) { + if(c.obj->is_folder()) { + auto found = findEditable(c.obj->get_folder()); + if(!found.expired()) + return found; + } + } + return {}; + }; + editingLayer = findEditable(layerTreeRoot->get_folder()); + drawP.selection.remove_uneditable_from_selection(); +} + +void DrawingProgramLayerManager::refresh_fog_client_projection() { + fogClientProjectionDirty = false; + FogStripper::sync_hidden_set_to_clients(drawP.world, *this); +} + +void DrawingProgramLayerManager::mark_fog_client_projection_dirty() { + if(drawP.world.netServer) + fogClientProjectionDirty = true; +} + +void DrawingProgramLayerManager::flush_fog_client_projection_if_dirty() { + if(!fogClientProjectionDirty) + return; + refresh_fog_client_projection(); +} + +void DrawingProgramLayerManager::defer_reject_component(DrawingProgramLayerListItem* layer, NetworkingObjects::NetObjID compId) { + deferredRejectInserts.emplace_back(layer, compId); +} + +void DrawingProgramLayerManager::flush_deferred_rejects() { + if(deferredRejectInserts.empty()) + return; + auto pending = std::move(deferredRejectInserts); + deferredRejectInserts.clear(); + for(auto& [layer, compId] : pending) { + if(!layer || layer->is_folder()) + continue; + auto& comps = layer->get_layer().components; + if(!comps->contains(compId)) + continue; + comps->erase(comps, comps->get(compId)); + } +} + CanvasComponentContainer::ObjInfo* DrawingProgramLayerManager::add_component_to_layer_being_edited(CanvasComponentContainer* newObj) { auto editLayerPtr = editingLayer.lock(); if(!editLayerPtr) throw std::runtime_error("[DrawingProgramLayerManager::add_component_to_layer_being_edited] No layer selected!"); + if(!can_current_peer_edit_layer(editLayerPtr.get())) { + delete newObj; + return nullptr; + } return &(*editLayerPtr->get_layer().components->push_back_and_send_create(editLayerPtr->get_layer().components, newObj)); } @@ -311,6 +406,7 @@ void DrawingProgramLayerManager::load_file(cereal::PortableBinaryInputArchive& a layerTreeRoot->erase_invalid_components(); drawP.rebuild_cache(); editingLayer = layerTreeRoot->get_folder().get_initial_editing_layer(); + ensure_editing_layer_is_editable(); } void DrawingProgramLayerManager::save_file(cereal::PortableBinaryOutputArchive& a) const { diff --git a/src/DrawingProgram/Layers/DrawingProgramLayerManager.hpp b/src/DrawingProgram/Layers/DrawingProgramLayerManager.hpp index 5f877e3..6c22977 100644 --- a/src/DrawingProgram/Layers/DrawingProgramLayerManager.hpp +++ b/src/DrawingProgram/Layers/DrawingProgramLayerManager.hpp @@ -20,6 +20,7 @@ #include "DrawingProgramLayerListItem.hpp" #include "DrawingProgramLayerManagerGUI.hpp" #include +#include class DrawingProgramLayerManager { private: @@ -85,5 +86,25 @@ class DrawingProgramLayerManager { std::vector get_flattened_component_list() const; std::vector get_flattened_layer_list(); void add_undo_place_component(CanvasComponentContainer::ObjInfo* objInfo); + + bool can_current_peer_edit_layer(const DrawingProgramLayerListItem* layer) const; + bool client_may_modify_component(const CanvasComponentContainer* comp) const; + void ensure_editing_layer_is_editable(); + /** Host: recompute fog coverage and erase/reveal (+ resources) on clients. */ + void refresh_fog_client_projection(); + /** Coalesce frequent mesh edits; flushed once per frame in DrawingProgram::update. */ + void mark_fog_client_projection_dirty(); + void flush_fog_client_projection_if_dirty(); + + // NetObjIDs omitted from the last client projection (host only). + std::unordered_set clientHiddenComponentIds; + + // Client inserts into locked/fog layers — erase after the NetObj update finishes. + void defer_reject_component(DrawingProgramLayerListItem* layer, NetworkingObjects::NetObjID compId); + void flush_deferred_rejects(); + DrawingProgramLayerManagerGUI listGUI; + private: + std::vector> deferredRejectInserts; + bool fogClientProjectionDirty = false; }; diff --git a/src/DrawingProgram/Layers/DrawingProgramLayerManagerGUI.cpp b/src/DrawingProgram/Layers/DrawingProgramLayerManagerGUI.cpp index 1b7f411..30f4bfd 100644 --- a/src/DrawingProgram/Layers/DrawingProgramLayerManagerGUI.cpp +++ b/src/DrawingProgram/Layers/DrawingProgramLayerManagerGUI.cpp @@ -19,6 +19,7 @@ #include "DrawingProgramLayerManagerGUI.hpp" #include "DrawingProgramLayerListItem.hpp" #include "DrawingProgramLayerManager.hpp" +#include "FogStripper.hpp" #include "../DrawingProgram.hpp" #include "../../World.hpp" #include "../../MainProgram.hpp" @@ -31,6 +32,7 @@ #include "../../GUIStuff/ElementHelpers/LayoutHelpers.hpp" #include "../../GUIStuff/ElementHelpers/TextBoxHelpers.hpp" #include "../../GUIStuff/ElementHelpers/NumberSliderHelpers.hpp" +#include "../../GUIStuff/ElementHelpers/CheckBoxHelpers.hpp" #include "../../GUIStuff/Elements/SVGIcon.hpp" #include "../../GUIStuff/Elements/DropDown.hpp" @@ -122,15 +124,21 @@ void DrawingProgramLayerManagerGUI::setup_list_gui() { .childAlignment = {.x = CLAY_ALIGN_X_LEFT, .y = CLAY_ALIGN_Y_CENTER} }, }) { - ellipse_wide_paragraph_label(gui, "layer name", layer->get_name()); + std::string label = layer->get_name(); + if(layer->is_fog_layer()) + label += " [fog]"; + else if(layer->is_player_edit_blocked()) + label += " [lock]"; + ellipse_wide_paragraph_label(gui, "layer name", label); } - if(!layer->is_folder()) { + if(!layer->is_folder() && layerMan.can_current_peer_edit_layer(layer.get())) { gui.set_z_index_keep_clipping_region(gui.get_z_index() + 1, [&] { svg_icon_button(gui, "edit button", "data/icons/pencil.svg", { .drawType = SelectableButton::DrawType::TRANSPARENT_ALL, .size = TreeListing::ENTRY_HEIGHT, .onClick = [&, layer] { - layerMan.editingLayer = layer; + if(layerMan.can_current_peer_edit_layer(layer.get())) + layerMan.editingLayer = layer; } }); }); @@ -155,8 +163,11 @@ void DrawingProgramLayerManagerGUI::setup_list_gui() { }); }, .onDoubleClick = [&](const TreeListingObjIndexList& objIndex) { - layerMan.editingLayer = get_layer_from_obj_index(objIndex); - layerMan.drawP.world.main.g.gui.set_to_layout(); + auto layer = get_layer_from_obj_index(objIndex); + if(layerMan.can_current_peer_edit_layer(layer.get())) { + layerMan.editingLayer = layer; + layerMan.drawP.world.main.g.gui.set_to_layout(); + } }, .onSelectChange = [&] { editing_layer_check(); @@ -441,6 +452,34 @@ void DrawingProgramLayerManagerGUI::setup_list_gui() { } }); }); + if(FogStripper::world_is_host(world) && !editingLayerLock->is_folder()) { + // Must not use stack bool* with checkbox_boolean_field — CheckBox + // calls isTicked/onClick after layout returns (UAF → random fog flags). + checkbox_field(gui, "fog layer toggle", "Fog of War layer", + [&] { + auto layer = editingLayer.lock(); + return layer && layer->is_fog_layer(); + }, + [&] { + auto layer = editingLayer.lock(); + if(layer) + layer->set_fog_layer(layerMan, !layer->is_fog_layer()); + }); + if(!editingLayerLock->is_fog_layer()) { + checkbox_field(gui, "player lock toggle", "Lock for players", + [&] { + auto layer = editingLayer.lock(); + return layer && layer->get_player_locked(); + }, + [&] { + auto layer = editingLayer.lock(); + if(layer) + layer->set_player_locked(layerMan, !layer->get_player_locked()); + }); + } + else + text_label(gui, "Fog layers are always locked for players."); + } } } } diff --git a/src/DrawingProgram/Layers/FogStripper.cpp b/src/DrawingProgram/Layers/FogStripper.cpp new file mode 100644 index 0000000..a733f6f --- /dev/null +++ b/src/DrawingProgram/Layers/FogStripper.cpp @@ -0,0 +1,258 @@ +/* + * InfiniPaint + * Copyright (C) 2025-2026 Yousef Khadadeh + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "FogStripper.hpp" +#include "DrawingProgramLayerManager.hpp" +#include "DrawingProgramLayerListItem.hpp" +#include "../../World.hpp" +#include "../../CommandList.hpp" +#include "../../CanvasComponents/MeshCanvasComponent.hpp" +#include "../../CanvasComponents/RectangleCanvasComponent.hpp" +#include "../../CanvasComponents/EllipseCanvasComponent.hpp" +#include +#include +#include +#include +#include +#include + +namespace FogStripper { +namespace { + +struct FogCoverPiece { + CanvasComponentContainer* fogComp = nullptr; + float effectiveAlpha = 1.0f; +}; + +float component_paint_alpha(const CanvasComponentContainer& c) { + switch(c.get_comp().get_type()) { + case CanvasComponentType::MESH: + return static_cast(c.get_comp()).d.color.w(); + case CanvasComponentType::RECTANGLE: { + auto& r = static_cast(c.get_comp()); + return std::max(r.d.fillColor.w(), r.d.strokeColor.w()); + } + case CanvasComponentType::ELLIPSE: { + auto& e = static_cast(c.get_comp()); + return std::max(e.d.fillColor.w(), e.d.strokeColor.w()); + } + default: + return 1.0f; + } +} + +bool point_inside_fog(CanvasComponentContainer& fogComp, const WorldVec& worldPoint) { + // collides_with_point expects the probe in fogComp.coords space. + Vector2f local = fogComp.coords.to_space(worldPoint); + return fogComp.collides_with_point(fogComp.coords, local); +} + +bool victim_fully_covered_by_fog(CanvasComponentContainer& victim, const FogCoverPiece& fogPiece) { + if(!victim.get_world_bounds().has_value() || !fogPiece.fogComp->get_world_bounds().has_value()) + return false; + if(fogPiece.effectiveAlpha < OPAQUE_ALPHA_THRESHOLD) + return false; + + const auto& victimAABB = victim.get_world_bounds().value(); + const auto& fogAABB = fogPiece.fogComp->get_world_bounds().value(); + // Must be completely inside the fog piece, not merely overlapping. + if(!fogAABB.fully_contains_aabb(victimAABB)) + return false; + + // Confirm shape coverage (brush blobs are not the AABB): corners + center inside fog paint. + const WorldVec corners[5] = { + victimAABB.min, + victimAABB.max, + victimAABB.top_right(), + victimAABB.bottom_left(), + victimAABB.center() + }; + for(const auto& corner : corners) { + if(!point_inside_fog(*fogPiece.fogComp, corner)) + return false; + } + return true; +} + +} // namespace + +bool world_is_host(const World& world) { + return world.netServer != nullptr || world.netClient == nullptr; +} + +std::unordered_set compute_hidden_component_ids(DrawingProgramLayerManager& layerMan) { + std::unordered_set hidden; + if(!layerMan.layer_tree_root_exists()) + return hidden; + + std::vector layers = layerMan.get_flattened_layer_list(); + // Flattened forward order: index 0 is top of stack (drawn last). Fog at i covers layers j > i. + std::vector activeFog; + + for(size_t i = 0; i < layers.size(); ++i) { + DrawingProgramLayerListItem* layer = layers[i]; + if(layer->is_fog_layer() && layer->get_visible()) { + float layerAlpha = layer->get_alpha(); + for(auto& c : *layer->get_layer().components) { + FogCoverPiece piece; + piece.fogComp = c.obj.get(); + piece.effectiveAlpha = layerAlpha * component_paint_alpha(*c.obj); + if(piece.effectiveAlpha >= OPAQUE_ALPHA_THRESHOLD) + activeFog.emplace_back(piece); + } + continue; + } + + if(activeFog.empty()) + continue; + + for(auto& c : *layer->get_layer().components) { + for(const auto& fogPiece : activeFog) { + if(victim_fully_covered_by_fog(*c.obj, fogPiece)) { + hidden.insert(c.obj.get_net_id()); + break; + } + } + } + } + return hidden; +} + +std::unordered_set compute_visible_resource_ids(DrawingProgramLayerManager& layerMan, const std::unordered_set& hiddenComponentIds) { + std::unordered_set resources; + auto comps = layerMan.get_flattened_component_list(); + for(auto* info : comps) { + if(hiddenComponentIds.contains(info->obj.get_net_id())) + continue; + info->obj->get_comp().get_used_resources(resources); + } + return resources; +} + +void write_stripped_layer_tree(const DrawingProgramLayerListItem& root, cereal::PortableBinaryOutputArchive& a, const std::unordered_set& hiddenComponentIds) { + root.write_stripped_constructor(a, hiddenComponentIds); +} + +void sync_hidden_set_to_clients(World& world, DrawingProgramLayerManager& layerMan) { + if(!world.netServer) + return; + + auto newHidden = compute_hidden_component_ids(layerMan); + auto& prevHidden = layerMan.clientHiddenComponentIds; + + std::vector newlyHidden; + std::vector newlyRevealed; + for(const auto& id : newHidden) { + if(!prevHidden.contains(id)) + newlyHidden.push_back(id); + } + for(const auto& id : prevHidden) { + if(!newHidden.contains(id)) + newlyRevealed.push_back(id); + } + + if(!newlyHidden.empty()) { + std::unordered_map> eraseByList; + auto comps = layerMan.get_flattened_component_list(); + for(auto* info : comps) { + NetworkingObjects::NetObjID compId = info->obj.get_net_id(); + if(std::find(newlyHidden.begin(), newlyHidden.end(), compId) == newlyHidden.end()) + continue; + auto* parent = info->obj->parentLayer; + if(!parent || parent->is_folder()) + continue; + eraseByList[parent->get_layer().components.get_net_id()].push_back(compId); + } + for(auto& [listId, ids] : eraseByList) { + auto listPtr = world.netObjMan.get_obj_temporary_ref_from_id(listId); + if(!listPtr) + continue; + listPtr.send_server_update_to_all_clients(RELIABLE_COMMAND_CHANNEL, [&ids](const NetworkingObjects::NetObjTemporaryPtr&, cereal::PortableBinaryOutputArchive& a) { + a(NetworkingObjects::ObjPtrOrderedListCommand_StoC::ERASE_ID_LIST, ids); + }); + } + } + + if(!newlyRevealed.empty()) { + struct RevealItem { + NetworkingObjects::NetObjID listId; + NetworkingObjects::NetObjID afterId; + NetworkingObjects::NetObjTemporaryPtr obj; + }; + std::vector reveals; + auto comps = layerMan.get_flattened_component_list(); + for(auto* info : comps) { + NetworkingObjects::NetObjID compId = info->obj.get_net_id(); + if(std::find(newlyRevealed.begin(), newlyRevealed.end(), compId) == newlyRevealed.end()) + continue; + auto* parent = info->obj->parentLayer; + if(!parent || parent->is_folder()) + continue; + RevealItem r; + r.listId = parent->get_layer().components.get_net_id(); + r.obj = info->obj; + r.afterId = NetworkingObjects::NetObjID{0, 0}; + if(info->pos > 0) { + auto prevIt = parent->get_layer().components->at(info->pos - 1); + r.afterId = prevIt->obj.get_net_id(); + while(prevHidden.contains(r.afterId) || newHidden.contains(r.afterId)) { + if(prevIt->pos == 0) { + r.afterId = NetworkingObjects::NetObjID{0, 0}; + break; + } + prevIt = parent->get_layer().components->at(prevIt->pos - 1); + r.afterId = prevIt->obj.get_net_id(); + } + } + reveals.push_back(r); + } + + std::unordered_map> byList; + for(auto& r : reveals) + byList[r.listId].push_back(r); + + for(auto& [listId, items] : byList) { + auto listPtr = world.netObjMan.get_obj_temporary_ref_from_id(listId); + if(!listPtr) + continue; + listPtr.send_server_update_to_all_clients(RELIABLE_COMMAND_CHANNEL, [&items](const NetworkingObjects::NetObjTemporaryPtr&, cereal::PortableBinaryOutputArchive& a) { + a(NetworkingObjects::ObjPtrOrderedListCommand_StoC::INSERT_LIST_CONSTRUCT, static_cast(items.size())); + for(auto& item : items) { + a(item.afterId); + item.obj.write_create_message(a); + } + }); + std::unordered_set needed; + for(auto& item : items) + item.obj->get_comp().get_used_resources(needed); + for(const auto& resId : needed) { + for(auto& r : world.rMan.resource_list()) { + if(r.get_net_id() == resId) { + world.netServer->send_items_to_all_clients(RESOURCE_COMMAND_CHANNEL, CLIENT_NEW_RESOURCE_ID, resId); + world.netServer->send_items_to_all_clients(RESOURCE_COMMAND_CHANNEL, CLIENT_NEW_RESOURCE_DATA, *r); + break; + } + } + } + } + } + + prevHidden = std::move(newHidden); +} + +} // namespace FogStripper diff --git a/src/DrawingProgram/Layers/FogStripper.hpp b/src/DrawingProgram/Layers/FogStripper.hpp new file mode 100644 index 0000000..bf8c768 --- /dev/null +++ b/src/DrawingProgram/Layers/FogStripper.hpp @@ -0,0 +1,45 @@ +/* + * InfiniPaint + * Copyright (C) 2025-2026 Yousef Khadadeh + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once +#include +#include +#include + +class DrawingProgramLayerManager; +class DrawingProgramLayerListItem; +class World; + +// Host-side projection only. Never mutates the host world graph. +namespace FogStripper { + constexpr float OPAQUE_ALPHA_THRESHOLD = 0.95f; + + bool world_is_host(const World& world); + + // Component NetObjIDs that must be omitted from player join/live views. + std::unordered_set compute_hidden_component_ids(DrawingProgramLayerManager& layerMan); + + // Resources still referenced by the player-visible projection (not hidden, plus fog paint). + std::unordered_set compute_visible_resource_ids(DrawingProgramLayerManager& layerMan, const std::unordered_set& hiddenComponentIds); + + // Write layerTreeRoot create message with hidden components omitted from leaf lists. + void write_stripped_layer_tree(const DrawingProgramLayerListItem& root, cereal::PortableBinaryOutputArchive& a, const std::unordered_set& hiddenComponentIds); + + // After fog geometry/flags change: erase newly hidden IDs from clients; insert newly revealed ones. + void sync_hidden_set_to_clients(World& world, DrawingProgramLayerManager& layerMan); +} diff --git a/src/DrawingProgram/Tools/BrushTool.cpp b/src/DrawingProgram/Tools/BrushTool.cpp index e3a8bf2..8df9582 100644 --- a/src/DrawingProgram/Tools/BrushTool.cpp +++ b/src/DrawingProgram/Tools/BrushTool.cpp @@ -68,6 +68,8 @@ void BrushTool::input_mouse_button_on_canvas_callback(const InputManager::MouseB BrushComponentCode::mouse_button(drawP, genData, newMeshContainer->coords, button, relativeWidthResult.first.value()); objInfoBeingEdited = drawP.layerMan.add_component_to_layer_being_edited(newMeshContainer); + if(!objInfoBeingEdited) + return; commit_data(false); } else if(!button.down && objInfoBeingEdited) diff --git a/src/DrawingProgram/Tools/EditTool.cpp b/src/DrawingProgram/Tools/EditTool.cpp index d849c0a..dcda205 100644 --- a/src/DrawingProgram/Tools/EditTool.cpp +++ b/src/DrawingProgram/Tools/EditTool.cpp @@ -267,7 +267,10 @@ void EditTool::edit_start(CanvasComponentContainer::ObjInfo* comp, bool initUndo } bool EditTool::is_editable(CanvasComponentContainer::ObjInfo* comp) { - return true; // Brush strokes used to not be editable, but now they are so this always returns true + if(!comp || !comp->obj) + return false; + // Host may edit fog; players may not (fog / player-locked layers). + return drawP.layerMan.can_current_peer_edit_layer(comp->obj->parentLayer); } void EditTool::tool_update() { diff --git a/src/DrawingProgram/Tools/EditTools/ImageEditTool.cpp b/src/DrawingProgram/Tools/EditTools/ImageEditTool.cpp index db0c4c4..e5b1a57 100644 --- a/src/DrawingProgram/Tools/EditTools/ImageEditTool.cpp +++ b/src/DrawingProgram/Tools/EditTools/ImageEditTool.cpp @@ -88,12 +88,39 @@ void ImageEditTool::edit_gui(Toolbar& t) { }, resourceData->name, true); } }); + text_button(gui, "file freistellen", "Freistellen", { + .wide = true, + .onClick = [&] { + drawP.process_images_on({comp}, true, true); + } + }); } else text_label_centered(gui, "Loading resource..."); }); } +void ImageEditTool::right_click_popup_gui(Toolbar& t, Vector2f popupPos) { + using namespace GUIStuff; + using namespace ElementHelpers; + + auto& gui = drawP.world.main.g.gui; + drawP.right_click_action_menu(popupPos, [&] { + text_label_light(gui, "Image"); + drawP.popup_menu_action_button("Freistellen", "Freistellen", [&] { + drawP.process_images_on({comp}, true, true); + }); + drawP.popup_menu_action_button("Paste Image", "Paste Image", [&, popupPos] { + drawP.selection.deselect_all(); + drawP.request_clipboard_image_paste(popupPos * drawP.world.main.g.final_gui_scale(), false); + }); + drawP.popup_menu_action_button("Paste Image Freistellen", "Paste Image + Freistellen", [&, popupPos] { + drawP.selection.deselect_all(); + drawP.request_clipboard_image_paste(popupPos * drawP.world.main.g.final_gui_scale(), true); + }); + }); +} + void ImageEditTool::gui_phone_toolbox(PhoneDrawingProgramScreen& t) { using namespace GUIStuff; using namespace ElementHelpers; diff --git a/src/DrawingProgram/Tools/EditTools/ImageEditTool.hpp b/src/DrawingProgram/Tools/EditTools/ImageEditTool.hpp index 9dce97f..b067332 100644 --- a/src/DrawingProgram/Tools/EditTools/ImageEditTool.hpp +++ b/src/DrawingProgram/Tools/EditTools/ImageEditTool.hpp @@ -30,4 +30,5 @@ class ImageEditTool : public DrawingProgramEditToolBase { virtual void edit_update() override; virtual void edit_gui(Toolbar& t) override; virtual void gui_phone_toolbox(PhoneDrawingProgramScreen& t) override; + virtual void right_click_popup_gui(Toolbar& t, Vector2f popupPos) override; }; diff --git a/src/DrawingProgram/Tools/EllipseDrawTool.cpp b/src/DrawingProgram/Tools/EllipseDrawTool.cpp index 2efd6b5..2a64f39 100644 --- a/src/DrawingProgram/Tools/EllipseDrawTool.cpp +++ b/src/DrawingProgram/Tools/EllipseDrawTool.cpp @@ -101,6 +101,8 @@ void EllipseDrawTool::input_mouse_button_on_canvas_callback(const InputManager:: newContainer->coords = drawP.world.drawData.cam.c; objInfoBeingEdited = drawP.layerMan.add_component_to_layer_being_edited(newContainer); + if(!objInfoBeingEdited) + return; } else if(!button.down && objInfoBeingEdited) commit(); diff --git a/src/DrawingProgram/Tools/LineDrawTool.cpp b/src/DrawingProgram/Tools/LineDrawTool.cpp index 1314d36..6ead1ae 100644 --- a/src/DrawingProgram/Tools/LineDrawTool.cpp +++ b/src/DrawingProgram/Tools/LineDrawTool.cpp @@ -107,6 +107,8 @@ void LineDrawTool::input_mouse_button_on_canvas_callback(const InputManager::Mou p.pos = ensure_points_have_distance(p.pos, p.pos, 1.0f); brushPoints.emplace_back(p); objInfoBeingEdited = drawP.layerMan.add_component_to_layer_being_edited(newMeshContainer); + if(!objInfoBeingEdited) + return; commit_data(false); } else if(!button.down && objInfoBeingEdited) diff --git a/src/DrawingProgram/Tools/RectDrawTool.cpp b/src/DrawingProgram/Tools/RectDrawTool.cpp index c233a7d..839e95d 100644 --- a/src/DrawingProgram/Tools/RectDrawTool.cpp +++ b/src/DrawingProgram/Tools/RectDrawTool.cpp @@ -107,6 +107,8 @@ void RectDrawTool::input_mouse_button_on_canvas_callback(const InputManager::Mou newRectangle.d.fillStrokeMode = static_cast(fillStrokeMode); newContainer->coords = drawP.world.drawData.cam.c; objInfoBeingEdited = drawP.layerMan.add_component_to_layer_being_edited(newContainer); + if(!objInfoBeingEdited) + return; } else if(!button.down && objInfoBeingEdited) commit(); diff --git a/src/DrawingProgram/Tools/TextBoxTool.cpp b/src/DrawingProgram/Tools/TextBoxTool.cpp index e38fbfe..a3af125 100644 --- a/src/DrawingProgram/Tools/TextBoxTool.cpp +++ b/src/DrawingProgram/Tools/TextBoxTool.cpp @@ -67,6 +67,8 @@ void TextBoxTool::input_mouse_button_on_canvas_callback(const InputManager::Mous newContainer->coords = drawP.world.drawData.cam.c; newContainer->coords.scale_about_double(drawP.world.drawData.cam.c.from_space(startAt), 1.0 / drawP.world.main.get_scale_and_density_factor_gui()); objInfoBeingEdited = drawP.layerMan.add_component_to_layer_being_edited(newContainer); + if(!objInfoBeingEdited) + return; } else if(!button.down && objInfoBeingEdited) { make_sure_textbox_is_big(); diff --git a/src/GlobalConfig.cpp b/src/GlobalConfig.cpp index 5d662ff..a6efc1a 100644 --- a/src/GlobalConfig.cpp +++ b/src/GlobalConfig.cpp @@ -154,10 +154,29 @@ void GlobalConfig::set_config_json(InputManager& input, const nlohmann::json& j, try{j.at("flipZoomToolDirection").get_to(flipZoomToolDirection);} catch(...) {} try{j.at("realTimeEraser").get_to(realTimeEraser);} catch(...) {} try{j.at("imageCompressOnInsert").get_to(imageCompressOnInsert);} catch(...) {} - try{j.at("imageCompressFormat").get_to(imageCompressFormat);} catch(...) {} + try { + const auto& v = j.at("imageCompressFormat"); + if(v.is_string()) + v.get_to(imageCompressFormat); + else if(v.is_number_integer()) { + // Legacy: enums were saved as ints before ADL serialize lived in ImagePreprocess. + int n = v.get(); + if(n >= 0 && n <= 2) + imageCompressFormat = static_cast(n); + } + } catch(...) {} try{j.at("imageWebpQuality").get_to(imageWebpQuality);} catch(...) {} try{j.at("imageFreistellenOnInsert").get_to(imageFreistellenOnInsert);} catch(...) {} - try{j.at("imageFreistellenMode").get_to(imageFreistellenMode);} catch(...) {} + try { + const auto& v = j.at("imageFreistellenMode"); + if(v.is_string()) + v.get_to(imageFreistellenMode); + else if(v.is_number_integer()) { + int n = v.get(); + if(n >= 0 && n <= 3) + imageFreistellenMode = static_cast(n); + } + } catch(...) {} try{j.at("imageWhiteKeyThreshold").get_to(imageWhiteKeyThreshold);} catch(...) {} #ifndef __EMSCRIPTEN__ try{j.at("checkForUpdates").get_to(checkForUpdates);} catch(...) {} diff --git a/src/GlobalConfig.hpp b/src/GlobalConfig.hpp index 5680974..0eabe61 100644 --- a/src/GlobalConfig.hpp +++ b/src/GlobalConfig.hpp @@ -144,16 +144,3 @@ NLOHMANN_JSON_SERIALIZE_ENUM(GlobalConfig::AntiAliasing, { {GlobalConfig::AntiAliasing::SKIA, "Skia"}, {GlobalConfig::AntiAliasing::DYNAMIC_MSAA, "Dynamic MSAA"}, }) - -NLOHMANN_JSON_SERIALIZE_ENUM(ImagePreprocess::CompressFormat, { - {ImagePreprocess::CompressFormat::KEEP, "Keep"}, - {ImagePreprocess::CompressFormat::WEBP, "WebP"}, - {ImagePreprocess::CompressFormat::PNG, "PNG"}, -}) - -NLOHMANN_JSON_SERIALIZE_ENUM(ImagePreprocess::FreistellenMode, { - {ImagePreprocess::FreistellenMode::OFF, "Off"}, - {ImagePreprocess::FreistellenMode::WHITE_KEY, "WhiteKey"}, - {ImagePreprocess::FreistellenMode::REMBG_SCHNELL, "RembgSchnell"}, - {ImagePreprocess::FreistellenMode::REMBG_ALLGEMEIN, "RembgAllgemein"}, -}) diff --git a/src/ImagePreprocess.cpp b/src/ImagePreprocess.cpp index b2c2f15..fc70848 100644 --- a/src/ImagePreprocess.cpp +++ b/src/ImagePreprocess.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -35,6 +36,13 @@ #ifndef __EMSCRIPTEN__ #include #include +#include +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif #endif namespace ImagePreprocess { @@ -130,47 +138,182 @@ std::optional encode_bitmap(const SkBitmap& bitmap, CompressFormat } #ifndef __EMSCRIPTEN__ -std::optional find_rembg_bin() { - const char* envBin = std::getenv("TOWER_REMBG_BIN"); - if(envBin && envBin[0] && std::filesystem::is_regular_file(envBin)) - return std::string(envBin); - const char* home = std::getenv("HOME"); - if(home) { - std::filesystem::path venv = std::filesystem::path(home) / ".local/share/tower-rembg/bin/rembg"; - if(std::filesystem::is_regular_file(venv)) - return venv.string(); - std::filesystem::path local = std::filesystem::path(home) / ".local/bin/rembg"; - if(std::filesystem::is_regular_file(local)) - return local.string(); +std::filesystem::path exe_parent_dir() { +#ifdef _WIN32 + constexpr DWORD STEP = 2048; + std::string pathStr; + DWORD copied = 0; + do { + pathStr.resize(pathStr.size() + STEP); + copied = GetModuleFileNameA(nullptr, pathStr.data(), static_cast(pathStr.size())); + } while(copied >= pathStr.size()); + pathStr.resize(copied); + return std::filesystem::path(pathStr).parent_path(); +#elif defined(__linux__) + std::error_code ec; + auto p = std::filesystem::canonical("/proc/self/exe", ec); + if(ec) + return std::filesystem::current_path(); + return p.parent_path(); +#else + return std::filesystem::current_path(); +#endif +} + +std::filesystem::path user_home_dir() { + if(const char* h = std::getenv("HOME"); h && h[0]) + return h; +#ifdef _WIN32 + if(const char* u = std::getenv("USERPROFILE"); u && u[0]) + return u; +#endif + return {}; +} + +bool is_runnable_file(const std::filesystem::path& p) { + std::error_code ec; + return std::filesystem::is_regular_file(p, ec); +} + +std::optional first_existing(std::initializer_list cands) { + for(const auto& p : cands) { + if(!p.empty() && is_runnable_file(p)) + return p.string(); } - // PATH lookup via `command -v` + return std::nullopt; +} + +std::optional find_rembg_bin() { + if(const char* envBin = std::getenv("TOWER_REMBG_BIN"); envBin && envBin[0] && is_runnable_file(envBin)) + return std::string(envBin); + + auto exe = exe_parent_dir(); + auto home = user_home_dir(); +#ifdef _WIN32 + if(auto f = first_existing({ + exe / "rembg" / "Scripts" / "rembg.exe", + exe / "rembg" / "bin" / "rembg.exe", + exe / "rembg" / "rembg.exe", + exe / "data" / "rembg" / "Scripts" / "rembg.exe", + home / "AppData" / "Local" / "tower-rembg" / "Scripts" / "rembg.exe", + home / ".local" / "share" / "tower-rembg" / "Scripts" / "rembg.exe", + home / ".local" / "bin" / "rembg.exe" + })) + return f; + FILE* pipe = _popen("where rembg 2>NUL", "r"); +#else + if(auto f = first_existing({ + exe / "rembg" / "bin" / "rembg", + exe / "rembg" / "rembg", + exe / "data" / "rembg" / "bin" / "rembg", + home / ".local" / "share" / "infinipaint" / "rembg" / "bin" / "rembg", + home / ".local" / "share" / "tower-rembg" / "bin" / "rembg", + home / ".local" / "bin" / "rembg" + })) + return f; FILE* pipe = popen("command -v rembg 2>/dev/null", "r"); +#endif if(!pipe) return std::nullopt; char buf[512]; std::string path; while(fgets(buf, sizeof(buf), pipe)) path += buf; +#ifdef _WIN32 + _pclose(pipe); +#else pclose(pipe); +#endif while(!path.empty() && (path.back() == '\n' || path.back() == '\r')) path.pop_back(); - if(!path.empty() && std::filesystem::is_regular_file(path)) + // `where` can return multiple lines; take the first + auto nl = path.find('\n'); + if(nl != std::string::npos) + path.resize(nl); + if(!path.empty() && is_runnable_file(path)) return path; return std::nullopt; } +std::filesystem::path rembg_home_dir() { + if(const char* e = std::getenv("REMBG_HOME"); e && e[0]) + return e; + if(const char* u = std::getenv("U2NET_HOME"); u && u[0]) + return u; + auto exe = exe_parent_dir(); + std::error_code ec; + auto bundled = exe / "rembg-home"; + if(std::filesystem::is_directory(bundled / "models", ec)) + return bundled; + auto nextToRembg = exe / "rembg" / "models"; + if(std::filesystem::is_directory(nextToRembg, ec)) + return exe / "rembg"; + auto app = user_home_dir() / ".local" / "share" / "infinipaint" / "rembg-home"; + if(std::filesystem::is_directory(app / "models", ec)) + return app; + auto home = user_home_dir(); + if(!home.empty()) + return home / ".rembg"; + return {}; +} + +bool model_file_exists(std::string_view model) { + std::string name(model); + std::error_code ec; + auto home = rembg_home_dir(); + std::vector cands; + if(!home.empty()) { + cands.push_back(home / "models" / name / (name + ".onnx")); + cands.push_back(home / (name + ".onnx")); + } + auto xdg = std::getenv("XDG_DATA_HOME"); + if(xdg && xdg[0]) + cands.push_back(std::filesystem::path(xdg) / ".u2net" / (name + ".onnx")); + auto uh = user_home_dir(); + if(!uh.empty()) { + cands.push_back(uh / ".u2net" / (name + ".onnx")); + cands.push_back(uh / ".rembg" / "models" / name / (name + ".onnx")); + } + for(const auto& p : cands) { + if(std::filesystem::is_regular_file(p, ec) && std::filesystem::file_size(p, ec) > 1024) + return true; + } + return false; +} + +struct RembgCache { + std::optional bin; + bool silueta = false; + bool bria = false; +}; + +RembgCache& rembg_cache() { + // Rescan each call — CLI/models may appear after install-local without restart. + static RembgCache c; + c.bin = find_rembg_bin(); + c.silueta = model_file_exists("silueta"); + c.bria = model_file_exists("bria-rmbg"); + return c; +} + std::optional run_rembg(const std::string& inputData, const std::string& model) { - auto rembg = find_rembg_bin(); + auto rembg = rembg_cache().bin; if(!rembg) { - Logger::get().log(Logger::LogType::INFO, "[ImagePreprocess] rembg not found — falling back to white-key"); + Logger::get().log(Logger::LogType::DESKTOP_USERINFO, "[Freistellen] rembg CLI not found"); + return std::nullopt; + } + if((model == "silueta" && !rembg_cache().silueta) || (model == "bria-rmbg" && !rembg_cache().bria)) { + Logger::get().log(Logger::LogType::DESKTOP_USERINFO, "[Freistellen] rembg model missing: " + model); return std::nullopt; } std::error_code ec; auto tmpDir = std::filesystem::temp_directory_path(ec); if(ec) return std::nullopt; - auto inPath = tmpDir / ("infinipaint-rembg-in-" + std::to_string(std::random_device{}()) + ".png"); - auto outPath = tmpDir / ("infinipaint-rembg-out-" + std::to_string(std::random_device{}()) + ".png"); + auto tag = std::to_string(std::random_device{}()); + auto inPath = tmpDir / ("infinipaint-rembg-in-" + tag + ".png"); + auto outPath = tmpDir / ("infinipaint-rembg-out-" + tag + ".png"); + auto errPath = tmpDir / ("infinipaint-rembg-err-" + tag + ".txt"); { std::ofstream out(inPath, std::ios::binary); @@ -179,7 +322,19 @@ std::optional run_rembg(const std::string& inputData, const std::st out.write(inputData.data(), static_cast(inputData.size())); } - std::string cmd = "\"" + *rembg + "\" i -m " + model + " \"" + inPath.string() + "\" \"" + outPath.string() + "\" 2>/dev/null"; + auto home = rembg_home_dir(); + // Match Tower freistellen inbox: post-process mask + decontaminate fringing. +#ifdef _WIN32 + std::string envPrefix; + if(!home.empty()) + envPrefix = "set \"REMBG_HOME=" + home.string() + "\" && set \"U2NET_HOME=" + home.string() + "\" && "; + std::string cmd = envPrefix + "\"" + *rembg + "\" i -m " + model + " -ppm -dc \"" + inPath.string() + "\" \"" + outPath.string() + "\" 2>\"" + errPath.string() + "\""; +#else + std::string cmd = "\"" + *rembg + "\" i -m " + model + " -ppm -dc \"" + inPath.string() + "\" \"" + outPath.string() + "\" 2>\"" + errPath.string() + "\""; + if(!home.empty()) + cmd = "REMBG_HOME=\"" + home.string() + "\" U2NET_HOME=\"" + home.string() + "\" " + cmd; +#endif + Logger::get().log(Logger::LogType::INFO, "[Freistellen] running rembg -m " + model + " -ppm -dc"); int rc = std::system(cmd.c_str()); std::optional result; if(rc == 0 && std::filesystem::is_regular_file(outPath)) { @@ -190,8 +345,21 @@ std::optional run_rembg(const std::string& inputData, const std::st result.reset(); } } + if(!result) { + std::string err; + { + std::ifstream e(errPath); + if(e) + err = std::string(std::istreambuf_iterator(e), std::istreambuf_iterator()); + } + if(err.size() > 400) + err.resize(400); + Logger::get().log(Logger::LogType::DESKTOP_USERINFO, + "[Freistellen] rembg failed (rc=" + std::to_string(rc) + ")" + (err.empty() ? "" : ": " + err)); + } std::filesystem::remove(inPath, ec); std::filesystem::remove(outPath, ec); + std::filesystem::remove(errPath, ec); return result; } #endif @@ -227,12 +395,24 @@ Options options_from_config(const GlobalConfig& conf) { o.compressOnInsert = conf.imageCompressOnInsert; o.compressFormat = conf.imageCompressFormat; o.webpQuality = conf.imageWebpQuality; - o.freistellenOnInsert = conf.imageFreistellenOnInsert; + o.freistellenOnInsert = false; o.freistellenMode = conf.imageFreistellenMode; +#ifndef __EMSCRIPTEN__ + if(!freistellen_mode_available(o.freistellenMode)) + o.freistellenMode = FreistellenMode::WHITE_KEY; +#endif o.whiteKeyThreshold = conf.imageWhiteKeyThreshold; return o; } +Options options_for_place(const GlobalConfig& conf, bool applyFreistellen) { + Options o = options_from_config(conf); + o.freistellenOnInsert = applyFreistellen; + if(applyFreistellen && o.freistellenMode == FreistellenMode::OFF) + o.freistellenMode = FreistellenMode::WHITE_KEY; + return o; +} + bool preprocess_resource(std::string& name, std::shared_ptr& data, const Options& options) { if(!data || data->empty()) return false; @@ -264,17 +444,7 @@ bool preprocess_resource(std::string& name, std::shared_ptr& data, changed = true; name = replace_extension(name, ".png"); } - else { - // Fallback white-key - if(decode_first_frame(working, bmp)) { - apply_white_key(bmp, options.whiteKeyThreshold); - if(auto enc = encode_bitmap(bmp, CompressFormat::PNG, 100)) { - working = *enc; - changed = true; - name = replace_extension(name, ".png"); - } - } - } + // Do not fall back to white-key — that looked "instant" and much worse than Tower. } else #endif @@ -312,4 +482,74 @@ bool preprocess_resource(std::string& name, std::shared_ptr& data, return changed; } +std::optional estimate_compress_size(std::string_view name, std::string_view data, CompressFormat format, int webpQuality) { + if(data.empty() || !looks_like_raster_image(name, data)) + return std::nullopt; + CompressSizeEstimate out; + out.currentBytes = static_cast(data.size()); + out.estimatedBytes = out.currentBytes; + if(format == CompressFormat::KEEP) + return out; + SkBitmap bmp; + std::string working(data); + if(!decode_first_frame(working, bmp)) + return out; + auto enc = encode_bitmap(bmp, format, webpQuality); + if(!enc) + return out; + bool preferNew = enc->size() < working.size() || format == CompressFormat::WEBP; + if(preferNew) + out.estimatedBytes = static_cast(enc->size()); + return out; +} + +std::string format_byte_size(uint64_t bytes) { + const char* units[] = {"B", "KB", "MB", "GB", "TB"}; + double v = static_cast(bytes); + int u = 0; + while(v >= 1024.0 && u < 4) { + v /= 1024.0; + u++; + } + char buf[64]; + if(u == 0) + std::snprintf(buf, sizeof(buf), "%llu B", static_cast(bytes)); + else + std::snprintf(buf, sizeof(buf), "%.1f %s", v, units[u]); + return buf; +} + +#ifndef __EMSCRIPTEN__ +bool rembg_cli_available() { + return rembg_cache().bin.has_value(); +} + +bool rembg_model_available(std::string_view model) { + auto& c = rembg_cache(); + if(model == "silueta") + return c.silueta; + if(model == "bria-rmbg") + return c.bria; + return model_file_exists(model); +} + +bool freistellen_mode_available(FreistellenMode mode) { + switch(mode) { + case FreistellenMode::OFF: + case FreistellenMode::WHITE_KEY: + return true; + case FreistellenMode::REMBG_SCHNELL: + return rembg_cli_available() && rembg_model_available("silueta"); + case FreistellenMode::REMBG_ALLGEMEIN: + return rembg_cli_available() && rembg_model_available("bria-rmbg"); + } + return false; +} + +std::string rembg_cli_path_string() { + auto& c = rembg_cache(); + return c.bin ? *c.bin : std::string{}; +} +#endif + } diff --git a/src/ImagePreprocess.hpp b/src/ImagePreprocess.hpp index bc7c485..1fec263 100644 --- a/src/ImagePreprocess.hpp +++ b/src/ImagePreprocess.hpp @@ -6,8 +6,10 @@ #pragma once #include #include +#include #include #include +#include class GlobalConfig; @@ -26,6 +28,20 @@ enum class FreistellenMode : uint8_t { REMBG_ALLGEMEIN // rembg -m bria-rmbg (Tower freistellen/allgemein) }; +// Must live in this namespace so nlohmann ADL serializes strings (not raw ints). +NLOHMANN_JSON_SERIALIZE_ENUM(CompressFormat, { + {CompressFormat::KEEP, "Keep"}, + {CompressFormat::WEBP, "WebP"}, + {CompressFormat::PNG, "PNG"}, +}) + +NLOHMANN_JSON_SERIALIZE_ENUM(FreistellenMode, { + {FreistellenMode::OFF, "Off"}, + {FreistellenMode::WHITE_KEY, "WhiteKey"}, + {FreistellenMode::REMBG_SCHNELL, "RembgSchnell"}, + {FreistellenMode::REMBG_ALLGEMEIN, "RembgAllgemein"}, +}) + struct Options { bool compressOnInsert = true; CompressFormat compressFormat = CompressFormat::WEBP; @@ -39,7 +55,29 @@ struct Options { bool preprocess_resource(std::string& name, std::shared_ptr& data, const Options& options); Options options_from_config(const GlobalConfig& conf); +/** Compress from settings; freistellen only if applyFreistellen (never from the old on-insert checkbox). */ +Options options_for_place(const GlobalConfig& conf, bool applyFreistellen); + +/** Dry-run encode only (no freistellen, no mutation). Nullopt if not a raster. KEEP is treated as no-op (estimated == current). */ +struct CompressSizeEstimate { + uint64_t currentBytes = 0; + uint64_t estimatedBytes = 0; +}; +std::optional estimate_compress_size(std::string_view name, std::string_view data, CompressFormat format, int webpQuality); + +std::string format_byte_size(uint64_t bytes); bool looks_like_raster_image(std::string_view name, std::string_view data); +#ifndef __EMSCRIPTEN__ +/** rembg CLI found (bundled next to app, Tower venv, PATH, or TOWER_REMBG_BIN). */ +bool rembg_cli_available(); +/** Model files present under REMBG_HOME / next to bundled rembg / ~/.rembg. model: "silueta" or "bria-rmbg". */ +bool rembg_model_available(std::string_view model); +/** CLI + model for this freistellen mode (WHITE_KEY/OFF always true). */ +bool freistellen_mode_available(FreistellenMode mode); +/** Short path for UI status, or empty. */ +std::string rembg_cli_path_string(); +#endif + } diff --git a/src/InputManager.cpp b/src/InputManager.cpp index 016652f..061e9d5 100644 --- a/src/InputManager.cpp +++ b/src/InputManager.cpp @@ -804,14 +804,16 @@ void InputManager::call_paste(CustomEvents::PasteEvent::DataType type, const Inp case CustomEvents::PasteEvent::DataType::IMAGE: { struct PasteData { std::optional pos; + bool applyFreistellen; InputManager* t; }; static PasteData pasteData; pasteData.pos = info.pastePosition; + pasteData.applyFreistellen = info.applyFreistellen; pasteData.t = this; emscripten_browser_clipboard::paste_async_image([](std::string_view pasteData, void* callbackData){ PasteData* p = (PasteData*)callbackData; - p->t->process_image_paste(pasteData, p->pos); + p->t->process_image_paste(pasteData, p->pos, p->applyFreistellen); }, &pasteData); break; } @@ -823,7 +825,7 @@ void InputManager::call_paste(CustomEvents::PasteEvent::DataType type, const Inp break; case CustomEvents::PasteEvent::DataType::IMAGE: get_clipboard_image_data_SDL([&](std::string_view pasteData) { - process_image_paste(pasteData, info.pastePosition); + process_image_paste(pasteData, info.pastePosition, info.applyFreistellen); }); break; } @@ -838,11 +840,12 @@ void InputManager::process_text_paste(const std::string& pasteStr, bool allowRic }); } -void InputManager::process_image_paste(std::string_view pasteData, const std::optional& pastePos) { +void InputManager::process_image_paste(std::string_view pasteData, const std::optional& pastePos, bool applyFreistellen) { CustomEvents::emit_event({ .type = CustomEvents::PasteEvent::DataType::IMAGE, .data = std::string(pasteData), - .mousePos = pastePos + .mousePos = pastePos, + .applyFreistellen = applyFreistellen }); } diff --git a/src/InputManager.hpp b/src/InputManager.hpp index f42b110..fb1d677 100644 --- a/src/InputManager.hpp +++ b/src/InputManager.hpp @@ -44,6 +44,7 @@ class MainProgram; struct InputManagerCallPasteInfo { std::optional pastePosition; bool allowRichText = true; + bool applyFreistellen = false; }; struct InputManager { @@ -286,7 +287,7 @@ struct InputManager { void call_paste(CustomEvents::PasteEvent::DataType type, const InputManagerCallPasteInfo& info = {}); void process_text_paste(const std::string& pasteStr, bool allowRichText); - void process_image_paste(std::string_view pasteData, const std::optional& pastePos); + void process_image_paste(std::string_view pasteData, const std::optional& pastePos, bool applyFreistellen = false); std::optional lastCopiedRichText; diff --git a/src/Toolbar.cpp b/src/Toolbar.cpp index b1f462a..9cc1f4d 100644 --- a/src/Toolbar.cpp +++ b/src/Toolbar.cpp @@ -1544,21 +1544,78 @@ void Toolbar::general_settings_inner_gui() { {"PNG", ImagePreprocess::CompressFormat::PNG} }); slider_scalar_field(gui, "webp quality", "WebP quality", &main.conf.imageWebpQuality, 1, 100); - checkbox_boolean_field(gui, "freistellen on insert", "Freistellen / remove background on insert", &main.conf.imageFreistellenOnInsert); - text_label(gui, "Freistellen mode:"); + if(main.world) { + auto sizes = main.world->drawProg.canvas_image_size_summary(); + mutable_text_label_light(gui, "canvas image size now", sizes.nowLine); + if(!sizes.afterLine.empty()) + mutable_text_label_light(gui, "canvas image size after", sizes.afterLine); + text_label_light(gui, "Unique raster ResourceData bytes (shared copies counted once). .infpnt is also zstd, so the save file is usually smaller than this plus drawing data."); + } + text_label(gui, "Freistellen (on demand):"); + text_label_light(gui, "Not applied on import. Choose when pasting (Ctrl+Shift+V or right-click → Paste Image + Freistellen), or right-click an image."); +#ifndef __EMSCRIPTEN__ + { + using FM = ImagePreprocess::FreistellenMode; + const char* modeLabel = "Off"; + switch(main.conf.imageFreistellenMode) { + case FM::OFF: modeLabel = "Off (Freistellen falls back to white-key)"; break; + case FM::WHITE_KEY: modeLabel = "White background"; break; + case FM::REMBG_SCHNELL: modeLabel = "Schnell (silueta)"; break; + case FM::REMBG_ALLGEMEIN: modeLabel = "Allgemein (bria-rmbg)"; break; + } + text_label_light(gui, std::string("Active: ") + modeLabel); + } + auto rembg_mode_row = [&](const char* id, std::string_view label, ImagePreprocess::FreistellenMode mode, bool available) { + gui.new_id(id, [&] { + if(available) { + radio_button_field(gui, "Option", label, + [&]{ return main.conf.imageFreistellenMode == mode; }, + [&]{ + main.conf.imageFreistellenMode = mode; + main.save_config(); + }); + } + else { + text_label_light(gui, std::string(label) + " (unavailable — rembg/model not installed)"); + } + }); + }; + gui.new_id("freistellen mode", [&] { + rembg_mode_row("off", "Off", ImagePreprocess::FreistellenMode::OFF, true); + rembg_mode_row("white", "White background", ImagePreprocess::FreistellenMode::WHITE_KEY, true); + rembg_mode_row("schnell", "Schnell (rembg silueta)", ImagePreprocess::FreistellenMode::REMBG_SCHNELL, + ImagePreprocess::freistellen_mode_available(ImagePreprocess::FreistellenMode::REMBG_SCHNELL)); + rembg_mode_row("allgemein", "Allgemein (rembg bria)", ImagePreprocess::FreistellenMode::REMBG_ALLGEMEIN, + ImagePreprocess::freistellen_mode_available(ImagePreprocess::FreistellenMode::REMBG_ALLGEMEIN)); + }); + if(ImagePreprocess::rembg_cli_available()) { + text_label_light(gui, "rembg: " + ImagePreprocess::rembg_cli_path_string()); + } + else { + text_label_light(gui, "rembg not found. White-key still works. To enable Schnell/Allgemein, put a rembg venv in rembg/ next to the binary (models in rembg-home/models/) or install Tower rembg."); + } +#else radio_button_selector(gui, "freistellen mode", &main.conf.imageFreistellenMode, { {"Off", ImagePreprocess::FreistellenMode::OFF}, {"White background", ImagePreprocess::FreistellenMode::WHITE_KEY}, -#ifndef __EMSCRIPTEN__ - {"Schnell (rembg silueta)", ImagePreprocess::FreistellenMode::REMBG_SCHNELL}, - {"Allgemein (rembg bria)", ImagePreprocess::FreistellenMode::REMBG_ALLGEMEIN}, -#endif }); - slider_scalar_field(gui, "white key threshold", "White-key threshold", &main.conf.imageWhiteKeyThreshold, 200, 255); - text_label_light(gui, "Selection toolbar: Compress Images / Freistellen also work afterwards."); -#ifndef __EMSCRIPTEN__ - text_label_light(gui, "rembg uses Tower venv (~/.local/share/tower-rembg) when present."); #endif + slider_scalar_field(gui, "white key threshold", "White-key threshold", &main.conf.imageWhiteKeyThreshold, 200, 255); + text_label(gui, "Already on canvas:"); + text_button(gui, "compress all canvas images", "Compress all images on canvas", { + .onClick = [&] { + if(main.world) + main.world->drawProg.process_all_canvas_images(false, true); + } + }); + text_button(gui, "freistellen all canvas images", "Freistellen all images on canvas", { + .onClick = [&] { + if(main.world) + main.world->drawProg.process_all_canvas_images(true, true); + } + }); + text_label_light(gui, "Uses the format and quality above (Keep → WebP). Shared copies of the same file are compressed once. Can take a while on large canvases."); + text_label_light(gui, "Or select images and use Compress Images / Freistellen in the selection panel, or right-click."); }); break; } diff --git a/src/VersionConstants.cpp b/src/VersionConstants.cpp index a27c713..2138525 100644 --- a/src/VersionConstants.cpp +++ b/src/VersionConstants.cpp @@ -31,6 +31,7 @@ VersionNumber header_to_version_number(const std::string& header) { m["INFPNT000005"] = VersionNumber(0, 4, 0); m["INFPNT000006"] = VersionNumber(0, 6, 0); m["INFPNT000007"] = VersionNumber(0, 7, 0); + m["INFPNT000008"] = VersionNumber(0, 8, 0); } auto it = m.find(header); if(it == m.end()) diff --git a/src/VersionConstants.hpp b/src/VersionConstants.hpp index 78f3dfe..d01c73f 100644 --- a/src/VersionConstants.hpp +++ b/src/VersionConstants.hpp @@ -28,7 +28,7 @@ namespace VersionConstants { VersionNumber header_to_version_number(const std::string& header); constexpr int SAVEFILE_HEADER_LEN = 12; // DO NOT CHANGE THIS HEADER LENGTH - const std::string CURRENT_SAVEFILE_HEADER = "INFPNT000007"; // Change whenever the save file is incompatible with the previous version - const std::string CURRENT_VERSION_STRING = "0.7.0-fork"; - constexpr VersionNumber CURRENT_VERSION_NUMBER(0, 7, 0); + const std::string CURRENT_SAVEFILE_HEADER = "INFPNT000008"; // Change whenever the save file is incompatible with the previous version + const std::string CURRENT_VERSION_STRING = "0.8.0-fork"; + constexpr VersionNumber CURRENT_VERSION_NUMBER(0, 8, 0); } diff --git a/src/World.cpp b/src/World.cpp index 9b41660..3cecd46 100644 --- a/src/World.cpp +++ b/src/World.cpp @@ -27,6 +27,7 @@ #include #include #include "DrawingProgram/Layers/DrawingProgramLayerListItem.hpp" +#include "DrawingProgram/Layers/FogStripper.hpp" #include "FingerInputTracker.hpp" #include "Helpers/NetworkingObjects/NetObjOrderedList.hpp" #include "Helpers/NetworkingObjects/NetObjTemporaryPtr.decl.hpp" @@ -449,13 +450,19 @@ void World::start_hosting(const std::string& initNetSource, const std::string& s #endif } netServer->send_string_stream_to_client(client, RELIABLE_COMMAND_CHANNEL, ss); + auto visibleResources = FogStripper::compute_visible_resource_ids(drawProg.layerMan, drawProg.layerMan.clientHiddenComponentIds); for(auto& r : rMan.resource_list()) { + if(!visibleResources.contains(r.get_net_id())) + continue; netServer->send_items_to_client(client, RESOURCE_COMMAND_CHANNEL, CLIENT_NEW_RESOURCE_ID, r.get_net_id()); netServer->send_items_to_client(client, RESOURCE_COMMAND_CHANNEL, CLIENT_NEW_RESOURCE_DATA, *r); } }); netServer->add_recv_callback(SERVER_UPDATE_NETWORK_OBJECT, [&](std::shared_ptr client, cereal::PortableBinaryInputArchive& message) { + currentNetUpdateClient = client; netObjMan.read_update_message(message, client); + currentNetUpdateClient = nullptr; + drawProg.layerMan.flush_deferred_rejects(); }); netServer->add_recv_callback(SERVER_KEEP_ALIVE, [&](std::shared_ptr client, cereal::PortableBinaryInputArchive& message) { }); diff --git a/src/World.hpp b/src/World.hpp index 19639ee..7a25cfd 100644 --- a/src/World.hpp +++ b/src/World.hpp @@ -112,6 +112,8 @@ class World { std::shared_ptr netServer; std::shared_ptr netClient; + // Non-null only while the host is inside a client-originated NetObj update handler. + std::shared_ptr currentNetUpdateClient; void input_add_file_to_canvas_callback(const CustomEvents::AddFileToCanvasEvent& addFile); void input_paste_callback(const CustomEvents::PasteEvent& paste);